use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
pub const GROUP_TAG_PREFIX: &str = "group:";
#[expect(
clippy::derived_hash_with_manual_eq,
reason = "manual PartialEq is constant-time but byte-identical to the \
derived one; the Hash/Eq invariant (equal values hash equal) \
holds because both operate on the same 32 bytes"
)]
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Serialize, Deserialize)]
pub struct GroupId(pub(crate) [u8; 32]);
impl PartialEq for GroupId {
fn eq(&self, other: &Self) -> bool {
self.0.ct_eq(&other.0).into()
}
}
impl GroupId {
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn from_tag(tag: &str) -> Option<Self> {
let hex_part = tag.strip_prefix(GROUP_TAG_PREFIX)?;
let mut out = [0u8; 32];
hex::decode_to_slice(hex_part, &mut out).ok()?;
Some(Self(out))
}
pub fn to_tag(self) -> String {
format!("{GROUP_TAG_PREFIX}{}", hex::encode(self.0))
}
}
impl std::fmt::Display for GroupId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&hex::encode(self.0))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_via_tag_form() {
let original = GroupId([0x5A; 32]);
let tag = original.to_tag();
assert_eq!(
tag,
"group:5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a"
);
let decoded = GroupId::from_tag(&tag).expect("round trip");
assert_eq!(decoded, original);
}
#[test]
fn parse_rejects_missing_prefix() {
let no_prefix = "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a";
assert!(GroupId::from_tag(no_prefix).is_none());
}
#[test]
fn parse_rejects_wrong_length() {
let short = "group:5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5";
assert!(GroupId::from_tag(short).is_none());
let long = "group:5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5aa";
assert!(GroupId::from_tag(long).is_none());
}
#[test]
fn parse_rejects_non_hex_chars() {
let bad = "group:zz5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a";
assert!(GroupId::from_tag(bad).is_none());
}
#[test]
fn distinct_groups_differ() {
let a = GroupId([0x11; 32]);
let b = GroupId([0x22; 32]);
assert_ne!(a, b);
}
#[test]
fn constant_time_eq_preserves_equality_semantics() {
assert_eq!(GroupId([0x11; 32]), GroupId([0x11; 32]));
assert_ne!(GroupId([0x00; 32]), GroupId([0xFF; 32]));
let mut first_byte = [0x11; 32];
first_byte[0] = 0x12;
assert_ne!(GroupId([0x11; 32]), GroupId(first_byte));
let mut last_byte = [0x11; 32];
last_byte[31] = 0x12;
assert_ne!(GroupId([0x11; 32]), GroupId(last_byte));
let mut set = std::collections::HashSet::new();
set.insert(GroupId([0x11; 32]));
assert!(set.contains(&GroupId([0x11; 32])));
}
#[test]
fn serde_round_trip_postcard() {
let g = GroupId([0xAA; 32]);
let bytes = postcard::to_allocvec(&g).unwrap();
let decoded: GroupId = postcard::from_bytes(&bytes).unwrap();
assert_eq!(decoded, g);
}
}