use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const SEPARATOR: char = '@';
const HASH_HEX_LEN: usize = 64;
pub const MAX_STORE_ID_LEN: usize = 64;
pub const STORE_ID_ALPHABET: &str = "A-Za-z0-9_.-";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct StoreId(String);
impl StoreId {
pub fn new(s: impl Into<String>) -> Result<Self, StoreIdError> {
let s = s.into();
if s.is_empty() {
return Err(StoreIdError::Empty);
}
if s.len() > MAX_STORE_ID_LEN {
return Err(StoreIdError::TooLong { len: s.len(), max: MAX_STORE_ID_LEN });
}
for (position, ch) in s.char_indices() {
if ch == SEPARATOR {
return Err(StoreIdError::ContainsSeparator { position });
}
if !is_store_id_char(ch) {
return Err(StoreIdError::InvalidChar { ch, position });
}
}
if s == "." || s == ".." {
return Err(StoreIdError::Reserved { id: s });
}
Ok(StoreId(s))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
fn is_store_id_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
}
impl fmt::Display for StoreId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for StoreId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::str::FromStr for StoreId {
type Err = StoreIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
StoreId::new(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Cause {
Local(String),
Qualified { store: StoreId, hash: String },
}
impl Cause {
pub fn local(hash: impl Into<String>) -> Result<Self, CauseParseError> {
let hash = hash.into();
validate_hash(&hash)?;
Ok(Cause::Local(hash))
}
pub fn qualified(store: StoreId, hash: impl Into<String>) -> Result<Self, CauseParseError> {
let hash = hash.into();
validate_hash(&hash)?;
Ok(Cause::Qualified { store, hash })
}
pub fn hash(&self) -> &str {
match self {
Cause::Local(h) => h,
Cause::Qualified { hash, .. } => hash,
}
}
pub fn store(&self) -> Option<&StoreId> {
match self {
Cause::Local(_) => None,
Cause::Qualified { store, .. } => Some(store),
}
}
pub fn is_qualified(&self) -> bool {
matches!(self, Cause::Qualified { .. })
}
pub fn render(&self) -> String {
render(self)
}
}
impl fmt::Display for Cause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Cause::Local(h) => f.write_str(h),
Cause::Qualified { store, hash } => write!(f, "{hash}{SEPARATOR}{store}"),
}
}
}
impl std::str::FromStr for Cause {
type Err = CauseParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
parse(s)
}
}
impl TryFrom<String> for Cause {
type Error = CauseParseError;
fn try_from(s: String) -> Result<Self, Self::Error> {
parse(&s)
}
}
impl From<Cause> for String {
fn from(c: Cause) -> String {
render(&c)
}
}
pub fn parse(s: &str) -> Result<Cause, CauseParseError> {
if s.is_empty() {
return Err(CauseParseError::Empty);
}
let separators = s.matches(SEPARATOR).count();
match separators {
0 => {
validate_hash(s)?;
Ok(Cause::Local(s.to_string()))
}
1 => {
let (hash, store) = s.split_once(SEPARATOR).expect("one separator counted");
validate_hash(hash)?;
let store = StoreId::new(store).map_err(CauseParseError::BadStoreId)?;
Ok(Cause::Qualified { store, hash: hash.to_string() })
}
count => Err(CauseParseError::TooManySeparators { count, input: s.to_string() }),
}
}
pub fn render(c: &Cause) -> String {
match c {
Cause::Local(h) => h.clone(),
Cause::Qualified { store, hash } => {
let mut out = String::with_capacity(hash.len() + 1 + store.as_str().len());
out.push_str(hash);
out.push(SEPARATOR);
out.push_str(store.as_str());
out
}
}
}
pub fn target_store<'a>(c: &'a Cause, reading_store: &'a str) -> &'a str {
match c {
Cause::Local(_) => reading_store,
Cause::Qualified { store, .. } => store.as_str(),
}
}
fn validate_hash(h: &str) -> Result<(), CauseParseError> {
if h.len() != HASH_HEX_LEN {
return Err(CauseParseError::BadHashLength { got: h.len(), expected: HASH_HEX_LEN });
}
for (position, ch) in h.char_indices() {
if ch.is_ascii_digit() || ('a'..='f').contains(&ch) {
continue;
}
if ch.is_ascii_uppercase() && ch.is_ascii_hexdigit() {
return Err(CauseParseError::UppercaseHex { ch, position });
}
return Err(CauseParseError::NonHexChar { ch, position });
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreIdError {
Empty,
TooLong { len: usize, max: usize },
ContainsSeparator { position: usize },
InvalidChar { ch: char, position: usize },
Reserved { id: String },
}
impl fmt::Display for StoreIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StoreIdError::Empty => write!(f, "store id is empty"),
StoreIdError::TooLong { len, max } => {
write!(f, "store id is {len} bytes, maximum is {max}")
}
StoreIdError::ContainsSeparator { position } => write!(
f,
"store id contains the reserved separator {SEPARATOR:?} at byte {position}; \
a store id that can break the cause encoding is not a valid store id"
),
StoreIdError::InvalidChar { ch, position } => write!(
f,
"store id contains invalid character {ch:?} at byte {position}; \
allowed characters are [{STORE_ID_ALPHABET}]"
),
StoreIdError::Reserved { id } => {
write!(f, "store id {id:?} is reserved (it names a directory, not a store)")
}
}
}
}
impl std::error::Error for StoreIdError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CauseParseError {
Empty,
BadHashLength { got: usize, expected: usize },
NonHexChar { ch: char, position: usize },
UppercaseHex { ch: char, position: usize },
BadStoreId(StoreIdError),
TooManySeparators { count: usize, input: String },
}
impl fmt::Display for CauseParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CauseParseError::Empty => {
write!(f, "empty cause reference: expected a 64-char hex hash, optionally followed by {SEPARATOR:?} and a store id")
}
CauseParseError::BadHashLength { got, expected } => {
write!(f, "cause hash is {got} characters, expected exactly {expected} hex characters")
}
CauseParseError::NonHexChar { ch, position } => {
write!(f, "cause hash contains non-hex character {ch:?} at position {position}; expected [0-9a-f]")
}
CauseParseError::UppercaseHex { ch, position } => write!(
f,
"cause hash contains uppercase hex character {ch:?} at position {position}; \
hashes must be lowercase (refused rather than normalised, so that one hash has one spelling)"
),
CauseParseError::BadStoreId(e) => write!(f, "invalid store id in cause reference: {e}"),
CauseParseError::TooManySeparators { count, input } => write!(
f,
"cause reference {input:?} contains {count} {SEPARATOR:?} separators, expected at most 1"
),
}
}
}
impl std::error::Error for CauseParseError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CauseParseError::BadStoreId(e) => Some(e),
_ => None,
}
}
}
impl From<StoreIdError> for CauseParseError {
fn from(e: StoreIdError) -> Self {
CauseParseError::BadStoreId(e)
}
}
impl Serialize for Cause {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&render(self))
}
}
impl<'de> Deserialize<'de> for Cause {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct CauseVisitor;
impl Visitor<'_> for CauseVisitor {
type Value = Cause;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "a cause reference string: 64 lowercase hex characters, optionally followed by {SEPARATOR:?} and a store id")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Cause, E> {
parse(v).map_err(|e| E::custom(e.to_string()))
}
}
d.deserialize_str(CauseVisitor)
}
}
impl Serialize for StoreId {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for StoreId {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
StoreId::new(s).map_err(|e| de::Error::custom(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hash_n(seed: u8) -> String {
let alphabet = b"0123456789abcdef";
(0..HASH_HEX_LEN)
.map(|i| alphabet[(i.wrapping_mul(7).wrapping_add(seed as usize)) % 16] as char)
.collect()
}
const H: &str = "3f9a7c1e0b2d4f6a8c0e1b3d5f7a9c1e2d4f6a8c0e1b3d5f7a9c1e2d4f6a8c0e";
#[test]
fn legacy_bare_hash_parses_as_local() {
let c = parse(H).expect("64-hex must parse");
assert_eq!(c, Cause::Local(H.to_string()));
assert!(!c.is_qualified());
assert_eq!(c.hash(), H);
assert_eq!(c.store(), None);
}
#[test]
fn legacy_bare_hash_renders_byte_identically() {
for seed in 0..32u8 {
let h = hash_n(seed);
let round = render(&parse(&h).unwrap());
assert_eq!(round, h, "bare hash must survive parse/render unchanged");
}
assert_eq!(render(&parse(H).unwrap()), H);
}
#[test]
fn legacy_caused_by_vec_needs_no_migration() {
let legacy: Vec<String> = (0..8u8).map(hash_n).collect();
let parsed: Vec<Cause> = legacy.iter().map(|s| parse(s).unwrap()).collect();
assert!(parsed.iter().all(|c| !c.is_qualified()));
let rendered: Vec<String> = parsed.iter().map(render).collect();
assert_eq!(rendered, legacy);
}
#[test]
fn qualified_can_never_look_like_a_bare_hash() {
let q = Cause::qualified(StoreId::new("branch-x").unwrap(), H).unwrap();
let s = render(&q);
assert!(s.contains(SEPARATOR));
assert_eq!(s.matches(SEPARATOR).count(), 1);
assert!(!render(&Cause::local(H).unwrap()).contains(SEPARATOR));
assert!(!SEPARATOR.is_ascii_hexdigit());
assert!(!is_store_id_char(SEPARATOR));
}
#[test]
fn store_id_containing_separator_is_refused_at_construction() {
let e = StoreId::new("branch@evil").unwrap_err();
assert_eq!(e, StoreIdError::ContainsSeparator { position: 6 });
assert!(e.to_string().contains("separator"));
for bad in ["@main", "main@", "@", "a@b@c"] {
assert!(
matches!(StoreId::new(bad), Err(StoreIdError::ContainsSeparator { .. })),
"{bad:?} must be refused"
);
}
let rendered = format!("{H}{SEPARATOR}inner");
assert_eq!(
StoreId::new(&rendered).unwrap_err(),
StoreIdError::TooLong { len: rendered.len(), max: MAX_STORE_ID_LEN }
);
assert!(matches!(
StoreId::new("abcdef@inner"),
Err(StoreIdError::ContainsSeparator { position: 6 })
));
}
#[test]
fn too_many_separators_is_refused_not_guessed() {
let s = format!("{H}@a@b");
match parse(&s).unwrap_err() {
CauseParseError::TooManySeparators { count, input } => {
assert_eq!(count, 2);
assert_eq!(input, s);
}
other => panic!("expected TooManySeparators, got {other:?}"),
}
}
#[test]
fn qualified_round_trips() {
let q = Cause::qualified(StoreId::new("branch-feature-x").unwrap(), H).unwrap();
let s = render(&q);
assert_eq!(s, format!("{H}@branch-feature-x"));
assert_eq!(parse(&s).unwrap(), q);
assert_eq!(render(&parse(&s).unwrap()), s);
}
#[test]
fn table_driven_round_trip() {
let store_ids = [
"a",
"Z",
"0",
"_",
"-",
".", "main",
"MAIN",
"branch-feature-x",
"branch_feature_x",
"team.alpha",
"v1.2.3-rc.4_final",
"0123456789",
"A-Za-z0-9_.",
&"x".repeat(MAX_STORE_ID_LEN),
&"y".repeat(MAX_STORE_ID_LEN - 1),
];
let mut cases: Vec<Cause> = Vec::new();
for seed in 0..8u8 {
cases.push(Cause::local(hash_n(seed)).unwrap());
}
cases.push(Cause::local("0".repeat(64)).unwrap());
cases.push(Cause::local("f".repeat(64)).unwrap());
cases.push(Cause::local(H).unwrap());
for (i, sid) in store_ids.iter().enumerate() {
let store = if *sid == "." {
StoreId::new("a.b").unwrap()
} else {
StoreId::new(*sid).unwrap_or_else(|e| panic!("{sid:?} should be valid: {e}"))
};
cases.push(Cause::qualified(store, hash_n(i as u8 * 3)).unwrap());
}
assert!(cases.len() >= 20, "want a decent sample, got {}", cases.len());
for c in &cases {
let s = render(c);
let back = parse(&s).unwrap_or_else(|e| panic!("{s:?} must re-parse: {e}"));
assert_eq!(&back, c, "parse(render(c)) must equal c");
assert_eq!(render(&back), s, "render must be stable across a round trip");
assert_eq!(c.to_string(), s);
let json = serde_json::to_string(c).unwrap();
assert!(json.starts_with('"') && json.ends_with('"'), "must be a JSON string");
let from_json: Cause = serde_json::from_str(&json).unwrap();
assert_eq!(&from_json, c);
}
}
#[test]
fn short_and_long_hashes_are_refused_distinguishably() {
let short = &H[..63];
let long = format!("{H}a");
let e_short = parse(short).unwrap_err();
let e_long = parse(&long).unwrap_err();
assert_eq!(e_short, CauseParseError::BadHashLength { got: 63, expected: 64 });
assert_eq!(e_long, CauseParseError::BadHashLength { got: 65, expected: 64 });
assert_ne!(e_short, e_long, "63 and 65 must be distinguishable");
assert!(e_short.to_string().contains("63"));
assert!(e_long.to_string().contains("65"));
}
#[test]
fn uppercase_hex_is_refused_not_normalised() {
let upper = H.to_uppercase();
match parse(&upper).unwrap_err() {
CauseParseError::UppercaseHex { ch, position } => {
assert_eq!(ch, 'F');
assert_eq!(position, 1); }
other => panic!("expected UppercaseHex, got {other:?}"),
}
let mixed = format!("{}A{}", &H[..10], &H[11..]);
assert!(matches!(parse(&mixed), Err(CauseParseError::UppercaseHex { ch: 'A', .. })));
assert!(parse(&upper).is_err());
}
#[test]
fn non_hex_character_is_refused_and_named() {
let bad = format!("{}z{}", &H[..5], &H[6..]);
match parse(&bad).unwrap_err() {
CauseParseError::NonHexChar { ch, position } => {
assert_eq!(ch, 'z');
assert_eq!(position, 5);
}
other => panic!("expected NonHexChar, got {other:?}"),
}
assert!(parse(&bad).unwrap_err().to_string().contains("'z'"));
let uni = format!("{}é{}", &H[..3], &H[5..]); assert!(matches!(parse(&uni), Err(CauseParseError::NonHexChar { ch: 'é', .. })));
}
#[test]
fn empty_input_is_its_own_error() {
assert_eq!(parse("").unwrap_err(), CauseParseError::Empty);
assert!(parse("").unwrap_err().to_string().contains("empty"));
}
#[test]
fn qualified_with_bad_hash_reports_the_hash_not_the_store() {
let e = parse("abc@main").unwrap_err();
assert_eq!(e, CauseParseError::BadHashLength { got: 3, expected: 64 });
}
#[test]
fn empty_store_id_is_refused() {
assert_eq!(StoreId::new("").unwrap_err(), StoreIdError::Empty);
assert_eq!(
parse(&format!("{H}@")).unwrap_err(),
CauseParseError::BadStoreId(StoreIdError::Empty)
);
}
#[test]
fn oversized_store_id_is_refused() {
let big = "a".repeat(MAX_STORE_ID_LEN + 1);
assert_eq!(
StoreId::new(&big).unwrap_err(),
StoreIdError::TooLong { len: MAX_STORE_ID_LEN + 1, max: MAX_STORE_ID_LEN }
);
assert!(StoreId::new("a".repeat(MAX_STORE_ID_LEN)).is_ok());
assert!(matches!(
parse(&format!("{H}@{big}")),
Err(CauseParseError::BadStoreId(StoreIdError::TooLong { .. }))
));
}
#[test]
fn whitespace_in_store_id_is_refused() {
for (bad, pos) in [("main branch", 4), (" main", 0), ("main\t", 4), ("main\n", 4)] {
match StoreId::new(bad).unwrap_err() {
StoreIdError::InvalidChar { ch, position } => {
assert_eq!(position, pos, "for {bad:?}");
assert!(ch.is_whitespace(), "for {bad:?}");
}
other => panic!("expected InvalidChar for {bad:?}, got {other:?}"),
}
}
}
#[test]
fn path_unsafe_and_exotic_store_ids_are_refused_naming_the_character() {
for bad in ["a/b", "a\\b", "a:b", "a#b", "a?b", "a%b", "a*b", "a\0b", "brânch"] {
let e = StoreId::new(bad).unwrap_err();
match e {
StoreIdError::InvalidChar { ch, .. } => {
assert!(
e.to_string().contains(&format!("{ch:?}")),
"message must name the offending character for {bad:?}"
);
}
other => panic!("expected InvalidChar for {bad:?}, got {other:?}"),
}
}
}
#[test]
fn dot_store_ids_are_reserved_but_dots_inside_ids_are_fine() {
assert_eq!(StoreId::new(".").unwrap_err(), StoreIdError::Reserved { id: ".".into() });
assert_eq!(StoreId::new("..").unwrap_err(), StoreIdError::Reserved { id: "..".into() });
assert_eq!(StoreId::new("team.alpha").unwrap().as_str(), "team.alpha");
assert_eq!(StoreId::new("...").unwrap().as_str(), "...");
}
#[test]
fn every_error_variant_has_a_distinct_informative_message() {
let errs = vec![
CauseParseError::Empty,
CauseParseError::BadHashLength { got: 63, expected: 64 },
CauseParseError::NonHexChar { ch: 'z', position: 5 },
CauseParseError::UppercaseHex { ch: 'F', position: 1 },
CauseParseError::BadStoreId(StoreIdError::Empty),
CauseParseError::BadStoreId(StoreIdError::TooLong { len: 99, max: 64 }),
CauseParseError::BadStoreId(StoreIdError::ContainsSeparator { position: 2 }),
CauseParseError::BadStoreId(StoreIdError::InvalidChar { ch: '/', position: 1 }),
CauseParseError::BadStoreId(StoreIdError::Reserved { id: "..".into() }),
CauseParseError::TooManySeparators { count: 2, input: "a@b@c".into() },
];
let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
for (i, m) in msgs.iter().enumerate() {
assert!(!m.is_empty());
for (j, n) in msgs.iter().enumerate() {
if i != j {
assert_ne!(m, n, "error messages must be distinguishable");
}
}
}
assert!(format!("{:?}", errs[1]).contains("BadHashLength"));
use std::error::Error as _;
assert!(errs[4].source().is_some());
assert!(errs[0].source().is_none());
}
#[test]
fn vec_of_causes_is_a_json_array_of_plain_strings() {
let causes = vec![
Cause::local(H).unwrap(),
Cause::qualified(StoreId::new("branch-x").unwrap(), hash_n(1)).unwrap(),
Cause::local(hash_n(2)).unwrap(),
];
let json = serde_json::to_string(&causes).unwrap();
assert_eq!(
json,
format!(
"[\"{}\",\"{}@branch-x\",\"{}\"]",
H,
hash_n(1),
hash_n(2)
)
);
let as_values: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
assert!(as_values.iter().all(|v| v.is_string()));
let back: Vec<Cause> = serde_json::from_str(&json).unwrap();
assert_eq!(back, causes);
}
#[test]
fn caused_by_is_wire_compatible_with_vec_string() {
let legacy: Vec<String> = (0..5u8).map(hash_n).collect();
let as_causes: Vec<Cause> = legacy.iter().map(|h| Cause::local(h).unwrap()).collect();
assert_eq!(
serde_json::to_string(&as_causes).unwrap(),
serde_json::to_string(&legacy).unwrap()
);
let from_legacy: Vec<Cause> =
serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
assert_eq!(from_legacy, as_causes);
}
#[test]
fn deserialising_garbage_fails_with_the_parse_diagnosis() {
let err = serde_json::from_str::<Cause>("\"nope\"").unwrap_err().to_string();
assert!(err.contains("expected exactly 64"), "got: {err}");
let err = serde_json::from_str::<Vec<Cause>>(&format!("[\"{}\"]", H.to_uppercase()))
.unwrap_err()
.to_string();
assert!(err.contains("uppercase"), "got: {err}");
assert!(serde_json::from_str::<Cause>("{\"Local\":\"x\"}").is_err());
assert!(serde_json::from_str::<Cause>("42").is_err());
}
#[test]
fn store_id_serde_round_trips_and_validates() {
let s = StoreId::new("branch-x").unwrap();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, "\"branch-x\"");
assert_eq!(serde_json::from_str::<StoreId>(&json).unwrap(), s);
assert!(serde_json::from_str::<StoreId>("\"bad id\"").is_err());
}
#[test]
fn target_store_resolves_local_to_reader_and_qualified_to_itself() {
let local = Cause::local(H).unwrap();
assert_eq!(target_store(&local, "main"), "main");
assert_eq!(target_store(&local, "some-other-store"), "some-other-store");
let q = Cause::qualified(StoreId::new("branch-x").unwrap(), H).unwrap();
assert_eq!(target_store(&q, "main"), "branch-x");
assert_eq!(target_store(&q, "branch-x"), "branch-x");
assert_eq!(target_store(&q, "anything-at-all"), "branch-x");
}
#[test]
fn constructors_validate_and_accessors_agree() {
assert!(Cause::local("short").is_err());
assert!(Cause::qualified(StoreId::new("s").unwrap(), "short").is_err());
let q = Cause::qualified(StoreId::new("s").unwrap(), H).unwrap();
assert_eq!(q.hash(), H);
assert_eq!(q.store().unwrap().as_str(), "s");
assert!(q.is_qualified());
assert_eq!(q.render(), render(&q));
use std::str::FromStr as _;
assert_eq!(Cause::from_str(H).unwrap(), Cause::local(H).unwrap());
assert_eq!(Cause::try_from(H.to_string()).unwrap(), Cause::local(H).unwrap());
assert_eq!(String::from(q.clone()), render(&q));
assert_eq!(StoreId::from_str("ok").unwrap().into_string(), "ok");
assert_eq!(StoreId::new("ok").unwrap().as_ref() as &str, "ok");
assert_eq!(StoreId::new("ok").unwrap().to_string(), "ok");
}
}