use crate::ids::{ByteCount, Timestamp};
pub const BLOB_ADDRESS_SCHEME: &str = "sha256:";
pub const SHA256_HEX_LEN: usize = 64;
pub const BLOB_CHUNK_BYTES: usize = 1024 * 1024;
pub fn is_sha256_hex(value: &str) -> bool {
value.len() == SHA256_HEX_LEN
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlobAddressError {
UnknownScheme,
MalformedDigest,
}
impl std::fmt::Display for BlobAddressError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownScheme => write!(f, "blob address must start with {BLOB_ADDRESS_SCHEME}"),
Self::MalformedDigest => {
f.write_str("blob address digest must be a lowercase 64-hex sha-256")
}
}
}
}
impl std::error::Error for BlobAddressError {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
#[serde(transparent)]
pub struct BlobAddress(String);
impl BlobAddress {
pub fn parse(value: &str) -> Result<Self, BlobAddressError> {
let digest = value
.strip_prefix(BLOB_ADDRESS_SCHEME)
.ok_or(BlobAddressError::UnknownScheme)?;
if !is_sha256_hex(digest) {
return Err(BlobAddressError::MalformedDigest);
}
Ok(Self(value.to_owned()))
}
pub fn from_digest(digest: &str) -> Result<Self, BlobAddressError> {
if !is_sha256_hex(digest) {
return Err(BlobAddressError::MalformedDigest);
}
Ok(Self(format!("{BLOB_ADDRESS_SCHEME}{digest}")))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn digest_hex(&self) -> &str {
&self.0[BLOB_ADDRESS_SCHEME.len()..]
}
}
impl std::fmt::Display for BlobAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> serde::Deserialize<'de> for BlobAddress {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = <std::borrow::Cow<'de, str>>::deserialize(d)?;
Self::parse(&raw).map_err(serde::de::Error::custom)
}
}
impl specta::Type for BlobAddress {
fn definition(types: &mut specta::Types) -> specta::datatype::DataType {
<String as specta::Type>::definition(types)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(deny_unknown_fields)]
pub struct BlobDescriptor {
pub address: BlobAddress,
pub media_type: String,
pub byte_size: ByteCount,
pub kek_id: String,
pub created_at: Timestamp,
pub pinned: bool,
pub tombstoned: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn address_parses_only_the_exact_v1_shape() {
let hex = "a".repeat(SHA256_HEX_LEN);
let addr = BlobAddress::parse(&format!("sha256:{hex}")).expect("valid address");
assert_eq!(addr.digest_hex(), hex);
for bad in [
"sha256:ABC", &format!("sha256:{}", "A".repeat(64)), &format!("sha256:{}", "a".repeat(63)), &format!("sha256:{}", "a".repeat(65)), &format!("sha512:{}", "a".repeat(64)), &"a".repeat(64), &format!("sha256:{}", "g".repeat(64)), &format!(" sha256:{}", "a".repeat(64)), &format!("sha256:{} ", "a".repeat(64)), ] {
assert!(BlobAddress::parse(bad).is_err(), "accepted {bad}");
}
}
#[test]
fn address_round_trips_and_rejects_bad_input_at_decode_time() {
let addr = BlobAddress::from_digest(&"0".repeat(SHA256_HEX_LEN)).expect("valid digest");
let json = serde_json::to_string(&addr).expect("serialize");
assert_eq!(json, format!("\"sha256:{}\"", "0".repeat(64)));
let back: BlobAddress = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, addr);
assert!(serde_json::from_str::<BlobAddress>("\"sha256:nope\"").is_err());
}
#[test]
fn digest_predicate_is_lowercase_and_exact_length() {
assert!(is_sha256_hex(&"0123456789abcdef".repeat(4)));
assert!(!is_sha256_hex(&"0123456789ABCDEF".repeat(4)));
assert!(!is_sha256_hex(""));
assert!(!is_sha256_hex(&"a".repeat(63)));
}
}