use sha2::{Digest, Sha256};
use thiserror::Error;
use url::Url;
pub(crate) const KEY_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResumeKey {
digest: [u8; 32],
key_format_version: u32,
}
impl ResumeKey {
pub fn new(
base_url: &Url,
event_type: &str,
filter: &serde_json::Value,
schema_fingerprint: Option<&str>,
) -> Result<Self, ResumeKeyError> {
let canonical_filter =
serde_jcs::to_vec(filter).map_err(ResumeKeyError::CanonicaliseFilter)?;
let mut hasher = Sha256::new();
hasher.update(KEY_FORMAT_VERSION.to_le_bytes());
write_field(&mut hasher, normalize_base_url(base_url).as_bytes());
write_field(&mut hasher, event_type.as_bytes());
write_field(&mut hasher, &canonical_filter);
write_optional_field(&mut hasher, schema_fingerprint.map(str::as_bytes));
Ok(Self {
digest: hasher.finalize().into(),
key_format_version: KEY_FORMAT_VERSION,
})
}
pub(crate) fn from_parts(digest: [u8; 32], key_format_version: u32) -> Self {
Self {
digest,
key_format_version,
}
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.digest
}
#[must_use]
pub fn as_hex(&self) -> String {
hex::encode(self.digest)
}
#[must_use]
pub fn key_format_version(&self) -> u32 {
self.key_format_version
}
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum ResumeKeyError {
#[error("filter canonicalisation failed: {0}")]
CanonicaliseFilter(#[source] serde_json::Error),
}
fn normalize_base_url(url: &Url) -> String {
let scheme = url.scheme().to_ascii_lowercase();
let host = url
.host()
.map_or_else(String::new, |h| h.to_string().to_ascii_lowercase());
let port_part = match (url.port(), scheme.as_str()) {
(Some(80), "http") | (Some(443), "https") | (None, _) => String::new(),
(Some(p), _) => format!(":{p}"),
};
let path = if url.path().is_empty() {
"/"
} else {
url.path()
};
format!("{scheme}://{host}{port_part}{path}")
}
fn write_field(hasher: &mut Sha256, bytes: &[u8]) {
let len = bytes.len() as u64;
hasher.update(len.to_le_bytes());
hasher.update(bytes);
}
fn write_optional_field(hasher: &mut Sha256, bytes: Option<&[u8]>) {
match bytes {
Some(b) => {
hasher.update([1u8]);
write_field(hasher, b);
}
None => {
hasher.update([0u8]);
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
reason = "test code: unwrap and panic on unexpected variant are the standard test diagnostics"
)]
mod tests {
use serde_json::json;
use url::Url;
use super::{KEY_FORMAT_VERSION, ResumeKey, normalize_base_url};
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn deterministic_for_same_inputs() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
assert_eq!(a, b);
}
#[test]
fn different_base_url_gives_different_key() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(&url("https://b/"), "mars", &json!({}), None).unwrap();
assert_ne!(a, b);
}
#[test]
fn different_event_type_gives_different_key() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(&url("https://a/"), "atms", &json!({}), None).unwrap();
assert_ne!(a, b);
}
#[test]
fn different_filter_value_gives_different_key() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({"k": "1"}), None).unwrap();
let b = ResumeKey::new(&url("https://a/"), "mars", &json!({"k": "2"}), None).unwrap();
assert_ne!(a, b);
}
#[test]
fn filter_key_reordering_does_not_affect_key() {
let a = ResumeKey::new(
&url("https://a/"),
"mars",
&json!({"a": "1", "b": "2"}),
None,
)
.unwrap();
let b = ResumeKey::new(
&url("https://a/"),
"mars",
&json!({"b": "2", "a": "1"}),
None,
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn nested_filter_object_reordering_does_not_affect_key() {
let a = ResumeKey::new(
&url("https://a/"),
"mars",
&json!({"outer": {"x": "1", "y": "2"}}),
None,
)
.unwrap();
let b = ResumeKey::new(
&url("https://a/"),
"mars",
&json!({"outer": {"y": "2", "x": "1"}}),
None,
)
.unwrap();
assert_eq!(a, b);
}
#[test]
fn schema_fingerprint_none_vs_some_empty_differ() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(&url("https://a/"), "mars", &json!({}), Some("")).unwrap();
assert_ne!(a, b);
}
#[test]
fn schema_fingerprint_distinct_values_differ() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({}), Some("v1")).unwrap();
let b = ResumeKey::new(&url("https://a/"), "mars", &json!({}), Some("v2")).unwrap();
assert_ne!(a, b);
}
#[test]
fn empty_filter_object_hashes_deterministically() {
let a = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
assert_eq!(a, b);
assert_eq!(a.as_hex().len(), 64);
}
#[test]
fn as_hex_is_64_lowercase_chars() {
let k = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
let hex = k.as_hex();
assert_eq!(hex.len(), 64);
assert!(hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')));
}
#[test]
fn key_format_version_is_current() {
let k = ResumeKey::new(&url("https://a/"), "mars", &json!({}), None).unwrap();
assert_eq!(k.key_format_version(), KEY_FORMAT_VERSION);
}
#[test]
fn event_type_with_nul_byte_does_not_collide_with_different_inputs() {
let with_nul = ResumeKey::new(&url("https://a/"), "a\x00b", &json!({}), None).unwrap();
let separate = ResumeKey::new(&url("https://a/"), "a", &json!({"_": "b"}), None).unwrap();
assert_ne!(with_nul, separate);
}
#[test]
fn schema_fingerprint_with_nul_does_not_collide_with_longer_event_type() {
let fp_nul =
ResumeKey::new(&url("https://a/"), "mars", &json!({}), Some("v\x001")).unwrap();
let no_fp = ResumeKey::new(&url("https://a/"), "marsfp:v\x001", &json!({}), None).unwrap();
assert_ne!(fp_nul, no_fp);
}
#[test]
fn from_parts_with_different_format_versions_are_not_equal() {
let a = ResumeKey::from_parts([0u8; 32], 1);
let b = ResumeKey::from_parts([0u8; 32], 2);
assert_ne!(a, b, "same digest, different format version must differ");
}
#[test]
fn normalize_lowercases_scheme_and_host() {
assert_eq!(
normalize_base_url(&url("HTTPS://Aviso.Example/")),
"https://aviso.example/"
);
}
#[test]
fn normalize_strips_default_ports() {
assert_eq!(
normalize_base_url(&url("https://aviso.example:443/")),
"https://aviso.example/"
);
assert_eq!(
normalize_base_url(&url("http://aviso.example:80/")),
"http://aviso.example/"
);
}
#[test]
fn normalize_preserves_non_default_port() {
assert_eq!(
normalize_base_url(&url("https://aviso.example:8443/")),
"https://aviso.example:8443/"
);
}
#[test]
fn normalize_strips_userinfo() {
assert_eq!(
normalize_base_url(&url("https://user:pass@aviso.example/")),
"https://aviso.example/"
);
}
#[test]
fn normalize_preserves_path() {
assert_eq!(
normalize_base_url(&url("https://aviso.example/path/")),
"https://aviso.example/path/"
);
}
#[test]
fn normalize_brackets_ipv6_host() {
assert_eq!(
normalize_base_url(&url("https://[::1]:8443/")),
"https://[::1]:8443/"
);
assert_eq!(
normalize_base_url(&url("https://[2001:db8::1]/")),
"https://[2001:db8::1]/"
);
}
#[test]
fn ipv6_compressed_and_expanded_forms_produce_same_key() {
let compressed = ResumeKey::new(&url("https://[::1]/"), "mars", &json!({}), None).unwrap();
let expanded =
ResumeKey::new(&url("https://[0:0:0:0:0:0:0:1]/"), "mars", &json!({}), None).unwrap();
assert_eq!(compressed, expanded);
}
#[test]
fn distinct_ipv6_hosts_produce_distinct_keys() {
let a = ResumeKey::new(&url("https://[::1]:8443/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(&url("https://[::2]:8443/"), "mars", &json!({}), None).unwrap();
assert_ne!(a, b);
}
#[test]
fn normalize_equivalent_urls_produce_same_key() {
let a = ResumeKey::new(&url("HTTPS://Aviso.Example/"), "mars", &json!({}), None).unwrap();
let b = ResumeKey::new(
&url("https://user:pass@aviso.example:443/"),
"mars",
&json!({}),
None,
)
.unwrap();
assert_eq!(a, b);
}
}