Skip to main content

lab_proxy_core/
target_policy.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Target ceiling policy manifest representation, canonicalization, and
6//! narrowing verification.
7//!
8//! The target ceiling defines the immutable hardware boundaries and safety
9//! constraints for the proxy driver. Runtime configuration (such as structured
10//! configuration) may only narrow this ceiling, never widen it (Spec 9.2, 22).
11//!
12//! Manifests are canonicalized and digested via SHA-256 (Spec 9.6).
13
14use crate::access_policy::{
15    MmioResource, ResourceCeiling, ResourceId, ResourceKind, WritableRegister,
16};
17use crate::digest::Sha256Digest;
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use sha2::{Digest as _, Sha256};
20use std::collections::BTreeMap;
21use std::ops::Range;
22
23/// The schema version for target ceiling manifests.
24pub const TARGET_POLICY_SCHEMA_VERSION: u32 = 1;
25
26/// Maximum resources allowed by the wire contract.
27pub const MAX_RESOURCES: usize = 64;
28
29/// Maximum snapshot items allowed by the wire contract.
30pub const MAX_SNAPSHOT_ITEMS: u32 = 64;
31
32/// Maximum name length allowed for a resource.
33pub const MAX_NAME_LENGTH: usize = 64;
34
35/// A byte range `[start, end)` representing a hard-denied region.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
37pub struct RangeDto {
38    pub start: u64,
39    pub end: u64,
40}
41
42impl RangeDto {
43    /// Creates and validates a range.
44    pub fn new(start: u64, end: u64) -> Result<Self, PolicyValidationError> {
45        if start >= end {
46            return Err(PolicyValidationError::InvalidRange { start, end });
47        }
48        Ok(Self { start, end })
49    }
50
51    /// Converts to standard Rust range.
52    pub fn to_range(&self) -> Range<u64> {
53        self.start..self.end
54    }
55
56    /// Returns true if this range contains `offset`.
57    pub fn contains_offset(&self, offset: u64) -> bool {
58        self.start <= offset && offset < self.end
59    }
60}
61
62impl Serialize for RangeDto {
63    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
64    where
65        S: Serializer,
66    {
67        (self.start, self.end).serialize(serializer)
68    }
69}
70
71#[derive(Deserialize)]
72#[serde(untagged)]
73enum RangeHelper {
74    Seq((u64, u64)),
75    Map { start: u64, end: u64 },
76}
77
78impl<'de> Deserialize<'de> for RangeDto {
79    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80    where
81        D: Deserializer<'de>,
82    {
83        let helper = RangeHelper::deserialize(deserializer)?;
84        let (start, end) = match helper {
85            RangeHelper::Seq((s, e)) => (s, e),
86            RangeHelper::Map { start, end } => (start, end),
87        };
88        RangeDto::new(start, end).map_err(serde::de::Error::custom)
89    }
90}
91
92/// Manifest specification for one writable register.
93///
94/// Fields are declared in alphabetical order to produce deterministic canonical JSON.
95#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
96pub struct WritableRegisterDto {
97    /// Allowed writable bitmask.
98    pub allow_mask: u32,
99    /// Whether read-modify-write is allowed.
100    pub allow_rmw: bool,
101    /// Byte offset from the start of the logical resource.
102    pub offset: u64,
103    /// Valid precondition bitmask.
104    pub precondition_mask: u32,
105    /// Whether readback is configured/supported.
106    pub readback: bool,
107    /// Whether a precondition is required for writes.
108    pub require_precondition: bool,
109    /// Access width in bytes (must be 4).
110    pub width: u32,
111}
112
113impl WritableRegisterDto {
114    pub fn to_writable_register(&self) -> WritableRegister {
115        WritableRegister {
116            offset: self.offset,
117            width: self.width,
118            allow_mask: self.allow_mask,
119            allow_rmw: self.allow_rmw,
120            require_precondition: self.require_precondition,
121            precondition_mask: self.precondition_mask,
122            readback: self.readback,
123        }
124    }
125}
126
127/// Manifest specification for one resource's ceiling.
128///
129/// Fields are declared in alphabetical order to produce deterministic canonical JSON.
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ResourcePolicyManifest {
132    /// Whether interrupt observation may be authorized by session allowlist.
133    #[serde(default)]
134    pub allow_interrupt: bool,
135    /// Whether polling reads may be authorized by session allowlist.
136    pub allow_poll: bool,
137    /// Whether operator-authorized unknown reads may be enabled.
138    pub allow_unknown_reads: bool,
139    /// Byte ranges that may never be read, even with operator consent.
140    pub hard_denied: Vec<RangeDto>,
141    /// Resource ID.
142    pub id: ResourceId,
143    /// Stable logical resource name.
144    pub name: String,
145    /// Writable registers permitted on this resource.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub writable_registers: Vec<WritableRegisterDto>,
148}
149
150impl ResourcePolicyManifest {
151    /// Validates and canonicalizes resource policy fields.
152    pub fn canonicalize(&mut self) -> Result<(), PolicyValidationError> {
153        if self.name.is_empty() {
154            return Err(PolicyValidationError::EmptyResourceName(self.id));
155        }
156        if self.name.len() > MAX_NAME_LENGTH {
157            return Err(PolicyValidationError::ResourceNameTooLong {
158                id: self.id,
159                len: self.name.len(),
160                max: MAX_NAME_LENGTH,
161            });
162        }
163        self.hard_denied = canonicalize_ranges(&self.hard_denied)?;
164        self.writable_registers.sort();
165        let mut seen = std::collections::BTreeSet::new();
166        for reg in &self.writable_registers {
167            if !seen.insert(reg.offset) {
168                return Err(PolicyValidationError::DuplicateWritableRegister {
169                    id: self.id,
170                    offset: reg.offset,
171                });
172            }
173            if reg.width != 4 {
174                return Err(PolicyValidationError::UnsupportedWidth(reg.width));
175            }
176            if reg.allow_mask == 0 {
177                return Err(PolicyValidationError::InvalidWritableRegisterMask {
178                    id: self.id,
179                    offset: reg.offset,
180                });
181            }
182        }
183        Ok(())
184    }
185
186    /// Converts this manifest entry to `ResourceCeiling`.
187    pub fn to_ceiling(&self) -> ResourceCeiling {
188        ResourceCeiling {
189            hard_denied: self.hard_denied.iter().map(|r| r.to_range()).collect(),
190            allow_unknown_reads: self.allow_unknown_reads,
191            allow_poll: self.allow_poll,
192            writable_registers: self
193                .writable_registers
194                .iter()
195                .map(|w| w.to_writable_register())
196                .collect(),
197            protocol: None,
198            allow_interrupt: self.allow_interrupt,
199        }
200    }
201}
202
203/// Target-wide ceiling policy manifest.
204///
205/// Fields are declared in alphabetical order to produce deterministic canonical JSON.
206#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
207pub struct TargetPolicyManifest {
208    /// Whether mutating sessions may be opened.
209    pub allow_mutating_sessions: bool,
210    /// Bounded audit capacity.
211    pub audit_capacity: u64,
212    /// Maximum snapshot items per bounded read snapshot.
213    pub max_snapshot_items: u32,
214    /// Per-resource ceiling policies, sorted by resource ID.
215    pub resources: Vec<ResourcePolicyManifest>,
216    /// Schema version (must be 1).
217    pub schema_version: u32,
218}
219
220impl TargetPolicyManifest {
221    /// Default engineering ceiling policy for Phase 1 exploration.
222    pub fn engineering_default(resources: &BTreeMap<ResourceId, MmioResource>) -> Self {
223        let resource_manifests = resources
224            .iter()
225            .map(|(id, res)| ResourcePolicyManifest {
226                id: *id,
227                name: res.name.clone(),
228                allow_interrupt: res.kind == ResourceKind::Interrupt,
229                allow_unknown_reads: true,
230                allow_poll: false,
231                hard_denied: vec![],
232                writable_registers: vec![],
233            })
234            .collect();
235
236        Self {
237            schema_version: TARGET_POLICY_SCHEMA_VERSION,
238            allow_mutating_sessions: true,
239            max_snapshot_items: MAX_SNAPSHOT_ITEMS,
240            audit_capacity: 1024,
241            resources: resource_manifests,
242        }
243    }
244
245    /// Constructs a canonical target policy manifest from offered resources and
246    /// their configured per-resource ceiling entries (including `hard_denied`
247    /// ranges and `writable_registers`).
248    pub fn from_bundle_ceiling(
249        resources: &BTreeMap<ResourceId, MmioResource>,
250        ceiling: &BTreeMap<ResourceId, ResourceCeiling>,
251        allow_mutating_sessions: bool,
252    ) -> Self {
253        let resource_manifests = resources
254            .iter()
255            .map(|(id, res)| {
256                if let Some(c) = ceiling.get(id) {
257                    ResourcePolicyManifest {
258                        id: *id,
259                        name: res.name.clone(),
260                        allow_interrupt: c.allow_interrupt,
261                        allow_unknown_reads: c.allow_unknown_reads,
262                        allow_poll: c.allow_poll,
263                        hard_denied: c
264                            .hard_denied
265                            .iter()
266                            .filter_map(|r| RangeDto::new(r.start, r.end).ok())
267                            .collect(),
268                        writable_registers: c
269                            .writable_registers
270                            .iter()
271                            .map(|w| WritableRegisterDto {
272                                allow_mask: w.allow_mask,
273                                allow_rmw: w.allow_rmw,
274                                offset: w.offset,
275                                precondition_mask: w.precondition_mask,
276                                readback: w.readback,
277                                require_precondition: w.require_precondition,
278                                width: w.width,
279                            })
280                            .collect(),
281                    }
282                } else {
283                    ResourcePolicyManifest {
284                        id: *id,
285                        name: res.name.clone(),
286                        allow_interrupt: res.kind == ResourceKind::Interrupt,
287                        allow_unknown_reads: false,
288                        allow_poll: false,
289                        hard_denied: vec![],
290                        writable_registers: vec![],
291                    }
292                }
293            })
294            .collect();
295
296        let mut manifest = Self {
297            schema_version: TARGET_POLICY_SCHEMA_VERSION,
298            allow_mutating_sessions,
299            max_snapshot_items: MAX_SNAPSHOT_ITEMS,
300            audit_capacity: 1024,
301            resources: resource_manifests,
302        };
303        let _ = manifest.canonicalize();
304        manifest
305    }
306
307    /// Validates and canonicalizes the manifest.
308    pub fn canonicalize(&mut self) -> Result<(), PolicyValidationError> {
309        if self.schema_version != TARGET_POLICY_SCHEMA_VERSION {
310            return Err(PolicyValidationError::InvalidSchemaVersion(self.schema_version));
311        }
312        if self.max_snapshot_items == 0 {
313            return Err(PolicyValidationError::ZeroSnapshotLimit);
314        }
315        if self.max_snapshot_items > MAX_SNAPSHOT_ITEMS {
316            return Err(PolicyValidationError::SnapshotLimitExceeded {
317                value: self.max_snapshot_items,
318                max: MAX_SNAPSHOT_ITEMS,
319            });
320        }
321        if self.audit_capacity == 0 {
322            return Err(PolicyValidationError::ZeroAuditCapacity);
323        }
324        if self.resources.len() > MAX_RESOURCES {
325            return Err(PolicyValidationError::TooManyResources {
326                count: self.resources.len(),
327                max: MAX_RESOURCES,
328            });
329        }
330
331        self.resources.sort_by_key(|r| r.id);
332        let mut seen_ids = std::collections::BTreeSet::new();
333        let mut seen_names = std::collections::BTreeSet::new();
334
335        for res in &mut self.resources {
336            if !seen_ids.insert(res.id) {
337                return Err(PolicyValidationError::DuplicateResourceId(res.id));
338            }
339            if !seen_names.insert(res.name.clone()) {
340                return Err(PolicyValidationError::DuplicateResourceName(res.name.clone()));
341            }
342            res.canonicalize()?;
343        }
344
345        Ok(())
346    }
347
348    /// Serializes to canonical JSON.
349    pub fn to_canonical_json(&self) -> Result<String, serde_json::Error> {
350        serde_json::to_string(self)
351    }
352
353    /// Computes the canonical SHA-256 digest of the manifest.
354    pub fn policy_digest(&self) -> Sha256Digest {
355        let json_str = self.to_canonical_json().expect("manifest serializes to JSON");
356        let hash = Sha256::digest(json_str.as_bytes());
357        Sha256Digest::from_bytes(hash.into())
358    }
359
360    /// Converts resource manifests to a ceiling map for the policy engine.
361    pub fn to_ceiling_map(&self) -> BTreeMap<ResourceId, ResourceCeiling> {
362        self.resources.iter().map(|r| (r.id, r.to_ceiling())).collect()
363    }
364
365    /// Validates that `runtime` narrows or preserves (does not widen) this baseline manifest.
366    /// Returns the validated narrowed manifest.
367    pub fn narrow_with(&self, runtime: &Self) -> Result<Self, NarrowingError> {
368        if self.schema_version != runtime.schema_version {
369            return Err(NarrowingError::SchemaVersionMismatch);
370        }
371
372        // Cannot enable mutating sessions if baseline disallows.
373        if !self.allow_mutating_sessions && runtime.allow_mutating_sessions {
374            return Err(NarrowingError::WidenedMutatingSessions);
375        }
376
377        // Snapshot limit can only decrease or stay the same.
378        if runtime.max_snapshot_items > self.max_snapshot_items {
379            return Err(NarrowingError::WidenedSnapshotLimit {
380                baseline: self.max_snapshot_items,
381                runtime: runtime.max_snapshot_items,
382            });
383        }
384
385        // Audit capacity can only decrease or stay the same.
386        if runtime.audit_capacity > self.audit_capacity {
387            return Err(NarrowingError::WidenedAuditCapacity {
388                baseline: self.audit_capacity,
389                runtime: runtime.audit_capacity,
390            });
391        }
392
393        let base_resources: BTreeMap<ResourceId, &ResourcePolicyManifest> =
394            self.resources.iter().map(|r| (r.id, r)).collect();
395
396        for runtime_res in &runtime.resources {
397            let base_res = base_resources
398                .get(&runtime_res.id)
399                .ok_or(NarrowingError::UnknownResource(runtime_res.id))?;
400
401            if runtime_res.name != base_res.name {
402                return Err(NarrowingError::ResourceNameMismatch {
403                    id: runtime_res.id,
404                    baseline: base_res.name.clone(),
405                    runtime: runtime_res.name.clone(),
406                });
407            }
408
409            // Cannot enable unknown reads if baseline disallows.
410            if !base_res.allow_unknown_reads && runtime_res.allow_unknown_reads {
411                return Err(NarrowingError::WidenedUnknownReads(runtime_res.id));
412            }
413
414            // Cannot enable polling if baseline disallows.
415            if !base_res.allow_poll && runtime_res.allow_poll {
416                return Err(NarrowingError::WidenedPoll(runtime_res.id));
417            }
418
419            // Cannot enable interrupt observation if baseline disallows.
420            if !base_res.allow_interrupt && runtime_res.allow_interrupt {
421                return Err(NarrowingError::WidenedInterrupt(runtime_res.id));
422            }
423
424            // Runtime MUST preserve all hard-denied bytes from baseline.
425            // Every base range must be fully covered by a runtime range.
426            for base_range in &base_res.hard_denied {
427                let covered = runtime_res.hard_denied.iter().any(|rt_range| {
428                    rt_range.start <= base_range.start && base_range.end <= rt_range.end
429                });
430                if !covered {
431                    return Err(NarrowingError::ReducedHardDenial {
432                        id: runtime_res.id,
433                        unconstrained_start: base_range.start,
434                        unconstrained_end: base_range.end,
435                    });
436                }
437            }
438
439            // Runtime writable registers must exist in baseline and cannot widen masks or permissions.
440            for rt_reg in &runtime_res.writable_registers {
441                let base_reg =
442                    base_res.writable_registers.iter().find(|b| b.offset == rt_reg.offset).ok_or(
443                        NarrowingError::WidenedWritableRegister {
444                            id: runtime_res.id,
445                            offset: rt_reg.offset,
446                        },
447                    )?;
448                if (rt_reg.allow_mask & !base_reg.allow_mask) != 0 {
449                    return Err(NarrowingError::WidenedWritableRegisterMask {
450                        id: runtime_res.id,
451                        offset: rt_reg.offset,
452                    });
453                }
454                if !base_reg.allow_rmw && rt_reg.allow_rmw {
455                    return Err(NarrowingError::WidenedWritableRegister {
456                        id: runtime_res.id,
457                        offset: rt_reg.offset,
458                    });
459                }
460                if base_reg.require_precondition && !rt_reg.require_precondition {
461                    return Err(NarrowingError::WidenedWritableRegister {
462                        id: runtime_res.id,
463                        offset: rt_reg.offset,
464                    });
465                }
466                if (rt_reg.precondition_mask & !base_reg.precondition_mask) != 0 {
467                    return Err(NarrowingError::WidenedWritableRegisterMask {
468                        id: runtime_res.id,
469                        offset: rt_reg.offset,
470                    });
471                }
472            }
473        }
474
475        let mut narrowed = runtime.clone();
476        narrowed.canonicalize().map_err(NarrowingError::Validation)?;
477        Ok(narrowed)
478    }
479}
480
481/// Merges overlapping and adjacent ranges and validates `start < end`.
482pub fn canonicalize_ranges(ranges: &[RangeDto]) -> Result<Vec<RangeDto>, PolicyValidationError> {
483    if ranges.is_empty() {
484        return Ok(Vec::new());
485    }
486
487    let mut sorted = ranges.to_vec();
488    for r in &sorted {
489        if r.start >= r.end {
490            return Err(PolicyValidationError::InvalidRange { start: r.start, end: r.end });
491        }
492    }
493    sorted.sort_unstable_by_key(|r| (r.start, r.end));
494
495    let mut merged: Vec<RangeDto> = Vec::with_capacity(sorted.len());
496    for r in sorted {
497        match merged.last_mut() {
498            Some(last) if r.start <= last.end => {
499                // Overlapping or adjacent: merge
500                last.end = std::cmp::max(last.end, r.end);
501            }
502            _ => merged.push(r),
503        }
504    }
505
506    Ok(merged)
507}
508
509/// Errors during manifest validation or canonicalization.
510#[derive(Clone, Debug, PartialEq, Eq)]
511pub enum PolicyValidationError {
512    InvalidSchemaVersion(u32),
513    InvalidRange { start: u64, end: u64 },
514    SnapshotLimitExceeded { value: u32, max: u32 },
515    ZeroSnapshotLimit,
516    ZeroAuditCapacity,
517    EmptyResourceName(ResourceId),
518    ResourceNameTooLong { id: ResourceId, len: usize, max: usize },
519    DuplicateResourceId(ResourceId),
520    DuplicateResourceName(String),
521    TooManyResources { count: usize, max: usize },
522    DuplicateWritableRegister { id: ResourceId, offset: u64 },
523    UnsupportedWidth(u32),
524    InvalidWritableRegisterMask { id: ResourceId, offset: u64 },
525}
526
527/// Errors when a runtime configuration widens rather than narrows the baseline policy.
528#[derive(Clone, Debug, PartialEq, Eq)]
529pub enum NarrowingError {
530    SchemaVersionMismatch,
531    WidenedMutatingSessions,
532    WidenedSnapshotLimit { baseline: u32, runtime: u32 },
533    WidenedAuditCapacity { baseline: u64, runtime: u64 },
534    UnknownResource(ResourceId),
535    ResourceNameMismatch { id: ResourceId, baseline: String, runtime: String },
536    WidenedUnknownReads(ResourceId),
537    WidenedPoll(ResourceId),
538    WidenedInterrupt(ResourceId),
539    ReducedHardDenial { id: ResourceId, unconstrained_start: u64, unconstrained_end: u64 },
540    WidenedWritableRegister { id: ResourceId, offset: u64 },
541    WidenedWritableRegisterMask { id: ResourceId, offset: u64 },
542    Validation(PolicyValidationError),
543}
544
545impl std::fmt::Display for PolicyValidationError {
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        match self {
548            Self::InvalidSchemaVersion(v) => write!(f, "invalid schema version: {v}"),
549            Self::InvalidRange { start, end } => {
550                write!(f, "invalid range: start {start} >= end {end}")
551            }
552            Self::SnapshotLimitExceeded { value, max } => {
553                write!(f, "snapshot limit {value} exceeds maximum {max}")
554            }
555            Self::ZeroSnapshotLimit => write!(f, "snapshot limit must be greater than zero"),
556            Self::ZeroAuditCapacity => write!(f, "audit capacity must be greater than zero"),
557            Self::EmptyResourceName(id) => write!(f, "empty resource name for resource {id}"),
558            Self::ResourceNameTooLong { id, len, max } => {
559                write!(f, "resource name for {id} length {len} exceeds maximum {max}")
560            }
561            Self::DuplicateResourceId(id) => write!(f, "duplicate resource ID: {id}"),
562            Self::DuplicateResourceName(name) => write!(f, "duplicate resource name: {name}"),
563            Self::TooManyResources { count, max } => {
564                write!(f, "resource count {count} exceeds maximum {max}")
565            }
566            Self::DuplicateWritableRegister { id, offset } => {
567                write!(f, "duplicate writable register for resource {id} at offset {offset}")
568            }
569            Self::UnsupportedWidth(w) => write!(f, "unsupported width: {w}"),
570            Self::InvalidWritableRegisterMask { id, offset } => {
571                write!(f, "invalid allow mask for resource {id} at offset {offset}")
572            }
573        }
574    }
575}
576
577impl std::error::Error for PolicyValidationError {}
578
579impl std::fmt::Display for NarrowingError {
580    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
581        match self {
582            Self::SchemaVersionMismatch => write!(f, "schema version mismatch"),
583            Self::WidenedMutatingSessions => write!(f, "cannot enable mutating sessions"),
584            Self::WidenedSnapshotLimit { baseline, runtime } => {
585                write!(f, "snapshot limit {runtime} exceeds baseline {baseline}")
586            }
587            Self::WidenedAuditCapacity { baseline, runtime } => {
588                write!(f, "audit capacity {runtime} exceeds baseline {baseline}")
589            }
590            Self::UnknownResource(id) => write!(f, "unknown resource {id} in runtime policy"),
591            Self::ResourceNameMismatch { id, baseline, runtime } => {
592                write!(f, "resource {id} name mismatch: baseline {baseline}, runtime {runtime}")
593            }
594            Self::WidenedUnknownReads(id) => {
595                write!(f, "cannot enable unknown reads on resource {id}")
596            }
597            Self::WidenedPoll(id) => write!(f, "cannot enable polling on resource {id}"),
598            Self::WidenedInterrupt(id) => {
599                write!(f, "cannot enable interrupt observation on resource {id}")
600            }
601            Self::ReducedHardDenial { id, unconstrained_start, unconstrained_end } => {
602                write!(
603                    f,
604                    "runtime hard denials for resource {id} fail to cover baseline denial [{unconstrained_start}, {unconstrained_end})"
605                )
606            }
607            Self::WidenedWritableRegister { id, offset } => {
608                write!(f, "writable register on resource {id} at offset {offset} widened baseline")
609            }
610            Self::WidenedWritableRegisterMask { id, offset } => {
611                write!(
612                    f,
613                    "mask for writable register on resource {id} at offset {offset} widened baseline"
614                )
615            }
616            Self::Validation(err) => write!(f, "validation error: {err}"),
617        }
618    }
619}
620
621impl std::error::Error for NarrowingError {}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    #[test]
628    fn range_canonicalization_merges_overlapping_and_adjacent() {
629        let input = vec![
630            RangeDto::new(10, 20).unwrap(),
631            RangeDto::new(15, 25).unwrap(),
632            RangeDto::new(25, 30).unwrap(),
633            RangeDto::new(40, 50).unwrap(),
634        ];
635        let merged = canonicalize_ranges(&input).unwrap();
636        assert_eq!(merged, vec![RangeDto::new(10, 30).unwrap(), RangeDto::new(40, 50).unwrap(),]);
637    }
638
639    #[test]
640    fn range_canonicalization_rejects_inverted_or_empty() {
641        assert_eq!(
642            RangeDto::new(20, 10),
643            Err(PolicyValidationError::InvalidRange { start: 20, end: 10 })
644        );
645        assert_eq!(
646            RangeDto::new(10, 10),
647            Err(PolicyValidationError::InvalidRange { start: 10, end: 10 })
648        );
649    }
650
651    #[test]
652    fn canonical_json_and_digest_determinism() {
653        let mut manifest = TargetPolicyManifest {
654            schema_version: 1,
655            allow_mutating_sessions: false,
656            audit_capacity: 1024,
657            max_snapshot_items: 64,
658            resources: vec![ResourcePolicyManifest {
659                id: 0,
660                name: "mmio0".to_string(),
661                allow_interrupt: false,
662                allow_unknown_reads: true,
663                allow_poll: false,
664                hard_denied: vec![RangeDto::new(64, 68).unwrap()],
665                writable_registers: vec![],
666            }],
667        };
668        manifest.canonicalize().unwrap();
669
670        let json_str = manifest.to_canonical_json().unwrap();
671        assert_eq!(
672            json_str,
673            "{\"allow_mutating_sessions\":false,\"audit_capacity\":1024,\"max_snapshot_items\":64,\"resources\":[{\"allow_interrupt\":false,\"allow_poll\":false,\"allow_unknown_reads\":true,\"hard_denied\":[[64,68]],\"id\":0,\"name\":\"mmio0\"}],\"schema_version\":1}"
674        );
675
676        let digest = manifest.policy_digest();
677        assert_eq!(
678            digest.to_string(),
679            "sha256:5511f2b70b6fcd8ed519f7e57d31e0becb17083e7f0004d48abfd7ab88034b4d"
680        );
681    }
682
683    #[test]
684    fn narrowing_accepts_valid_reductions() {
685        let baseline = TargetPolicyManifest {
686            schema_version: 1,
687            allow_mutating_sessions: true,
688            audit_capacity: 1024,
689            max_snapshot_items: 64,
690            resources: vec![ResourcePolicyManifest {
691                id: 0,
692                name: "mmio0".to_string(),
693                allow_interrupt: true,
694                allow_unknown_reads: true,
695                allow_poll: true,
696                hard_denied: vec![RangeDto::new(64, 68).unwrap()],
697                writable_registers: vec![],
698            }],
699        };
700
701        // Runtime narrows limits and adds a hard denial.
702        let runtime = TargetPolicyManifest {
703            schema_version: 1,
704            allow_mutating_sessions: false,
705            audit_capacity: 512,
706            max_snapshot_items: 32,
707            resources: vec![ResourcePolicyManifest {
708                id: 0,
709                name: "mmio0".to_string(),
710                allow_interrupt: false,
711                allow_unknown_reads: false,
712                allow_poll: false,
713                hard_denied: vec![RangeDto::new(64, 68).unwrap(), RangeDto::new(128, 144).unwrap()],
714                writable_registers: vec![],
715            }],
716        };
717
718        let narrowed = baseline.narrow_with(&runtime).unwrap();
719        assert_eq!(narrowed.audit_capacity, 512);
720        assert_eq!(narrowed.max_snapshot_items, 32);
721        assert!(!narrowed.allow_mutating_sessions);
722        assert!(!narrowed.resources[0].allow_unknown_reads);
723        assert!(!narrowed.resources[0].allow_poll);
724        assert!(!narrowed.resources[0].allow_interrupt);
725        assert_eq!(narrowed.resources[0].hard_denied.len(), 2);
726    }
727
728    #[test]
729    fn narrowing_rejects_widenings() {
730        let baseline = TargetPolicyManifest {
731            schema_version: 1,
732            allow_mutating_sessions: false,
733            audit_capacity: 512,
734            max_snapshot_items: 32,
735            resources: vec![ResourcePolicyManifest {
736                id: 0,
737                name: "mmio0".to_string(),
738                allow_interrupt: false,
739                allow_unknown_reads: false,
740                allow_poll: false,
741                hard_denied: vec![RangeDto::new(64, 128).unwrap()],
742                writable_registers: vec![],
743            }],
744        };
745
746        // 1. Enabling mutating sessions
747        let mut widened = baseline.clone();
748        widened.allow_mutating_sessions = true;
749        assert_eq!(baseline.narrow_with(&widened), Err(NarrowingError::WidenedMutatingSessions));
750
751        // 2. Increasing audit capacity
752        let mut widened = baseline.clone();
753        widened.audit_capacity = 1024;
754        assert_eq!(
755            baseline.narrow_with(&widened),
756            Err(NarrowingError::WidenedAuditCapacity { baseline: 512, runtime: 1024 })
757        );
758
759        // 3. Increasing max snapshot items
760        let mut widened = baseline.clone();
761        widened.max_snapshot_items = 64;
762        assert_eq!(
763            baseline.narrow_with(&widened),
764            Err(NarrowingError::WidenedSnapshotLimit { baseline: 32, runtime: 64 })
765        );
766
767        // 4. Enabling unknown reads
768        let mut widened = baseline.clone();
769        widened.resources[0].allow_unknown_reads = true;
770        assert_eq!(baseline.narrow_with(&widened), Err(NarrowingError::WidenedUnknownReads(0)));
771
772        // 5. Enabling poll
773        let mut widened = baseline.clone();
774        widened.resources[0].allow_poll = true;
775        assert_eq!(baseline.narrow_with(&widened), Err(NarrowingError::WidenedPoll(0)));
776
777        // 6. Enabling interrupt
778        let mut widened = baseline.clone();
779        widened.resources[0].allow_interrupt = true;
780        assert_eq!(baseline.narrow_with(&widened), Err(NarrowingError::WidenedInterrupt(0)));
781
782        // 7. Reducing hard denial (shrinking [64, 128) to [64, 100))
783        let mut widened = baseline.clone();
784        widened.resources[0].hard_denied = vec![RangeDto::new(64, 100).unwrap()];
785        assert_eq!(
786            baseline.narrow_with(&widened),
787            Err(NarrowingError::ReducedHardDenial {
788                id: 0,
789                unconstrained_start: 64,
790                unconstrained_end: 128
791            })
792        );
793    }
794
795    #[test]
796    fn writable_registers_canonicalization_and_narrowing() {
797        let base_reg = WritableRegisterDto {
798            allow_mask: 0x0000FFFF,
799            allow_rmw: false,
800            offset: 0x10,
801            precondition_mask: 0x00000001,
802            readback: true,
803            require_precondition: true,
804            width: 4,
805        };
806        let mut baseline = TargetPolicyManifest {
807            schema_version: 1,
808            allow_mutating_sessions: true,
809            audit_capacity: 1024,
810            max_snapshot_items: 64,
811            resources: vec![ResourcePolicyManifest {
812                id: 0,
813                name: "mmio0".to_string(),
814                allow_interrupt: false,
815                allow_unknown_reads: true,
816                allow_poll: false,
817                hard_denied: vec![],
818                writable_registers: vec![base_reg.clone()],
819            }],
820        };
821        baseline.canonicalize().unwrap();
822
823        // Runtime cannot add a new writable register not in baseline
824        let mut runtime = baseline.clone();
825        runtime.resources[0].writable_registers.push(WritableRegisterDto {
826            allow_mask: 0xFFFFFFFF,
827            allow_rmw: true,
828            offset: 0x20,
829            precondition_mask: 0,
830            readback: false,
831            require_precondition: false,
832            width: 4,
833        });
834        assert_eq!(
835            baseline.narrow_with(&runtime),
836            Err(NarrowingError::WidenedWritableRegister { id: 0, offset: 0x20 })
837        );
838
839        // Runtime cannot widen allow_mask
840        let mut runtime = baseline.clone();
841        runtime.resources[0].writable_registers[0].allow_mask = 0x0001FFFF;
842        assert_eq!(
843            baseline.narrow_with(&runtime),
844            Err(NarrowingError::WidenedWritableRegisterMask { id: 0, offset: 0x10 })
845        );
846
847        // Runtime cannot enable RMW when baseline disallows
848        let mut runtime = baseline.clone();
849        runtime.resources[0].writable_registers[0].allow_rmw = true;
850        assert_eq!(
851            baseline.narrow_with(&runtime),
852            Err(NarrowingError::WidenedWritableRegister { id: 0, offset: 0x10 })
853        );
854
855        // Runtime can narrow allow_mask
856        let mut runtime = baseline.clone();
857        runtime.resources[0].writable_registers[0].allow_mask = 0x000000FF;
858        assert!(baseline.narrow_with(&runtime).is_ok());
859
860        // Runtime can omit writable registers (narrowing to read-only)
861        let mut runtime = baseline.clone();
862        runtime.resources[0].writable_registers.clear();
863        assert!(baseline.narrow_with(&runtime).is_ok());
864    }
865}