Skip to main content

lab_proxy_core/
digest.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//! Resource-description digests.
6//!
7//! A digest covers the stable identity of one offered resource plus the
8//! immutable target-ceiling entries that apply to that resource, so a
9//! persistent host read grant stops matching when resource layout or policy
10//! changes, while a policy change on one resource does not invalidate
11//! grants on unrelated resources. Dynamic values (mapping addresses, boot
12//! IDs, handles, and the actual mapped size) are excluded.
13
14use crate::access_policy::{MmioResource, ResourceCeiling, ResourceId, ResourceKind, WIDTH32};
15use sha2::{Digest as _, Sha256};
16use std::collections::BTreeMap;
17use std::fmt;
18
19/// A SHA-256 digest, displayed as `sha256:<lowercase hex>`.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct Sha256Digest([u8; 32]);
22impl Sha256Digest {
23    /// Creates a digest from raw 32 bytes.
24    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
25        Self(bytes)
26    }
27
28    /// The raw digest bytes.
29    pub fn as_bytes(&self) -> &[u8; 32] {
30        &self.0
31    }
32}
33
34impl fmt::Display for Sha256Digest {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "sha256:")?;
37        for byte in self.0 {
38            write!(f, "{byte:02x}")?;
39        }
40        Ok(())
41    }
42}
43
44/// Stable inputs identifying one resource for grant matching.
45#[derive(Clone, Copy, Debug)]
46pub struct DigestInputs<'a> {
47    /// Stable node identity criteria (for example the node moniker), never
48    /// a per-boot numeric ID.
49    pub node_identity: &'a str,
50    /// Provider kind, for example `"platform"`, `"pci"`, or `"fake"`.
51    pub provider: &'a str,
52    /// Resource ordinal.
53    pub id: ResourceId,
54    /// The resource description. The actual mapped size is deliberately
55    /// excluded: it is a property of the current mapping, not of the
56    /// logical resource.
57    pub resource: &'a MmioResource,
58    /// The ceiling entry that applies to this resource, or `None` when the
59    /// ceiling has no entry (the resource permits nothing).
60    pub ceiling: Option<&'a ResourceCeiling>,
61}
62
63const RESOURCE_DOMAIN: &str = "fuchsia.driver.lab resource digest v1";
64const COMBINED_DOMAIN: &str = "fuchsia.driver.lab combined digest v1";
65const POLICY_DOMAIN: &str = "fuchsia.driver.lab policy digest v1";
66
67fn put_bytes(hasher: &mut Sha256, tag: &str, bytes: &[u8]) {
68    hasher.update((tag.len() as u64).to_le_bytes());
69    hasher.update(tag.as_bytes());
70    hasher.update((bytes.len() as u64).to_le_bytes());
71    hasher.update(bytes);
72}
73
74fn put_u64(hasher: &mut Sha256, tag: &str, value: u64) {
75    put_bytes(hasher, tag, &value.to_le_bytes());
76}
77
78fn finish(hasher: Sha256) -> Sha256Digest {
79    let output = hasher.finalize();
80    Sha256Digest(output.as_slice().try_into().expect("sha256 output is 32 bytes"))
81}
82
83/// Computes the canonical digest of one resource description and its
84/// applicable ceiling slice.
85pub fn resource_digest(inputs: &DigestInputs<'_>) -> Sha256Digest {
86    let mut hasher = Sha256::new();
87    put_bytes(&mut hasher, "domain", RESOURCE_DOMAIN.as_bytes());
88    put_bytes(&mut hasher, "node", inputs.node_identity.as_bytes());
89    put_bytes(&mut hasher, "provider", inputs.provider.as_bytes());
90    put_u64(&mut hasher, "id", u64::from(inputs.id));
91    put_bytes(&mut hasher, "name", inputs.resource.name.as_bytes());
92    put_bytes(
93        &mut hasher,
94        "kind",
95        match inputs.resource.kind {
96            ResourceKind::Mmio => b"mmio",
97            ResourceKind::Gpio => b"gpio",
98            ResourceKind::I2c => b"i2c",
99            ResourceKind::Spi => b"spi",
100            ResourceKind::Interrupt => b"interrupt",
101            ResourceKind::Clock => b"clock",
102            ResourceKind::Reset => b"reset",
103            ResourceKind::Serial => b"serial",
104        },
105    );
106    put_u64(&mut hasher, "logical_size", inputs.resource.logical_size);
107    put_u64(&mut hasher, "width", u64::from(WIDTH32));
108    match inputs.ceiling {
109        None => put_u64(&mut hasher, "ceiling", 0),
110        Some(ceiling) => {
111            put_u64(&mut hasher, "ceiling", 1);
112            put_u64(&mut hasher, "allow_unknown_reads", u64::from(ceiling.allow_unknown_reads));
113            put_u64(&mut hasher, "allow_poll", u64::from(ceiling.allow_poll));
114            put_u64(&mut hasher, "allow_interrupt", u64::from(ceiling.allow_interrupt));
115            let mut denied: Vec<(u64, u64)> =
116                ceiling.hard_denied.iter().map(|range| (range.start, range.end)).collect();
117            denied.sort_unstable();
118            put_u64(&mut hasher, "hard_denied_count", denied.len() as u64);
119            for (start, end) in denied {
120                put_u64(&mut hasher, "deny_start", start);
121                put_u64(&mut hasher, "deny_end", end);
122            }
123            if let Some(proto) = &ceiling.protocol {
124                put_u64(&mut hasher, "protocol_ceiling", 1);
125                put_u64(&mut hasher, "max_transfer_size", u64::from(proto.max_transfer_size));
126                put_u64(&mut hasher, "allow_mutating", u64::from(proto.allow_mutating));
127                for method in &proto.allowed_methods {
128                    put_bytes(&mut hasher, "method", method.as_bytes());
129                }
130            } else {
131                put_u64(&mut hasher, "protocol_ceiling", 0);
132            }
133        }
134    }
135    finish(hasher)
136}
137
138/// Digest over an ordered sequence of per-resource digests, used as the
139/// whole-instance resource digest in `Describe`. Callers must supply a
140/// deterministic order (for example ascending resource ID).
141pub fn combined_digest(digests: impl IntoIterator<Item = Sha256Digest>) -> Sha256Digest {
142    let mut hasher = Sha256::new();
143    put_bytes(&mut hasher, "domain", COMBINED_DOMAIN.as_bytes());
144    for digest in digests {
145        put_bytes(&mut hasher, "resource", digest.as_bytes());
146    }
147    finish(hasher)
148}
149
150/// The per-resource digests of one proxy instance, in ascending resource
151/// ID order (the order `combined_digest` expects).
152pub fn per_resource_digests(
153    node_identity: &str,
154    provider: &str,
155    resources: &BTreeMap<ResourceId, MmioResource>,
156    ceiling: &BTreeMap<ResourceId, ResourceCeiling>,
157) -> BTreeMap<ResourceId, Sha256Digest> {
158    resources
159        .iter()
160        .map(|(id, resource)| {
161            let digest = resource_digest(&DigestInputs {
162                node_identity,
163                provider,
164                id: *id,
165                resource,
166                ceiling: ceiling.get(id),
167            });
168            (*id, digest)
169        })
170        .collect()
171}
172
173/// Canonical digest of the immutable target-ceiling content, reported as
174/// `policy_digest` in `Describe`. Domain-separated from resource digests
175/// so the two never collide, even over identical inputs.
176pub fn policy_digest(ceiling: &BTreeMap<ResourceId, ResourceCeiling>) -> Sha256Digest {
177    let mut hasher = Sha256::new();
178    put_bytes(&mut hasher, "domain", POLICY_DOMAIN.as_bytes());
179    put_u64(&mut hasher, "resource_count", ceiling.len() as u64);
180    for (id, entry) in ceiling {
181        put_u64(&mut hasher, "id", u64::from(*id));
182        put_u64(&mut hasher, "allow_unknown_reads", u64::from(entry.allow_unknown_reads));
183        put_u64(&mut hasher, "allow_poll", u64::from(entry.allow_poll));
184        put_u64(&mut hasher, "allow_interrupt", u64::from(entry.allow_interrupt));
185        let mut denied: Vec<(u64, u64)> =
186            entry.hard_denied.iter().map(|range| (range.start, range.end)).collect();
187        denied.sort_unstable();
188        put_u64(&mut hasher, "hard_denied_count", denied.len() as u64);
189        for (start, end) in denied {
190            put_u64(&mut hasher, "deny_start", start);
191            put_u64(&mut hasher, "deny_end", end);
192        }
193    }
194    finish(hasher)
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn resource() -> MmioResource {
202        MmioResource {
203            name: "ctrl".to_string(),
204            kind: ResourceKind::Mmio,
205            logical_size: 0x100,
206            mapped_size: 0x1000,
207        }
208    }
209
210    fn ceiling() -> ResourceCeiling {
211        ResourceCeiling {
212            hard_denied: vec![0x40..0x44, 0x80..0x90],
213            allow_unknown_reads: true,
214            allow_poll: false,
215            writable_registers: vec![],
216            protocol: None,
217            allow_interrupt: false,
218        }
219    }
220
221    fn digest_of(resource: &MmioResource, ceiling: Option<&ResourceCeiling>) -> Sha256Digest {
222        resource_digest(&DigestInputs {
223            node_identity: "dev.sys.platform.example",
224            provider: "platform",
225            id: 1,
226            resource,
227            ceiling,
228        })
229    }
230
231    #[test]
232    fn digest_is_deterministic() {
233        assert_eq!(
234            digest_of(&resource(), Some(&ceiling())),
235            digest_of(&resource(), Some(&ceiling()))
236        );
237    }
238
239    #[test]
240    fn display_format() {
241        let text = digest_of(&resource(), Some(&ceiling())).to_string();
242        assert!(text.starts_with("sha256:"));
243        assert_eq!(text.len(), "sha256:".len() + 64);
244        assert!(text["sha256:".len()..].chars().all(|c| c.is_ascii_hexdigit()));
245    }
246
247    #[test]
248    fn every_stable_field_changes_the_digest() {
249        let base = digest_of(&resource(), Some(&ceiling()));
250
251        let mut renamed = resource();
252        renamed.name = "ctrl2".to_string();
253        assert_ne!(digest_of(&renamed, Some(&ceiling())), base);
254
255        let mut resized = resource();
256        resized.logical_size = 0x200;
257        assert_ne!(digest_of(&resized, Some(&ceiling())), base);
258
259        let other_node = resource_digest(&DigestInputs {
260            node_identity: "dev.sys.platform.other",
261            provider: "platform",
262            id: 1,
263            resource: &resource(),
264            ceiling: Some(&ceiling()),
265        });
266        assert_ne!(other_node, base);
267
268        let other_provider = resource_digest(&DigestInputs {
269            node_identity: "dev.sys.platform.example",
270            provider: "pci",
271            id: 1,
272            resource: &resource(),
273            ceiling: Some(&ceiling()),
274        });
275        assert_ne!(other_provider, base);
276
277        let other_id = resource_digest(&DigestInputs {
278            node_identity: "dev.sys.platform.example",
279            provider: "platform",
280            id: 2,
281            resource: &resource(),
282            ceiling: Some(&ceiling()),
283        });
284        assert_ne!(other_id, base);
285    }
286
287    #[test]
288    fn ceiling_changes_the_digest() {
289        let base = digest_of(&resource(), Some(&ceiling()));
290
291        let mut no_unknown = ceiling();
292        no_unknown.allow_unknown_reads = false;
293        assert_ne!(digest_of(&resource(), Some(&no_unknown)), base);
294
295        let mut different_denies = ceiling();
296        different_denies.hard_denied = vec![0x40..0x48];
297        assert_ne!(digest_of(&resource(), Some(&different_denies)), base);
298
299        assert_ne!(digest_of(&resource(), None), base);
300        let empty = ResourceCeiling::default();
301        assert_ne!(digest_of(&resource(), Some(&empty)), digest_of(&resource(), None));
302    }
303
304    #[test]
305    fn hard_denied_order_does_not_matter() {
306        let reordered = ResourceCeiling {
307            hard_denied: vec![0x80..0x90, 0x40..0x44],
308            allow_unknown_reads: true,
309            allow_poll: false,
310            writable_registers: vec![],
311            protocol: None,
312            allow_interrupt: false,
313        };
314        assert_eq!(
315            digest_of(&resource(), Some(&reordered)),
316            digest_of(&resource(), Some(&ceiling()))
317        );
318    }
319
320    #[test]
321    fn mapped_size_is_excluded() {
322        let mut remapped = resource();
323        remapped.mapped_size = 0x4000;
324        assert_eq!(
325            digest_of(&remapped, Some(&ceiling())),
326            digest_of(&resource(), Some(&ceiling()))
327        );
328    }
329
330    #[test]
331    fn combined_digest_reflects_members() {
332        let a = digest_of(&resource(), Some(&ceiling()));
333        let b = digest_of(&resource(), None);
334        assert_eq!(combined_digest([a, b]), combined_digest([a, b]));
335        assert_ne!(combined_digest([a, b]), combined_digest([a]));
336        assert_ne!(combined_digest([a, b]), combined_digest([b, a]));
337        assert_eq!(combined_digest([]), combined_digest([]));
338    }
339
340    #[test]
341    fn per_resource_digests_match_direct_computation() {
342        let resources = BTreeMap::from([(1, resource()), (2, resource())]);
343        let ceilings = BTreeMap::from([(1, ceiling())]);
344        let digests = per_resource_digests("node", "fake", &resources, &ceilings);
345        assert_eq!(digests.len(), 2);
346        assert_eq!(
347            digests[&1],
348            resource_digest(&DigestInputs {
349                node_identity: "node",
350                provider: "fake",
351                id: 1,
352                resource: &resources[&1],
353                ceiling: Some(&ceilings[&1]),
354            })
355        );
356        // Resource 2 has no ceiling entry; its digest reflects that.
357        assert_ne!(digests[&1], digests[&2]);
358    }
359
360    #[test]
361    fn policy_digest_is_domain_separated_and_content_sensitive() {
362        let empty = BTreeMap::new();
363        // The empty policy digest never equals the empty combined resource
364        // digest: a fresh instance reports two distinct identities.
365        assert_ne!(policy_digest(&empty).to_string(), combined_digest([]).to_string());
366
367        let one = BTreeMap::from([(1, ceiling())]);
368        assert_ne!(policy_digest(&one), policy_digest(&empty));
369
370        let mut narrowed = ceiling();
371        narrowed.allow_unknown_reads = false;
372        let changed = BTreeMap::from([(1, narrowed)]);
373        assert_ne!(policy_digest(&changed), policy_digest(&one));
374
375        let reordered_denies = BTreeMap::from([(
376            1,
377            ResourceCeiling {
378                hard_denied: vec![0x80..0x90, 0x40..0x44],
379                allow_unknown_reads: true,
380                allow_poll: false,
381                writable_registers: vec![],
382                protocol: None,
383                allow_interrupt: false,
384            },
385        )]);
386        assert_eq!(policy_digest(&reordered_denies), policy_digest(&one));
387
388        let mut poll_allowed = ceiling();
389        poll_allowed.allow_poll = true;
390        let poll_changed = BTreeMap::from([(1, poll_allowed)]);
391        assert_ne!(policy_digest(&poll_changed), policy_digest(&one));
392
393        let mut interrupt_allowed = ceiling();
394        interrupt_allowed.allow_interrupt = true;
395        let interrupt_changed = BTreeMap::from([(1, interrupt_allowed)]);
396        assert_ne!(policy_digest(&interrupt_changed), policy_digest(&one));
397    }
398}