Skip to main content

canic_core/ids/
release_build.rs

1//! Module: ids::release_build
2//!
3//! Responsibility: identify one pre-planned release build without depending on its artifacts.
4//! Does not own: nonce generation, durable build plans, manifests, or deployment admission.
5//! Boundary: the host supplies random nonce bytes and every selected Wasm receives the derived ID.
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8use sha2::{Digest, Sha256};
9use std::{fmt, str::FromStr};
10use thiserror::Error as ThisError;
11
12const RELEASE_BUILD_ID_DOMAIN: &[u8] = b"canic:release-build-id\0";
13const RELEASE_BUILD_NONCE_BYTES: u64 = 32;
14
15/// Host-to-build-script environment variable carrying one planned release identity.
16pub const RELEASE_BUILD_ID_ENV: &str = "CANIC_RELEASE_BUILD_ID";
17
18///
19/// ReleaseBuildNonce
20///
21/// Random host-owned input durably recorded before a release build starts.
22///
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub struct ReleaseBuildNonce([u8; 32]);
26
27impl ReleaseBuildNonce {
28    /// Construct a nonce from bytes supplied by the host's cryptographic generator.
29    #[must_use]
30    pub const fn from_random_bytes(bytes: [u8; 32]) -> Self {
31        Self(bytes)
32    }
33
34    #[must_use]
35    pub const fn as_bytes(&self) -> &[u8; 32] {
36        &self.0
37    }
38}
39
40///
41/// ReleaseBuildId
42///
43/// Non-circular identity embedded into every Wasm in one planned release build.
44///
45
46#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct ReleaseBuildId([u8; 32]);
48
49impl ReleaseBuildId {
50    /// Derive the only valid release-build identity from a journalled nonce.
51    #[must_use]
52    pub fn from_nonce(nonce: ReleaseBuildNonce) -> Self {
53        let mut hasher = Sha256::new();
54        hasher.update(RELEASE_BUILD_ID_DOMAIN);
55        hasher.update(RELEASE_BUILD_NONCE_BYTES.to_be_bytes());
56        hasher.update(nonce.as_bytes());
57        Self(hasher.finalize().into())
58    }
59
60    #[must_use]
61    pub const fn as_bytes(&self) -> &[u8; 32] {
62        &self.0
63    }
64}
65
66impl fmt::Display for ReleaseBuildId {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        for byte in self.0 {
69            write!(formatter, "{byte:02x}")?;
70        }
71        Ok(())
72    }
73}
74
75impl FromStr for ReleaseBuildId {
76    type Err = ReleaseBuildIdParseError;
77
78    fn from_str(value: &str) -> Result<Self, Self::Err> {
79        if value.len() != 64 {
80            return Err(ReleaseBuildIdParseError::Length(value.len()));
81        }
82        if !value
83            .bytes()
84            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
85        {
86            return Err(ReleaseBuildIdParseError::CanonicalHex);
87        }
88
89        let mut bytes = [0; 32];
90        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
91            bytes[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
92        }
93        Ok(Self(bytes))
94    }
95}
96
97impl Serialize for ReleaseBuildId {
98    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
99    where
100        S: Serializer,
101    {
102        serializer.collect_str(self)
103    }
104}
105
106impl<'de> Deserialize<'de> for ReleaseBuildId {
107    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
108    where
109        D: Deserializer<'de>,
110    {
111        let value = String::deserialize(deserializer)?;
112        value.parse().map_err(de::Error::custom)
113    }
114}
115
116///
117/// ReleaseBuildIdParseError
118///
119/// Typed rejection for a non-canonical release-build identity.
120///
121
122#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
123pub enum ReleaseBuildIdParseError {
124    #[error("release build ID must contain exactly 64 characters, got {0}")]
125    Length(usize),
126
127    #[error("release build ID must contain only lowercase hexadecimal characters")]
128    CanonicalHex,
129}
130
131fn decode_nibble(byte: u8) -> u8 {
132    match byte {
133        b'0'..=b'9' => byte - b'0',
134        b'a'..=b'f' => byte - b'a' + 10,
135        _ => unreachable!("canonical hex was validated before decoding"),
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn release_build_id_uses_the_exact_non_circular_derivation() {
145        let nonce = ReleaseBuildNonce::from_random_bytes([7; 32]);
146        let expected: [u8; 32] = Sha256::digest(
147            [
148                RELEASE_BUILD_ID_DOMAIN,
149                RELEASE_BUILD_NONCE_BYTES.to_be_bytes().as_slice(),
150                nonce.as_bytes(),
151            ]
152            .concat(),
153        )
154        .into();
155
156        assert_eq!(ReleaseBuildId::from_nonce(nonce).as_bytes(), &expected);
157    }
158
159    #[test]
160    fn release_build_id_text_is_exact_and_canonical() {
161        let id = ReleaseBuildId::from_nonce(ReleaseBuildNonce::from_random_bytes([11; 32]));
162        let text = id.to_string();
163
164        assert_eq!(text.len(), 64);
165        assert_eq!(text.parse::<ReleaseBuildId>(), Ok(id));
166        std::assert_matches!(
167            text.to_uppercase().parse::<ReleaseBuildId>(),
168            Err(ReleaseBuildIdParseError::CanonicalHex)
169        );
170    }
171}