use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use sha2::{Digest, Sha256};
use std::{fmt, str::FromStr};
use thiserror::Error as ThisError;
const RELEASE_BUILD_ID_DOMAIN: &[u8] = b"canic:release-build-id\0";
const RELEASE_BUILD_NONCE_BYTES: u64 = 32;
pub const RELEASE_BUILD_ID_ENV: &str = "CANIC_RELEASE_BUILD_ID";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ReleaseBuildNonce([u8; 32]);
impl ReleaseBuildNonce {
#[must_use]
pub const fn from_random_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct ReleaseBuildId([u8; 32]);
impl ReleaseBuildId {
#[must_use]
pub fn from_nonce(nonce: ReleaseBuildNonce) -> Self {
let mut hasher = Sha256::new();
hasher.update(RELEASE_BUILD_ID_DOMAIN);
hasher.update(RELEASE_BUILD_NONCE_BYTES.to_be_bytes());
hasher.update(nonce.as_bytes());
Self(hasher.finalize().into())
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Display for ReleaseBuildId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
impl FromStr for ReleaseBuildId {
type Err = ReleaseBuildIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.len() != 64 {
return Err(ReleaseBuildIdParseError::Length(value.len()));
}
if !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(ReleaseBuildIdParseError::CanonicalHex);
}
let mut bytes = [0; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
bytes[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
}
Ok(Self(bytes))
}
}
impl Serialize for ReleaseBuildId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for ReleaseBuildId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
value.parse().map_err(de::Error::custom)
}
}
#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
pub enum ReleaseBuildIdParseError {
#[error("release build ID must contain exactly 64 characters, got {0}")]
Length(usize),
#[error("release build ID must contain only lowercase hexadecimal characters")]
CanonicalHex,
}
fn decode_nibble(byte: u8) -> u8 {
match byte {
b'0'..=b'9' => byte - b'0',
b'a'..=b'f' => byte - b'a' + 10,
_ => unreachable!("canonical hex was validated before decoding"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_build_id_uses_the_exact_non_circular_derivation() {
let nonce = ReleaseBuildNonce::from_random_bytes([7; 32]);
let expected: [u8; 32] = Sha256::digest(
[
RELEASE_BUILD_ID_DOMAIN,
RELEASE_BUILD_NONCE_BYTES.to_be_bytes().as_slice(),
nonce.as_bytes(),
]
.concat(),
)
.into();
assert_eq!(ReleaseBuildId::from_nonce(nonce).as_bytes(), &expected);
}
#[test]
fn release_build_id_text_is_exact_and_canonical() {
let id = ReleaseBuildId::from_nonce(ReleaseBuildNonce::from_random_bytes([11; 32]));
let text = id.to_string();
assert_eq!(text.len(), 64);
assert_eq!(text.parse::<ReleaseBuildId>(), Ok(id));
std::assert_matches!(
text.to_uppercase().parse::<ReleaseBuildId>(),
Err(ReleaseBuildIdParseError::CanonicalHex)
);
}
}