Skip to main content

idprova_core/dat/
constraints.rs

1//! RBAC Policy Engine — DAT constraint evaluators.
2//!
3//! Each evaluator is pure (no I/O, no async). The caller provides an
4//! [`EvaluationContext`] with all runtime values; evaluators return
5//! `Ok(())` on pass or `Err(ConstraintViolated)` on fail.
6
7use std::net::IpAddr;
8
9use serde::{Deserialize, Serialize};
10
11use crate::{IdprovaError, Result};
12
13// ────────────────────────────────────────────────────────────────────────────
14// Extended DatConstraints (replaces the minimal version in token.rs)
15// ────────────────────────────────────────────────────────────────────────────
16
17/// Full constraint set that can be embedded in a DAT.
18///
19/// All fields are optional — absent means "no restriction on this axis".
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct DatConstraints {
22    // ── existing fields (preserved for backwards compat) ──────────────────
23    /// Maximum total actions allowed under this DAT (lifetime cap).
24    #[serde(rename = "maxActions", skip_serializing_if = "Option::is_none")]
25    pub max_actions: Option<u64>,
26
27    /// Allowed MCP server hostnames/patterns.
28    #[serde(rename = "allowedServers", skip_serializing_if = "Option::is_none")]
29    pub allowed_servers: Option<Vec<String>>,
30
31    /// Whether every action MUST produce an Action Receipt.
32    #[serde(rename = "requireReceipt", skip_serializing_if = "Option::is_none")]
33    pub require_receipt: Option<bool>,
34
35    // ── Phase 2: rate limiting ─────────────────────────────────────────────
36    /// Sliding-window rate limit.
37    #[serde(rename = "rateLimit", skip_serializing_if = "Option::is_none")]
38    pub rate_limit: Option<RateLimit>,
39
40    // ── Phase 2: IP access control ─────────────────────────────────────────
41    /// CIDR ranges that are allowed to present this DAT.
42    /// If set, the request IP MUST match at least one entry.
43    #[serde(rename = "ipAllowlist", skip_serializing_if = "Option::is_none")]
44    pub ip_allowlist: Option<Vec<String>>,
45
46    /// CIDR ranges that are explicitly denied.
47    /// Evaluated AFTER allowlist — a deny always wins.
48    #[serde(rename = "ipDenylist", skip_serializing_if = "Option::is_none")]
49    pub ip_denylist: Option<Vec<String>>,
50
51    // ── Phase 2: trust level ───────────────────────────────────────────────
52    /// Minimum trust level the presenting agent must have (0–100 scale).
53    #[serde(rename = "minTrustLevel", skip_serializing_if = "Option::is_none")]
54    pub min_trust_level: Option<u8>,
55
56    // ── Phase 2: delegation depth ──────────────────────────────────────────
57    /// Maximum delegation chain depth allowed (0 = no re-delegation).
58    #[serde(rename = "maxDelegationDepth", skip_serializing_if = "Option::is_none")]
59    pub max_delegation_depth: Option<u32>,
60
61    // ── Phase 2: geofence ──────────────────────────────────────────────────
62    /// ISO 3166-1 alpha-2 country codes that are allowed.
63    /// If set, the request country MUST be in this list.
64    #[serde(rename = "allowedCountries", skip_serializing_if = "Option::is_none")]
65    pub allowed_countries: Option<Vec<String>>,
66
67    // ── Phase 2: time windows ──────────────────────────────────────────────
68    /// UTC time windows during which the DAT may be used.
69    #[serde(rename = "timeWindows", skip_serializing_if = "Option::is_none")]
70    pub time_windows: Option<Vec<TimeWindow>>,
71
72    // ── Phase 2: config attestation ────────────────────────────────────────
73    /// Required SHA-256 hex hash of the agent's config.
74    /// Stored in DatClaims.config_attestation; evaluator checks it matches.
75    #[serde(rename = "requiredConfigHash", skip_serializing_if = "Option::is_none")]
76    pub required_config_hash: Option<String>,
77}
78
79// ────────────────────────────────────────────────────────────────────────────
80// Supporting types
81// ────────────────────────────────────────────────────────────────────────────
82
83/// Sliding-window rate limit specification.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RateLimit {
86    /// Maximum number of actions within the window.
87    pub max_actions: u64,
88    /// Window duration in seconds.
89    pub window_secs: u64,
90}
91
92/// A UTC time window within which access is permitted.
93///
94/// `start_hour` / `end_hour` are in UTC (0–23, inclusive on both ends).
95/// If `days_of_week` is set, only those days are permitted (0=Monday, 6=Sunday).
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct TimeWindow {
98    /// Start hour (UTC, 0–23).
99    pub start_hour: u8,
100    /// End hour (UTC, 0–23, inclusive).
101    pub end_hour: u8,
102    /// Permitted days of week (0=Monday … 6=Sunday). None = every day.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub days_of_week: Option<Vec<u8>>,
105}
106
107// ────────────────────────────────────────────────────────────────────────────
108// Evaluation context — supplied by the caller at verification time
109// ────────────────────────────────────────────────────────────────────────────
110
111/// Runtime values provided by the verifier when evaluating a DAT.
112#[derive(Debug, Clone, Default)]
113pub struct EvaluationContext {
114    /// Number of actions already taken under this DAT in the current window.
115    pub actions_in_window: u64,
116
117    /// IP address of the agent presenting the DAT.
118    pub request_ip: Option<IpAddr>,
119
120    /// Trust level of the presenting agent (0–100).
121    pub agent_trust_level: Option<u8>,
122
123    /// Length of the delegation chain (0 = root token, 1 = one level deep, …).
124    pub delegation_depth: u32,
125
126    /// ISO 3166-1 alpha-2 country code of the request origin.
127    pub country_code: Option<String>,
128
129    /// Current UTC timestamp (seconds since Unix epoch).
130    /// If `None`, `Utc::now()` is used.
131    pub current_timestamp: Option<i64>,
132
133    /// SHA-256 hex hash of the agent's current config.
134    pub agent_config_hash: Option<String>,
135}
136
137// ────────────────────────────────────────────────────────────────────────────
138// Evaluators
139// ────────────────────────────────────────────────────────────────────────────
140
141impl DatConstraints {
142    /// Run all applicable evaluators against the provided context.
143    ///
144    /// Returns the first violation found, or `Ok(())` if everything passes.
145    pub fn evaluate(&self, ctx: &EvaluationContext) -> Result<()> {
146        self.eval_rate_limit(ctx)?;
147        self.eval_ip_allowlist(ctx)?;
148        self.eval_ip_denylist(ctx)?;
149        self.eval_trust_level(ctx)?;
150        self.eval_delegation_depth(ctx)?;
151        self.eval_geofence(ctx)?;
152        self.eval_time_windows(ctx)?;
153        // config_attestation is checked against DatClaims separately — see
154        // eval_config_attestation() which takes the token's stored hash.
155        Ok(())
156    }
157
158    // ── 1. Rate limiting ────────────────────────────────────────────────────
159
160    /// Checks that `ctx.actions_in_window` has not exceeded the rate limit.
161    ///
162    /// NOTE: This evaluator checks a snapshot supplied by the caller — it does
163    /// NOT maintain state itself (state lives in the runtime/middleware layer).
164    pub fn eval_rate_limit(&self, ctx: &EvaluationContext) -> Result<()> {
165        if let Some(rl) = &self.rate_limit {
166            if ctx.actions_in_window >= rl.max_actions {
167                return Err(IdprovaError::ConstraintViolated(format!(
168                    "rate limit exceeded: {} actions in {}s window (max {})",
169                    ctx.actions_in_window, rl.window_secs, rl.max_actions
170                )));
171            }
172        }
173        Ok(())
174    }
175
176    // ── 2. IP allowlist ─────────────────────────────────────────────────────
177
178    /// If `ip_allowlist` is set, the request IP must match at least one CIDR.
179    pub fn eval_ip_allowlist(&self, ctx: &EvaluationContext) -> Result<()> {
180        let allowlist = match &self.ip_allowlist {
181            Some(list) if !list.is_empty() => list,
182            _ => return Ok(()), // no restriction
183        };
184
185        let ip = match ctx.request_ip {
186            Some(ip) => ip,
187            None => {
188                return Err(IdprovaError::ConstraintViolated(
189                    "ip_allowlist is set but no request IP was provided".into(),
190                ))
191            }
192        };
193
194        for cidr in allowlist {
195            if cidr_contains(cidr, ip) {
196                return Ok(());
197            }
198        }
199
200        Err(IdprovaError::ConstraintViolated(format!(
201            "request IP {} is not in the allowlist",
202            ip
203        )))
204    }
205
206    // ── 3. IP denylist ──────────────────────────────────────────────────────
207
208    /// If the request IP matches any entry in `ip_denylist`, deny immediately.
209    pub fn eval_ip_denylist(&self, ctx: &EvaluationContext) -> Result<()> {
210        let denylist = match &self.ip_denylist {
211            Some(list) if !list.is_empty() => list,
212            _ => return Ok(()),
213        };
214
215        let ip = match ctx.request_ip {
216            Some(ip) => ip,
217            None => return Ok(()), // no IP supplied → can't match denylist
218        };
219
220        for cidr in denylist {
221            if cidr_contains(cidr, ip) {
222                return Err(IdprovaError::ConstraintViolated(format!(
223                    "request IP {} is in the denylist ({})",
224                    ip, cidr
225                )));
226            }
227        }
228
229        Ok(())
230    }
231
232    // ── 4. Trust level ──────────────────────────────────────────────────────
233
234    /// The agent's trust level must be >= `min_trust_level`.
235    pub fn eval_trust_level(&self, ctx: &EvaluationContext) -> Result<()> {
236        let min = match self.min_trust_level {
237            Some(m) => m,
238            None => return Ok(()),
239        };
240
241        let actual = match ctx.agent_trust_level {
242            Some(t) => t,
243            None => {
244                return Err(IdprovaError::ConstraintViolated(format!(
245                    "min_trust_level {} required but agent trust level was not provided",
246                    min
247                )))
248            }
249        };
250
251        if actual < min {
252            return Err(IdprovaError::ConstraintViolated(format!(
253                "agent trust level {} is below required minimum {}",
254                actual, min
255            )));
256        }
257
258        Ok(())
259    }
260
261    // ── 5. Delegation depth ─────────────────────────────────────────────────
262
263    /// The delegation chain depth must not exceed `max_delegation_depth`.
264    pub fn eval_delegation_depth(&self, ctx: &EvaluationContext) -> Result<()> {
265        let max = match self.max_delegation_depth {
266            Some(m) => m,
267            None => return Ok(()),
268        };
269
270        if ctx.delegation_depth > max {
271            return Err(IdprovaError::ConstraintViolated(format!(
272                "delegation depth {} exceeds maximum {}",
273                ctx.delegation_depth, max
274            )));
275        }
276
277        Ok(())
278    }
279
280    // ── 6. Geofence ─────────────────────────────────────────────────────────
281
282    /// If `allowed_countries` is set, the request country code must be listed.
283    pub fn eval_geofence(&self, ctx: &EvaluationContext) -> Result<()> {
284        let allowed = match &self.allowed_countries {
285            Some(list) if !list.is_empty() => list,
286            _ => return Ok(()),
287        };
288
289        let country = match &ctx.country_code {
290            Some(c) => c,
291            None => {
292                return Err(IdprovaError::ConstraintViolated(
293                    "allowed_countries is set but no country code was provided".into(),
294                ))
295            }
296        };
297
298        let upper = country.to_uppercase();
299        if allowed.iter().any(|a| a.to_uppercase() == upper) {
300            return Ok(());
301        }
302
303        Err(IdprovaError::ConstraintViolated(format!(
304            "country '{}' is not in the geofence allowlist",
305            country
306        )))
307    }
308
309    // ── 7. Time windows ─────────────────────────────────────────────────────
310
311    /// If `time_windows` is set, the current time must fall within at least one
312    /// window. Hours are evaluated in UTC.
313    pub fn eval_time_windows(&self, ctx: &EvaluationContext) -> Result<()> {
314        let windows = match &self.time_windows {
315            Some(w) if !w.is_empty() => w,
316            _ => return Ok(()),
317        };
318
319        let now_secs = ctx
320            .current_timestamp
321            .unwrap_or_else(|| chrono::Utc::now().timestamp());
322
323        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(now_secs, 0)
324            .ok_or_else(|| IdprovaError::ConstraintViolated("invalid timestamp".into()))?;
325
326        let hour = dt.hour() as u8;
327        // chrono weekday: Mon=0 … Sun=6
328        let dow = dt.weekday().num_days_from_monday() as u8;
329
330        for w in windows {
331            // Validate window configuration
332            if w.start_hour > 23 || w.end_hour > 23 {
333                return Err(IdprovaError::ConstraintViolated(
334                    "time window hour out of range (0-23)".into(),
335                ));
336            }
337
338            // Check day-of-week
339            if let Some(days) = &w.days_of_week {
340                if !days.contains(&dow) {
341                    continue;
342                }
343            }
344
345            // Check hour range (handles wrap-around e.g. 22–02 UTC)
346            let in_range = if w.start_hour <= w.end_hour {
347                hour >= w.start_hour && hour <= w.end_hour
348            } else {
349                // wrap: e.g. start=22, end=02
350                hour >= w.start_hour || hour <= w.end_hour
351            };
352
353            if in_range {
354                return Ok(());
355            }
356        }
357
358        Err(IdprovaError::ConstraintViolated(format!(
359            "current UTC hour {} is outside all permitted time windows",
360            hour
361        )))
362    }
363
364    // ── 8. Config attestation ───────────────────────────────────────────────
365
366    /// Verify that the agent's current config hash matches the one required by
367    /// the constraint AND the one recorded in the DAT claims.
368    ///
369    /// `token_config_hash` is the value from `DatClaims.config_attestation`.
370    pub fn eval_config_attestation(
371        &self,
372        ctx: &EvaluationContext,
373        token_config_hash: Option<&str>,
374    ) -> Result<()> {
375        let required = match &self.required_config_hash {
376            Some(h) => h,
377            None => return Ok(()),
378        };
379
380        // The token must carry a matching config_attestation claim.
381        let token_hash = match token_config_hash {
382            Some(h) => h,
383            None => return Err(IdprovaError::ConstraintViolated(
384                "required_config_hash constraint set but token carries no configAttestation claim"
385                    .into(),
386            )),
387        };
388
389        if token_hash != required {
390            return Err(IdprovaError::ConstraintViolated(format!(
391                "token configAttestation '{}' does not match required hash '{}'",
392                token_hash, required
393            )));
394        }
395
396        // The agent's live config must also match.
397        let live_hash =
398            match &ctx.agent_config_hash {
399                Some(h) => h,
400                None => return Err(IdprovaError::ConstraintViolated(
401                    "required_config_hash constraint set but agent config hash was not provided"
402                        .into(),
403                )),
404            };
405
406        if live_hash != required {
407            return Err(IdprovaError::ConstraintViolated(format!(
408                "agent live config hash '{}' does not match required '{}'",
409                live_hash, required
410            )));
411        }
412
413        Ok(())
414    }
415}
416
417// ────────────────────────────────────────────────────────────────────────────
418// CIDR matching — pure stdlib, no external dependencies
419// ────────────────────────────────────────────────────────────────────────────
420
421/// Returns `true` if `ip` falls within the CIDR block described by `cidr_str`.
422///
423/// Supports both IPv4 (`10.0.0.0/8`) and IPv6 (`::1/128`) CIDRs.
424/// A plain IP address with no prefix length is treated as /32 (IPv4) or /128 (IPv6).
425fn cidr_contains(cidr_str: &str, ip: IpAddr) -> bool {
426    let (addr_str, prefix_len) = match cidr_str.split_once('/') {
427        Some((a, p)) => (a, p.parse::<u32>().unwrap_or(128)),
428        None => (cidr_str, if cidr_str.contains(':') { 128 } else { 32 }),
429    };
430
431    let Ok(network_addr) = addr_str.parse::<IpAddr>() else {
432        return false;
433    };
434
435    match (network_addr, ip) {
436        (IpAddr::V4(net), IpAddr::V4(req)) => {
437            let prefix = prefix_len.min(32);
438            if prefix == 0 {
439                return true;
440            }
441            let shift = 32 - prefix;
442            (u32::from(net) >> shift) == (u32::from(req) >> shift)
443        }
444        (IpAddr::V6(net), IpAddr::V6(req)) => {
445            let prefix = prefix_len.min(128);
446            if prefix == 0 {
447                return true;
448            }
449            let net_bits = u128::from(net);
450            let req_bits = u128::from(req);
451            let shift = 128 - prefix;
452            (net_bits >> shift) == (req_bits >> shift)
453        }
454        // IPv4 vs IPv6 mismatch → never matches
455        _ => false,
456    }
457}
458
459// ────────────────────────────────────────────────────────────────────────────
460// Use chrono's time accessors
461// ────────────────────────────────────────────────────────────────────────────
462
463use chrono::Datelike;
464use chrono::Timelike;
465
466// ────────────────────────────────────────────────────────────────────────────
467// Tests
468// ────────────────────────────────────────────────────────────────────────────
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
474
475    fn ctx() -> EvaluationContext {
476        EvaluationContext::default()
477    }
478
479    // ── CIDR helper ─────────────────────────────────────────────────────────
480
481    #[test]
482    fn test_cidr_ipv4_exact() {
483        let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
484        assert!(cidr_contains("192.168.1.0/24", ip));
485        assert!(!cidr_contains("10.0.0.0/8", ip));
486    }
487
488    #[test]
489    fn test_cidr_ipv4_host() {
490        let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
491        assert!(cidr_contains("1.2.3.4", ip));
492        assert!(cidr_contains("1.2.3.4/32", ip));
493        assert!(!cidr_contains("1.2.3.5/32", ip));
494    }
495
496    #[test]
497    fn test_cidr_ipv4_slash0() {
498        let ip = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
499        assert!(cidr_contains("0.0.0.0/0", ip));
500    }
501
502    #[test]
503    fn test_cidr_ipv6() {
504        let ip = IpAddr::V6(Ipv6Addr::LOCALHOST);
505        assert!(cidr_contains("::1/128", ip));
506        assert!(!cidr_contains("fe80::/10", ip));
507    }
508
509    #[test]
510    fn test_cidr_mismatch_family() {
511        let ipv4 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
512        assert!(!cidr_contains("::1/128", ipv4));
513    }
514
515    // ── 1. Rate limiting ────────────────────────────────────────────────────
516
517    #[test]
518    fn test_rate_limit_pass() {
519        let c = DatConstraints {
520            rate_limit: Some(RateLimit {
521                max_actions: 10,
522                window_secs: 60,
523            }),
524            ..Default::default()
525        };
526        let mut cx = ctx();
527        cx.actions_in_window = 9;
528        assert!(c.eval_rate_limit(&cx).is_ok());
529    }
530
531    #[test]
532    fn test_rate_limit_exceeded() {
533        let c = DatConstraints {
534            rate_limit: Some(RateLimit {
535                max_actions: 10,
536                window_secs: 60,
537            }),
538            ..Default::default()
539        };
540        let mut cx = ctx();
541        cx.actions_in_window = 10;
542        let err = c.eval_rate_limit(&cx).unwrap_err();
543        assert!(err.to_string().contains("rate limit exceeded"));
544    }
545
546    #[test]
547    fn test_rate_limit_none() {
548        let c = DatConstraints::default();
549        assert!(c.eval_rate_limit(&ctx()).is_ok());
550    }
551
552    // ── 2. IP allowlist ─────────────────────────────────────────────────────
553
554    #[test]
555    fn test_ip_allowlist_pass() {
556        let c = DatConstraints {
557            ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
558            ..Default::default()
559        };
560        let mut cx = ctx();
561        cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)));
562        assert!(c.eval_ip_allowlist(&cx).is_ok());
563    }
564
565    #[test]
566    fn test_ip_allowlist_fail() {
567        let c = DatConstraints {
568            ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
569            ..Default::default()
570        };
571        let mut cx = ctx();
572        cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
573        assert!(c.eval_ip_allowlist(&cx).is_err());
574    }
575
576    #[test]
577    fn test_ip_allowlist_no_ip_provided() {
578        let c = DatConstraints {
579            ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
580            ..Default::default()
581        };
582        assert!(c.eval_ip_allowlist(&ctx()).is_err());
583    }
584
585    // ── 3. IP denylist ──────────────────────────────────────────────────────
586
587    #[test]
588    fn test_ip_denylist_blocked() {
589        let c = DatConstraints {
590            ip_denylist: Some(vec!["192.168.0.0/16".into()]),
591            ..Default::default()
592        };
593        let mut cx = ctx();
594        cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(192, 168, 5, 10)));
595        assert!(c.eval_ip_denylist(&cx).is_err());
596    }
597
598    #[test]
599    fn test_ip_denylist_pass() {
600        let c = DatConstraints {
601            ip_denylist: Some(vec!["192.168.0.0/16".into()]),
602            ..Default::default()
603        };
604        let mut cx = ctx();
605        cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
606        assert!(c.eval_ip_denylist(&cx).is_ok());
607    }
608
609    #[test]
610    fn test_ip_denylist_no_ip_is_ok() {
611        // No IP → can't match denylist → pass
612        let c = DatConstraints {
613            ip_denylist: Some(vec!["0.0.0.0/0".into()]),
614            ..Default::default()
615        };
616        assert!(c.eval_ip_denylist(&ctx()).is_ok());
617    }
618
619    // ── 4. Trust level ──────────────────────────────────────────────────────
620
621    #[test]
622    fn test_trust_level_pass() {
623        let c = DatConstraints {
624            min_trust_level: Some(50),
625            ..Default::default()
626        };
627        let mut cx = ctx();
628        cx.agent_trust_level = Some(75);
629        assert!(c.eval_trust_level(&cx).is_ok());
630    }
631
632    #[test]
633    fn test_trust_level_equal_passes() {
634        let c = DatConstraints {
635            min_trust_level: Some(80),
636            ..Default::default()
637        };
638        let mut cx = ctx();
639        cx.agent_trust_level = Some(80);
640        assert!(c.eval_trust_level(&cx).is_ok());
641    }
642
643    #[test]
644    fn test_trust_level_fail() {
645        let c = DatConstraints {
646            min_trust_level: Some(80),
647            ..Default::default()
648        };
649        let mut cx = ctx();
650        cx.agent_trust_level = Some(40);
651        assert!(c.eval_trust_level(&cx).is_err());
652    }
653
654    #[test]
655    fn test_trust_level_not_provided() {
656        let c = DatConstraints {
657            min_trust_level: Some(1),
658            ..Default::default()
659        };
660        assert!(c.eval_trust_level(&ctx()).is_err());
661    }
662
663    // ── 5. Delegation depth ─────────────────────────────────────────────────
664
665    #[test]
666    fn test_delegation_depth_pass() {
667        let c = DatConstraints {
668            max_delegation_depth: Some(3),
669            ..Default::default()
670        };
671        let mut cx = ctx();
672        cx.delegation_depth = 2;
673        assert!(c.eval_delegation_depth(&cx).is_ok());
674    }
675
676    #[test]
677    fn test_delegation_depth_at_limit() {
678        let c = DatConstraints {
679            max_delegation_depth: Some(3),
680            ..Default::default()
681        };
682        let mut cx = ctx();
683        cx.delegation_depth = 3;
684        assert!(c.eval_delegation_depth(&cx).is_ok());
685    }
686
687    #[test]
688    fn test_delegation_depth_exceeded() {
689        let c = DatConstraints {
690            max_delegation_depth: Some(2),
691            ..Default::default()
692        };
693        let mut cx = ctx();
694        cx.delegation_depth = 3;
695        assert!(c.eval_delegation_depth(&cx).is_err());
696    }
697
698    #[test]
699    fn test_delegation_depth_zero_no_redelegate() {
700        let c = DatConstraints {
701            max_delegation_depth: Some(0),
702            ..Default::default()
703        };
704        let mut cx = ctx();
705        cx.delegation_depth = 0;
706        assert!(c.eval_delegation_depth(&cx).is_ok());
707        cx.delegation_depth = 1;
708        assert!(c.eval_delegation_depth(&cx).is_err());
709    }
710
711    // ── 6. Geofence ─────────────────────────────────────────────────────────
712
713    #[test]
714    fn test_geofence_pass() {
715        let c = DatConstraints {
716            allowed_countries: Some(vec!["AU".into(), "NZ".into()]),
717            ..Default::default()
718        };
719        let mut cx = ctx();
720        cx.country_code = Some("AU".into());
721        assert!(c.eval_geofence(&cx).is_ok());
722    }
723
724    #[test]
725    fn test_geofence_case_insensitive() {
726        let c = DatConstraints {
727            allowed_countries: Some(vec!["AU".into()]),
728            ..Default::default()
729        };
730        let mut cx = ctx();
731        cx.country_code = Some("au".into());
732        assert!(c.eval_geofence(&cx).is_ok());
733    }
734
735    #[test]
736    fn test_geofence_fail() {
737        let c = DatConstraints {
738            allowed_countries: Some(vec!["AU".into()]),
739            ..Default::default()
740        };
741        let mut cx = ctx();
742        cx.country_code = Some("US".into());
743        assert!(c.eval_geofence(&cx).is_err());
744    }
745
746    #[test]
747    fn test_geofence_no_country_code() {
748        let c = DatConstraints {
749            allowed_countries: Some(vec!["AU".into()]),
750            ..Default::default()
751        };
752        assert!(c.eval_geofence(&ctx()).is_err());
753    }
754
755    // ── 7. Time windows ─────────────────────────────────────────────────────
756
757    #[test]
758    fn test_time_window_pass() {
759        // Timestamp: 2024-01-15 14:30 UTC = Monday (dow=0), hour=14
760        let ts = 1705327800_i64; // 2024-01-15T14:30:00Z
761        let c = DatConstraints {
762            time_windows: Some(vec![TimeWindow {
763                start_hour: 9,
764                end_hour: 17,
765                days_of_week: None,
766            }]),
767            ..Default::default()
768        };
769        let mut cx = ctx();
770        cx.current_timestamp = Some(ts);
771        assert!(c.eval_time_windows(&cx).is_ok());
772    }
773
774    #[test]
775    fn test_time_window_fail_outside_hours() {
776        // 2024-01-15T02:00:00Z — hour=2
777        let ts = 1705276800_i64;
778        let c = DatConstraints {
779            time_windows: Some(vec![TimeWindow {
780                start_hour: 9,
781                end_hour: 17,
782                days_of_week: None,
783            }]),
784            ..Default::default()
785        };
786        let mut cx = ctx();
787        cx.current_timestamp = Some(ts);
788        assert!(c.eval_time_windows(&cx).is_err());
789    }
790
791    #[test]
792    fn test_time_window_day_of_week_pass() {
793        // 2024-01-15T14:30:00Z = Monday = dow 0
794        let ts = 1705327800_i64;
795        let c = DatConstraints {
796            time_windows: Some(vec![TimeWindow {
797                start_hour: 9,
798                end_hour: 17,
799                days_of_week: Some(vec![0, 1, 2, 3, 4]), // Mon-Fri
800            }]),
801            ..Default::default()
802        };
803        let mut cx = ctx();
804        cx.current_timestamp = Some(ts);
805        assert!(c.eval_time_windows(&cx).is_ok());
806    }
807
808    #[test]
809    fn test_time_window_day_of_week_fail() {
810        // 2024-01-20T14:00:00Z = Saturday = dow 5
811        let ts = 1705759200_i64;
812        let c = DatConstraints {
813            time_windows: Some(vec![TimeWindow {
814                start_hour: 9,
815                end_hour: 17,
816                days_of_week: Some(vec![0, 1, 2, 3, 4]), // Mon-Fri only
817            }]),
818            ..Default::default()
819        };
820        let mut cx = ctx();
821        cx.current_timestamp = Some(ts);
822        assert!(c.eval_time_windows(&cx).is_err());
823    }
824
825    #[test]
826    fn test_time_window_wraparound() {
827        // Window 22–02 (overnight). Test at 23:00 UTC.
828        // 2024-01-15T23:00:00Z
829        let ts = 1705363200_i64;
830        let c = DatConstraints {
831            time_windows: Some(vec![TimeWindow {
832                start_hour: 22,
833                end_hour: 2,
834                days_of_week: None,
835            }]),
836            ..Default::default()
837        };
838        let mut cx = ctx();
839        cx.current_timestamp = Some(ts);
840        assert!(c.eval_time_windows(&cx).is_ok());
841    }
842
843    // ── 8. Config attestation ───────────────────────────────────────────────
844
845    #[test]
846    fn test_config_attestation_pass() {
847        let hash = "abc123def456".to_string();
848        let c = DatConstraints {
849            required_config_hash: Some(hash.clone()),
850            ..Default::default()
851        };
852        let mut cx = ctx();
853        cx.agent_config_hash = Some(hash.clone());
854        assert!(c.eval_config_attestation(&cx, Some(&hash)).is_ok());
855    }
856
857    #[test]
858    fn test_config_attestation_token_mismatch() {
859        let c = DatConstraints {
860            required_config_hash: Some("required_hash".into()),
861            ..Default::default()
862        };
863        let mut cx = ctx();
864        cx.agent_config_hash = Some("required_hash".into());
865        // token carries a different hash
866        assert!(c.eval_config_attestation(&cx, Some("other_hash")).is_err());
867    }
868
869    #[test]
870    fn test_config_attestation_live_mismatch() {
871        let c = DatConstraints {
872            required_config_hash: Some("required_hash".into()),
873            ..Default::default()
874        };
875        let mut cx = ctx();
876        cx.agent_config_hash = Some("different_hash".into());
877        assert!(c
878            .eval_config_attestation(&cx, Some("required_hash"))
879            .is_err());
880    }
881
882    #[test]
883    fn test_config_attestation_no_token_claim() {
884        let c = DatConstraints {
885            required_config_hash: Some("required_hash".into()),
886            ..Default::default()
887        };
888        let mut cx = ctx();
889        cx.agent_config_hash = Some("required_hash".into());
890        assert!(c.eval_config_attestation(&cx, None).is_err());
891    }
892
893    #[test]
894    fn test_config_attestation_no_constraint() {
895        let c = DatConstraints::default();
896        assert!(c.eval_config_attestation(&ctx(), None).is_ok());
897    }
898
899    // ── evaluate() composite ────────────────────────────────────────────────
900
901    #[test]
902    fn test_evaluate_all_pass() {
903        let c = DatConstraints {
904            rate_limit: Some(RateLimit {
905                max_actions: 100,
906                window_secs: 60,
907            }),
908            ip_allowlist: Some(vec!["10.0.0.0/8".into()]),
909            ip_denylist: Some(vec!["10.0.0.0/24".into()]), // deny narrow subnet
910            min_trust_level: Some(50),
911            max_delegation_depth: Some(3),
912            allowed_countries: Some(vec!["AU".into()]),
913            // time_windows: None → no time restriction
914            ..Default::default()
915        };
916        let mut cx = ctx();
917        cx.actions_in_window = 5;
918        cx.request_ip = Some(IpAddr::V4(Ipv4Addr::new(10, 1, 0, 1))); // /8 yes, /24 no
919        cx.agent_trust_level = Some(75);
920        cx.delegation_depth = 2;
921        cx.country_code = Some("AU".into());
922        assert!(c.evaluate(&cx).is_ok());
923    }
924
925    #[test]
926    fn test_evaluate_stops_at_first_violation() {
927        let c = DatConstraints {
928            rate_limit: Some(RateLimit {
929                max_actions: 1,
930                window_secs: 60,
931            }),
932            min_trust_level: Some(99), // would also fail
933            ..Default::default()
934        };
935        let mut cx = ctx();
936        cx.actions_in_window = 5; // rate limit fails first
937        cx.agent_trust_level = Some(10);
938        let err = c.evaluate(&cx).unwrap_err().to_string();
939        assert!(err.contains("rate limit exceeded"));
940    }
941}