Skip to main content

heddle_object_model/object/collaboration/
ids.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
6use uuid::Uuid;
7
8use crate::object::{ContentHash, StateAttachmentId, StateId};
9
10#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
11pub struct CollabOpId([u8; 32]);
12
13impl CollabOpId {
14    pub fn for_bytes(bytes: &[u8]) -> Self {
15        Self(*ContentHash::compute_typed("collaboration-operation", bytes).as_bytes())
16    }
17
18    pub fn from_bytes(bytes: [u8; 32]) -> Self {
19        Self(bytes)
20    }
21
22    pub fn as_bytes(&self) -> &[u8; 32] {
23        &self.0
24    }
25
26    pub fn to_hex(&self) -> String {
27        hex::encode(self.0)
28    }
29
30    pub fn to_string_full(&self) -> String {
31        format!(
32            "co-{}",
33            base32::encode(base32::Alphabet::Crockford, &self.0).to_lowercase()
34        )
35    }
36
37    pub fn parse(value: &str) -> Result<Self, CollabOpIdParseError> {
38        let Some(value) = value.strip_prefix("co-") else {
39            return Err(CollabOpIdParseError::MissingPrefix);
40        };
41        let bytes = base32::decode(base32::Alphabet::Crockford, &value.to_uppercase())
42            .ok_or(CollabOpIdParseError::InvalidBase32)?;
43        let bytes: [u8; 32] = bytes
44            .try_into()
45            .map_err(|_| CollabOpIdParseError::InvalidLength)?;
46        Ok(Self(bytes))
47    }
48}
49
50impl fmt::Debug for CollabOpId {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        f.write_str(&self.to_string_full())
53    }
54}
55
56impl fmt::Display for CollabOpId {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(&self.to_string_full())
59    }
60}
61
62impl FromStr for CollabOpId {
63    type Err = CollabOpIdParseError;
64    fn from_str(value: &str) -> Result<Self, Self::Err> {
65        Self::parse(value)
66    }
67}
68
69impl Serialize for CollabOpId {
70    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
71    where
72        S: Serializer,
73    {
74        serializer.serialize_str(&self.to_string_full())
75    }
76}
77
78impl<'de> Deserialize<'de> for CollabOpId {
79    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
80    where
81        D: Deserializer<'de>,
82    {
83        String::deserialize(deserializer)?
84            .parse()
85            .map_err(de::Error::custom)
86    }
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
90pub enum CollabOpIdParseError {
91    #[error("collaboration operation id must start with co-")]
92    MissingPrefix,
93    #[error("invalid collaboration operation base32")]
94    InvalidBase32,
95    #[error("collaboration operation id must contain 32 bytes")]
96    InvalidLength,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Ord, PartialOrd)]
100pub struct DiscussionRecordId(Uuid);
101
102impl DiscussionRecordId {
103    pub fn generate() -> Self {
104        Self(Uuid::now_v7())
105    }
106
107    pub fn from_uuid(value: Uuid) -> Result<Self, DiscussionRecordIdParseError> {
108        if value.get_version_num() != 7 {
109            return Err(DiscussionRecordIdParseError::WrongVersion);
110        }
111        Ok(Self(value))
112    }
113
114    pub fn for_legacy_source(source: &LegacySourceLocator, opened_at_ms: i64) -> Self {
115        let hash = ContentHash::compute_typed("legacy-discussion", &source.identity_bytes());
116        let mut bytes = [0; 16];
117        bytes[..6].copy_from_slice(&(opened_at_ms.max(0) as u64).to_be_bytes()[2..]);
118        bytes[6..].copy_from_slice(&hash.as_bytes()[..10]);
119        bytes[6] = (bytes[6] & 0x0f) | 0x70;
120        bytes[8] = (bytes[8] & 0x3f) | 0x80;
121        Self(Uuid::from_bytes(bytes))
122    }
123
124    /// Deterministic local id for a hosted discussion whose wire id is not a
125    /// `disc-<UUIDv7>` (a legacy `hc-` id opened before clients carried their
126    /// own id). Derived solely from the hosted id so every clone that pulls the
127    /// same discussion agrees on one id, and shaped as a valid UUIDv7 so
128    /// [`Self::from_uuid`] accepts it.
129    pub fn for_hosted_source(hosted_id: &str) -> Self {
130        let hash = ContentHash::compute_typed("hosted-discussion", hosted_id.as_bytes());
131        let mut bytes = [0; 16];
132        bytes.copy_from_slice(&hash.as_bytes()[..16]);
133        bytes[6] = (bytes[6] & 0x0f) | 0x70;
134        bytes[8] = (bytes[8] & 0x3f) | 0x80;
135        Self(Uuid::from_bytes(bytes))
136    }
137
138    /// Short text id: `disc-` plus the first UUID group (8 hex chars).
139    pub fn to_string_short(&self) -> String {
140        let hyphenated = self.0.to_string();
141        format!("disc-{}", &hyphenated[..8])
142    }
143}
144
145impl fmt::Display for DiscussionRecordId {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(f, "disc-{}", self.0)
148    }
149}
150
151impl FromStr for DiscussionRecordId {
152    type Err = DiscussionRecordIdParseError;
153    fn from_str(value: &str) -> Result<Self, Self::Err> {
154        let value = value
155            .strip_prefix("disc-")
156            .ok_or(DiscussionRecordIdParseError::MissingPrefix)?;
157        Self::from_uuid(Uuid::parse_str(value).map_err(DiscussionRecordIdParseError::InvalidUuid)?)
158    }
159}
160
161impl Serialize for DiscussionRecordId {
162    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
163    where
164        S: Serializer,
165    {
166        serializer.serialize_str(&self.to_string())
167    }
168}
169
170impl<'de> Deserialize<'de> for DiscussionRecordId {
171    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
172    where
173        D: Deserializer<'de>,
174    {
175        String::deserialize(deserializer)?
176            .parse()
177            .map_err(de::Error::custom)
178    }
179}
180
181#[derive(Debug, thiserror::Error)]
182pub enum DiscussionRecordIdParseError {
183    #[error("discussion id must start with disc-")]
184    MissingPrefix,
185    #[error("invalid discussion UUID: {0}")]
186    InvalidUuid(uuid::Error),
187    #[error("discussion id must be UUIDv7")]
188    WrongVersion,
189}
190
191macro_rules! nonempty_id {
192    ($name:ident, $message:literal) => {
193        #[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize)]
194        #[serde(transparent)]
195        pub struct $name(String);
196
197        impl $name {
198            pub fn new(value: impl Into<String>) -> Result<Self, String> {
199                let value = value.into();
200                if value.trim().is_empty() {
201                    return Err($message.to_string());
202                }
203                Ok(Self(value))
204            }
205
206            pub fn as_str(&self) -> &str {
207                &self.0
208            }
209        }
210
211        impl<'de> Deserialize<'de> for $name {
212            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213            where
214                D: Deserializer<'de>,
215            {
216                Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
217            }
218        }
219    };
220}
221
222nonempty_id!(
223    CollaborationIdempotencyKey,
224    "idempotency key must not be empty"
225);
226nonempty_id!(LegacyDiscussionId, "legacy discussion id must not be empty");
227
228#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
229pub struct LegacySourceLocator {
230    pub state_id: StateId,
231    pub attachment_id: StateAttachmentId,
232    pub blob_hash: ContentHash,
233}
234
235impl LegacySourceLocator {
236    pub fn new(
237        state_id: StateId,
238        attachment_id: StateAttachmentId,
239        blob_hash: ContentHash,
240    ) -> Self {
241        Self {
242            state_id,
243            attachment_id,
244            blob_hash,
245        }
246    }
247
248    pub fn identity_bytes(&self) -> [u8; 96] {
249        let mut bytes = [0; 96];
250        bytes[..32].copy_from_slice(self.state_id.as_bytes());
251        bytes[32..64].copy_from_slice(self.attachment_id.as_hash().as_bytes());
252        bytes[64..].copy_from_slice(self.blob_hash.as_bytes());
253        bytes
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn serialized_nonempty_ids_cannot_bypass_validation() {
263        let bytes = rmp_serde::to_vec_named("").unwrap();
264        assert!(rmp_serde::from_slice::<CollaborationIdempotencyKey>(&bytes).is_err());
265        assert!(rmp_serde::from_slice::<LegacyDiscussionId>(&bytes).is_err());
266    }
267
268    #[test]
269    fn for_hosted_source_is_deterministic_and_valid_v7() {
270        // Falsifier for the clone-agreement of the legacy `hc-` fallback: two
271        // clones deriving from the same hosted id must land on the same local id.
272        let a = DiscussionRecordId::for_hosted_source("hc-abc123");
273        let b = DiscussionRecordId::for_hosted_source("hc-abc123");
274        assert_eq!(a, b, "same hosted id must derive the same local id");
275        // Distinct hosted ids must not collide.
276        assert_ne!(a, DiscussionRecordId::for_hosted_source("hc-xyz789"));
277        // The derived id must round-trip through the `disc-<UUIDv7>` gate.
278        let text = a.to_string();
279        assert!(text.starts_with("disc-"));
280        assert_eq!(text.parse::<DiscussionRecordId>().unwrap(), a);
281        let short = a.to_string_short();
282        assert!(short.starts_with("disc-"));
283        assert_eq!(short.len(), "disc-".len() + 8);
284        assert!(text.starts_with(&short));
285    }
286
287    #[test]
288    fn legacy_locator_preserves_full_typed_identities() {
289        let locator = LegacySourceLocator::new(
290            StateId::from_bytes([1; 32]),
291            StateAttachmentId::from_hash(ContentHash::from_bytes([2; 32])),
292            ContentHash::from_bytes([3; 32]),
293        );
294        let bytes = locator.identity_bytes();
295        assert_eq!(&bytes[..32], &[1; 32]);
296        assert_eq!(&bytes[32..64], &[2; 32]);
297        assert_eq!(&bytes[64..], &[3; 32]);
298    }
299}