canic_core/ids/
release_build.rs1use 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
15pub const RELEASE_BUILD_ID_ENV: &str = "CANIC_RELEASE_BUILD_ID";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub struct ReleaseBuildNonce([u8; 32]);
26
27impl ReleaseBuildNonce {
28 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct ReleaseBuildId([u8; 32]);
48
49impl ReleaseBuildId {
50 #[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#[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}