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
139impl fmt::Display for DiscussionRecordId {
140    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141        write!(f, "disc-{}", self.0)
142    }
143}
144
145impl FromStr for DiscussionRecordId {
146    type Err = DiscussionRecordIdParseError;
147    fn from_str(value: &str) -> Result<Self, Self::Err> {
148        let value = value
149            .strip_prefix("disc-")
150            .ok_or(DiscussionRecordIdParseError::MissingPrefix)?;
151        Self::from_uuid(Uuid::parse_str(value).map_err(DiscussionRecordIdParseError::InvalidUuid)?)
152    }
153}
154
155impl Serialize for DiscussionRecordId {
156    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
157    where
158        S: Serializer,
159    {
160        serializer.serialize_str(&self.to_string())
161    }
162}
163
164impl<'de> Deserialize<'de> for DiscussionRecordId {
165    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166    where
167        D: Deserializer<'de>,
168    {
169        String::deserialize(deserializer)?
170            .parse()
171            .map_err(de::Error::custom)
172    }
173}
174
175#[derive(Debug, thiserror::Error)]
176pub enum DiscussionRecordIdParseError {
177    #[error("discussion id must start with disc-")]
178    MissingPrefix,
179    #[error("invalid discussion UUID: {0}")]
180    InvalidUuid(uuid::Error),
181    #[error("discussion id must be UUIDv7")]
182    WrongVersion,
183}
184
185macro_rules! nonempty_id {
186    ($name:ident, $message:literal) => {
187        #[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize)]
188        #[serde(transparent)]
189        pub struct $name(String);
190
191        impl $name {
192            pub fn new(value: impl Into<String>) -> Result<Self, String> {
193                let value = value.into();
194                if value.trim().is_empty() {
195                    return Err($message.to_string());
196                }
197                Ok(Self(value))
198            }
199
200            pub fn as_str(&self) -> &str {
201                &self.0
202            }
203        }
204
205        impl<'de> Deserialize<'de> for $name {
206            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
207            where
208                D: Deserializer<'de>,
209            {
210                Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
211            }
212        }
213    };
214}
215
216nonempty_id!(
217    CollaborationIdempotencyKey,
218    "idempotency key must not be empty"
219);
220nonempty_id!(LegacyDiscussionId, "legacy discussion id must not be empty");
221
222#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
223pub struct LegacySourceLocator {
224    pub state_id: StateId,
225    pub attachment_id: StateAttachmentId,
226    pub blob_hash: ContentHash,
227}
228
229impl LegacySourceLocator {
230    pub fn new(
231        state_id: StateId,
232        attachment_id: StateAttachmentId,
233        blob_hash: ContentHash,
234    ) -> Self {
235        Self {
236            state_id,
237            attachment_id,
238            blob_hash,
239        }
240    }
241
242    pub fn identity_bytes(&self) -> [u8; 96] {
243        let mut bytes = [0; 96];
244        bytes[..32].copy_from_slice(self.state_id.as_bytes());
245        bytes[32..64].copy_from_slice(self.attachment_id.as_hash().as_bytes());
246        bytes[64..].copy_from_slice(self.blob_hash.as_bytes());
247        bytes
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn serialized_nonempty_ids_cannot_bypass_validation() {
257        let bytes = rmp_serde::to_vec_named("").unwrap();
258        assert!(rmp_serde::from_slice::<CollaborationIdempotencyKey>(&bytes).is_err());
259        assert!(rmp_serde::from_slice::<LegacyDiscussionId>(&bytes).is_err());
260    }
261
262    #[test]
263    fn for_hosted_source_is_deterministic_and_valid_v7() {
264        // Falsifier for the clone-agreement of the legacy `hc-` fallback: two
265        // clones deriving from the same hosted id must land on the same local id.
266        let a = DiscussionRecordId::for_hosted_source("hc-abc123");
267        let b = DiscussionRecordId::for_hosted_source("hc-abc123");
268        assert_eq!(a, b, "same hosted id must derive the same local id");
269        // Distinct hosted ids must not collide.
270        assert_ne!(a, DiscussionRecordId::for_hosted_source("hc-xyz789"));
271        // The derived id must round-trip through the `disc-<UUIDv7>` gate.
272        let text = a.to_string();
273        assert!(text.starts_with("disc-"));
274        assert_eq!(text.parse::<DiscussionRecordId>().unwrap(), a);
275    }
276
277    #[test]
278    fn legacy_locator_preserves_full_typed_identities() {
279        let locator = LegacySourceLocator::new(
280            StateId::from_bytes([1; 32]),
281            StateAttachmentId::from_hash(ContentHash::from_bytes([2; 32])),
282            ContentHash::from_bytes([3; 32]),
283        );
284        let bytes = locator.identity_bytes();
285        assert_eq!(&bytes[..32], &[1; 32]);
286        assert_eq!(&bytes[32..64], &[2; 32]);
287        assert_eq!(&bytes[64..], &[3; 32]);
288    }
289}