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