use super::error::{ArtefactError, Result};
use serde::Serialize;
use std::fmt;
const DIGEST_HEX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct Sha256Digest(String);
impl Sha256Digest {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_inner(self) -> String {
self.0
}
}
impl<'de> serde::Deserialize<'de> for Sha256Digest {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let s = <String as serde::Deserialize>::deserialize(deserializer)?;
Self::try_from(s).map_err(serde::de::Error::custom)
}
}
impl TryFrom<&str> for Sha256Digest {
type Error = ArtefactError;
fn try_from(value: &str) -> Result<Self> {
validate_sha256(value)?;
Ok(Self(value.to_owned()))
}
}
impl TryFrom<String> for Sha256Digest {
type Error = ArtefactError;
fn try_from(value: String) -> Result<Self> {
let _ = Self::try_from(value.as_str())?;
Ok(Self(value))
}
}
impl AsRef<str> for Sha256Digest {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for Sha256Digest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
fn validate_sha256(value: &str) -> Result<()> {
if value.len() != DIGEST_HEX_LEN {
return Err(ArtefactError::InvalidSha256Digest {
reason: format!(
"expected {DIGEST_HEX_LEN} hex characters, got {}",
value.len()
),
});
}
if let Some(bad) = value
.chars()
.find(|c| !c.is_ascii_hexdigit() || c.is_ascii_uppercase())
{
let reason = if !bad.is_ascii_hexdigit() {
format!("non-hex character '{bad}'")
} else {
"digest must be lowercase".to_owned()
};
return Err(ArtefactError::InvalidSha256Digest { reason });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::{fixture, rstest};
#[fixture]
fn valid_digest() -> String {
"a".repeat(64)
}
#[rstest]
fn accepts_valid_sixty_four_char_hex(valid_digest: String) {
let digest = Sha256Digest::try_from(valid_digest.as_str());
assert!(digest.is_ok());
}
fn invalid_digest(label: &str) -> String {
match label {
"too_short" => "abcdef".to_owned(),
"too_long" => "a".repeat(65),
"non_hex" => {
let mut s = "a".repeat(63);
s.push('g');
s
}
"uppercase" => "A".repeat(64),
other => panic!("unknown case: {other}"),
}
}
#[rstest]
#[case::too_short("too_short")]
#[case::too_long("too_long")]
#[case::non_hex("non_hex")]
#[case::uppercase("uppercase")]
fn rejects_invalid_digest(#[case] label: &str) {
let input = invalid_digest(label);
let err = Sha256Digest::try_from(input.as_str())
.expect_err("expected rejection of invalid digest");
assert!(
matches!(err, ArtefactError::InvalidSha256Digest { .. }),
"expected InvalidSha256Digest for {label}, got {err:?}"
);
}
#[rstest]
fn display_shows_full_digest(valid_digest: String) {
let digest = Sha256Digest::try_from(valid_digest.as_str()).expect("known good");
assert_eq!(format!("{digest}"), valid_digest);
}
#[rstest]
fn from_owned_string_accepts_valid(valid_digest: String) {
let digest = Sha256Digest::try_from(valid_digest);
assert!(digest.is_ok());
}
#[rstest]
fn serde_round_trip(valid_digest: String) {
let digest = Sha256Digest::try_from(valid_digest.as_str()).expect("valid");
let json = serde_json::to_string(&digest).expect("serialize");
let back: Sha256Digest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(digest, back);
}
#[test]
fn deserialize_rejects_invalid() {
let json = r#""tooshort""#;
let result: std::result::Result<Sha256Digest, _> = serde_json::from_str(json);
assert!(result.is_err());
}
}