use std::fmt;
use std::str::FromStr;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
pub enum ParseError {
#[error("expected prefix `{prefix}`, got `{got}`")]
MissingPrefix { prefix: &'static str, got: String },
#[error("expected length {expected}, got {got}")]
WrongLength { expected: usize, got: usize },
#[error("length {got} not in allowed range {min}..={max}")]
LengthOutOfRange { got: usize, min: usize, max: usize },
#[error("invalid character set ({reason})")]
InvalidCharset { reason: &'static str },
#[error("empty value not allowed")]
Empty,
#[error("reserved namespace `{prefix}` is not user-writable")]
ReservedNamespace { prefix: &'static str },
}
macro_rules! string_newtype {
(
$(#[$meta:meta])*
$vis:vis $name:ident,
parse_fn = $parse_fn:path,
schema_pattern = $pattern:expr,
schema_description = $desc:expr,
) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
$vis struct $name(String);
impl $name {
pub fn parse(s: &str) -> Result<Self, ParseError> {
$parse_fn(s).map(Self)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for $name {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(s)
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for $name {
fn schema_name() -> String {
stringify!($name).to_owned()
}
fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::schema::Schema::Object(schemars::schema::SchemaObject {
instance_type: Some(schemars::schema::InstanceType::String.into()),
string: Some(Box::new(schemars::schema::StringValidation {
pattern: Some($pattern.to_owned()),
..Default::default()
})),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some($desc.to_owned()),
..Default::default()
})),
..Default::default()
})
}
}
};
}
const SHA256_PREFIX: &str = "sha256:";
const SHA256_HEX_LEN: usize = 64;
fn parse_sha256(s: &str) -> Result<String, ParseError> {
let hex = s
.strip_prefix(SHA256_PREFIX)
.ok_or_else(|| ParseError::MissingPrefix {
prefix: SHA256_PREFIX,
got: s.chars().take(SHA256_PREFIX.len()).collect(),
})?;
if hex.len() != SHA256_HEX_LEN {
return Err(ParseError::WrongLength {
expected: SHA256_HEX_LEN,
got: hex.len(),
});
}
if !hex.bytes().all(is_lowercase_hex) {
return Err(ParseError::InvalidCharset {
reason: "expected lowercase hex digits 0-9, a-f",
});
}
Ok(s.to_owned())
}
string_newtype! {
pub Sha256,
parse_fn = parse_sha256,
schema_pattern = r"^sha256:[0-9a-f]{64}$",
schema_description = "SHA-256 hash, lowercase hex, prefixed `sha256:`.",
}
#[aristo::intent(
"A hash computed by this constructor is always in canonical form — \
the same form `parse` accepts and the same form written to the \
index file. Hashes never need re-validation after computation.",
verify = "test",
id = "sha256_from_bytes_is_canonical_form"
)]
impl Sha256 {
pub fn from_bytes(input: &[u8]) -> Self {
use sha2::{Digest, Sha256 as Sha256Hasher};
let digest = Sha256Hasher::digest(input);
let mut s = String::with_capacity(SHA256_PREFIX.len() + SHA256_HEX_LEN);
s.push_str(SHA256_PREFIX);
for byte in digest {
const HEX: &[u8; 16] = b"0123456789abcdef";
s.push(HEX[(byte >> 4) as usize] as char);
s.push(HEX[(byte & 0x0f) as usize] as char);
}
Self(s)
}
}
const GIT_SHA1_LEN: usize = 40;
const GIT_SHA256_LEN: usize = 64;
fn parse_commit_hash(s: &str) -> Result<String, ParseError> {
if s.len() != GIT_SHA1_LEN && s.len() != GIT_SHA256_LEN {
return Err(ParseError::LengthOutOfRange {
got: s.len(),
min: GIT_SHA1_LEN,
max: GIT_SHA256_LEN,
});
}
if !s.bytes().all(is_lowercase_hex) {
return Err(ParseError::InvalidCharset {
reason: "expected lowercase hex digits 0-9, a-f",
});
}
Ok(s.to_owned())
}
string_newtype! {
pub CommitHash,
parse_fn = parse_commit_hash,
schema_pattern = r"^([0-9a-f]{40}|[0-9a-f]{64})$",
schema_description = "Git commit hash: 40 lowercase hex chars (SHA-1) or 64 (SHA-256).",
}
const ARTA_PREFIX: &str = "arta_";
const ARTA_BODY_MIN: usize = 8;
const ARTA_BODY_MAX: usize = 64;
fn parse_arta_id(s: &str) -> Result<String, ParseError> {
let body = s
.strip_prefix(ARTA_PREFIX)
.ok_or_else(|| ParseError::MissingPrefix {
prefix: ARTA_PREFIX,
got: s.chars().take(ARTA_PREFIX.len()).collect(),
})?;
if body.len() < ARTA_BODY_MIN || body.len() > ARTA_BODY_MAX {
return Err(ParseError::LengthOutOfRange {
got: body.len(),
min: ARTA_BODY_MIN,
max: ARTA_BODY_MAX,
});
}
if !body.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(ParseError::InvalidCharset {
reason: "expected ASCII alphanumeric (A-Z, a-z, 0-9)",
});
}
Ok(s.to_owned())
}
string_newtype! {
pub ArtaId,
parse_fn = parse_arta_id,
schema_pattern = r"^arta_[A-Za-z0-9]{8,64}$",
schema_description = "Opaque server identity, prefixed `arta_`, 8-64 alphanumeric chars.",
}
const OUTCOME_SCHEME_PREFIX: &str = "v1:";
const OUTCOME_BODY_MIN: usize = 64;
const OUTCOME_BODY_MAX: usize = 256;
fn parse_verified_outcome(s: &str) -> Result<String, ParseError> {
let body = s
.strip_prefix(OUTCOME_SCHEME_PREFIX)
.ok_or_else(|| ParseError::MissingPrefix {
prefix: OUTCOME_SCHEME_PREFIX,
got: s.chars().take(OUTCOME_SCHEME_PREFIX.len()).collect(),
})?;
if body.len() < OUTCOME_BODY_MIN || body.len() > OUTCOME_BODY_MAX {
return Err(ParseError::LengthOutOfRange {
got: body.len(),
min: OUTCOME_BODY_MIN,
max: OUTCOME_BODY_MAX,
});
}
if !body.bytes().all(is_base64_url_safe) {
return Err(ParseError::InvalidCharset {
reason: "expected base64-URL-safe (A-Z, a-z, 0-9, +, /, =, -, _)",
});
}
Ok(s.to_owned())
}
string_newtype! {
pub VerifiedOutcome,
parse_fn = parse_verified_outcome,
schema_pattern = r"^v1:[A-Za-z0-9+/=_-]{64,256}$",
schema_description = "Ed25519 verification certificate, base64-URL-safe, version-prefixed `v1:`.",
}
const ARISTOS_PREFIX: &str = "aristos:";
const KANON_PREFIX: &str = "kanon:";
const ARET_PREFIX: &str = "aret_";
const READABLE_MIN: usize = 1;
const READABLE_MAX: usize = 128;
const OPAQUE_BODY_MIN: usize = 4;
const OPAQUE_BODY_MAX: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum IdNamespace {
Local,
Opaque,
Aristos,
Kanon,
}
fn validate_readable_body(body: &str) -> Result<(), ParseError> {
if body.is_empty() {
return Err(ParseError::Empty);
}
if body.len() > READABLE_MAX {
return Err(ParseError::LengthOutOfRange {
got: body.len(),
min: READABLE_MIN,
max: READABLE_MAX,
});
}
let mut bytes = body.bytes();
let first = bytes.next().expect("body length checked above");
if !(first.is_ascii_lowercase() || first == b'_') {
return Err(ParseError::InvalidCharset {
reason: "readable id must start with lowercase letter or underscore",
});
}
if !bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') {
return Err(ParseError::InvalidCharset {
reason: "readable id must use only lowercase letters, digits, and underscores",
});
}
Ok(())
}
fn parse_annotation_id(s: &str) -> Result<String, ParseError> {
if s.is_empty() {
return Err(ParseError::Empty);
}
if let Some(body) = s.strip_prefix(ARISTOS_PREFIX) {
validate_readable_body(body)?;
return Ok(s.to_owned());
}
if let Some(body) = s.strip_prefix(KANON_PREFIX) {
validate_readable_body(body)?;
return Ok(s.to_owned());
}
if let Some(body) = s.strip_prefix(ARET_PREFIX) {
if body.len() < OPAQUE_BODY_MIN || body.len() > OPAQUE_BODY_MAX {
return Err(ParseError::LengthOutOfRange {
got: body.len(),
min: OPAQUE_BODY_MIN,
max: OPAQUE_BODY_MAX,
});
}
if !body.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(ParseError::InvalidCharset {
reason: "opaque id must be ASCII alphanumeric",
});
}
return Ok(s.to_owned());
}
if let Some(stripped) = s.strip_prefix(':') {
let _ = stripped;
return Err(ParseError::InvalidCharset {
reason: "ids must not start with `:` (forgot a namespace prefix?)",
});
}
validate_readable_body(s)?;
Ok(s.to_owned())
}
string_newtype! {
pub AnnotationId,
parse_fn = parse_annotation_id,
schema_pattern =
r"^(aristos:|kanon:)?[a-z_][a-z0-9_]{0,127}$|^aret_[A-Za-z0-9]{4,64}$",
schema_description =
"Annotation id: local snake_case, stamp-assigned `aret_<alphanumeric>`, \
server-bound backed `aristos:<snake_case>`, or server-bound unbacked \
`kanon:<snake_case>`.",
}
impl AnnotationId {
pub fn namespace(&self) -> IdNamespace {
if self.0.starts_with(ARISTOS_PREFIX) {
IdNamespace::Aristos
} else if self.0.starts_with(KANON_PREFIX) {
IdNamespace::Kanon
} else if self.0.starts_with(ARET_PREFIX) {
IdNamespace::Opaque
} else {
IdNamespace::Local
}
}
pub fn is_canon_bound(&self) -> bool {
matches!(self.namespace(), IdNamespace::Aristos | IdNamespace::Kanon)
}
}
fn is_lowercase_hex(b: u8) -> bool {
b.is_ascii_digit() || (b'a'..=b'f').contains(&b)
}
fn is_base64_url_safe(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'-' | b'_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_accepts_canonical_form() {
let s = format!("sha256:{}", "0".repeat(64));
assert_eq!(Sha256::parse(&s).unwrap().as_str(), s);
}
#[test]
fn sha256_rejects_missing_prefix() {
let s = "0".repeat(64);
assert!(matches!(
Sha256::parse(&s),
Err(ParseError::MissingPrefix {
prefix: "sha256:",
..
})
));
}
#[test]
fn sha256_rejects_wrong_length() {
assert!(matches!(
Sha256::parse("sha256:abc"),
Err(ParseError::WrongLength {
expected: 64,
got: 3
})
));
}
#[test]
fn sha256_rejects_uppercase_hex() {
let s = format!("sha256:{}", "A".repeat(64));
assert!(matches!(
Sha256::parse(&s),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn sha256_rejects_non_hex_chars() {
let s = format!("sha256:{}", "g".repeat(64));
assert!(matches!(
Sha256::parse(&s),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn commit_hash_accepts_sha1() {
let s = "a".repeat(40);
assert_eq!(CommitHash::parse(&s).unwrap().as_str(), s);
}
#[test]
fn commit_hash_accepts_sha256() {
let s = "a".repeat(64);
assert_eq!(CommitHash::parse(&s).unwrap().as_str(), s);
}
#[test]
fn commit_hash_rejects_other_lengths() {
assert!(matches!(
CommitHash::parse(&"a".repeat(50)),
Err(ParseError::LengthOutOfRange { .. })
));
}
#[test]
fn commit_hash_rejects_uppercase() {
assert!(matches!(
CommitHash::parse(&"A".repeat(40)),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn arta_id_accepts_mixed_case_alphanumeric_body() {
ArtaId::parse("arta_op4q3z9NbV").unwrap();
}
#[test]
fn arta_id_rejects_missing_prefix() {
assert!(matches!(
ArtaId::parse("op4q3z9NbV"),
Err(ParseError::MissingPrefix {
prefix: "arta_",
..
})
));
}
#[test]
fn arta_id_rejects_too_short_body() {
assert!(matches!(
ArtaId::parse("arta_abc"),
Err(ParseError::LengthOutOfRange { .. })
));
}
#[test]
fn arta_id_rejects_punctuation_in_body() {
assert!(matches!(
ArtaId::parse("arta_op4q3-9NbV"),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn verified_outcome_accepts_base64_body_in_range() {
let s = format!("v1:{}", "A".repeat(86));
VerifiedOutcome::parse(&s).unwrap();
}
#[test]
fn verified_outcome_rejects_missing_prefix() {
let s = "A".repeat(86);
assert!(matches!(
VerifiedOutcome::parse(&s),
Err(ParseError::MissingPrefix { prefix: "v1:", .. })
));
}
#[test]
fn verified_outcome_rejects_too_short_body() {
assert!(matches!(
VerifiedOutcome::parse("v1:short"),
Err(ParseError::LengthOutOfRange { .. })
));
}
#[test]
fn verified_outcome_rejects_non_base64_chars() {
let mut body = "A".repeat(85);
body.push('!');
let s = format!("v1:{body}");
assert!(matches!(
VerifiedOutcome::parse(&s),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn annotation_id_accepts_local_snake_case() {
let id = AnnotationId::parse("balance_no_duplicate_cells").unwrap();
assert_eq!(id.namespace(), IdNamespace::Local);
}
#[test]
fn annotation_id_accepts_aristos_namespace() {
let id = AnnotationId::parse("aristos:balance_no_duplicate_cells").unwrap();
assert_eq!(id.namespace(), IdNamespace::Aristos);
assert!(id.is_canon_bound());
}
#[test]
fn annotation_id_accepts_kanon_namespace() {
let id = AnnotationId::parse("kanon:checkout_total_non_negative").unwrap();
assert_eq!(id.namespace(), IdNamespace::Kanon);
assert!(id.is_canon_bound());
}
#[test]
fn annotation_id_rejects_kanon_uppercase_body() {
assert!(matches!(
AnnotationId::parse("kanon:Foo"),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn annotation_id_rejects_kanon_empty_body() {
assert!(matches!(
AnnotationId::parse("kanon:"),
Err(ParseError::Empty)
));
}
#[test]
fn annotation_id_local_is_not_canon_bound() {
let id = AnnotationId::parse("balance_no_duplicate_cells").unwrap();
assert!(!id.is_canon_bound());
let id = AnnotationId::parse("aret_a1b2c3d4").unwrap();
assert!(!id.is_canon_bound());
}
#[test]
fn annotation_id_accepts_aret_opaque() {
let id = AnnotationId::parse("aret_a1b2c3d4").unwrap();
assert_eq!(id.namespace(), IdNamespace::Opaque);
}
#[test]
fn annotation_id_rejects_empty() {
assert_eq!(AnnotationId::parse(""), Err(ParseError::Empty));
}
#[test]
fn annotation_id_rejects_starting_digit() {
assert!(matches!(
AnnotationId::parse("1foo"),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn annotation_id_rejects_uppercase_in_local() {
assert!(matches!(
AnnotationId::parse("FooBar"),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn annotation_id_rejects_aret_with_too_short_body() {
assert!(matches!(
AnnotationId::parse("aret_xy"),
Err(ParseError::LengthOutOfRange { .. })
));
}
#[test]
fn annotation_id_rejects_leading_colon() {
assert!(matches!(
AnnotationId::parse(":foo"),
Err(ParseError::InvalidCharset { .. })
));
}
#[test]
fn display_matches_parsed_input() {
let s = format!("sha256:{}", "1".repeat(64));
let h = Sha256::parse(&s).unwrap();
assert_eq!(h.to_string(), s);
}
#[test]
fn from_str_works() {
let s = format!("sha256:{}", "1".repeat(64));
let _: Sha256 = s.parse().unwrap();
}
#[test]
fn serde_round_trips_via_json_string() {
let s = format!("sha256:{}", "1".repeat(64));
let h = Sha256::parse(&s).unwrap();
let json = serde_json::to_string(&h).unwrap();
assert_eq!(json, format!("\"{}\"", s));
let back: Sha256 = serde_json::from_str(&json).unwrap();
assert_eq!(back, h);
}
#[test]
fn serde_deserialize_propagates_parse_error() {
let bad = "\"sha256:short\"";
let result: Result<Sha256, _> = serde_json::from_str(bad);
let err = result.unwrap_err().to_string();
assert!(err.contains("expected length 64"));
}
}