Skip to main content

objects/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
125impl fmt::Display for DiscussionRecordId {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "disc-{}", self.0)
128    }
129}
130
131impl FromStr for DiscussionRecordId {
132    type Err = DiscussionRecordIdParseError;
133    fn from_str(value: &str) -> Result<Self, Self::Err> {
134        let value = value
135            .strip_prefix("disc-")
136            .ok_or(DiscussionRecordIdParseError::MissingPrefix)?;
137        Self::from_uuid(Uuid::parse_str(value).map_err(DiscussionRecordIdParseError::InvalidUuid)?)
138    }
139}
140
141impl Serialize for DiscussionRecordId {
142    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
143    where
144        S: Serializer,
145    {
146        serializer.serialize_str(&self.to_string())
147    }
148}
149
150impl<'de> Deserialize<'de> for DiscussionRecordId {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: Deserializer<'de>,
154    {
155        String::deserialize(deserializer)?
156            .parse()
157            .map_err(de::Error::custom)
158    }
159}
160
161#[derive(Debug, thiserror::Error)]
162pub enum DiscussionRecordIdParseError {
163    #[error("discussion id must start with disc-")]
164    MissingPrefix,
165    #[error("invalid discussion UUID: {0}")]
166    InvalidUuid(uuid::Error),
167    #[error("discussion id must be UUIDv7")]
168    WrongVersion,
169}
170
171macro_rules! nonempty_id {
172    ($name:ident, $message:literal) => {
173        #[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize)]
174        #[serde(transparent)]
175        pub struct $name(String);
176
177        impl $name {
178            pub fn new(value: impl Into<String>) -> Result<Self, String> {
179                let value = value.into();
180                if value.trim().is_empty() {
181                    return Err($message.to_string());
182                }
183                Ok(Self(value))
184            }
185
186            pub fn as_str(&self) -> &str {
187                &self.0
188            }
189        }
190
191        impl<'de> Deserialize<'de> for $name {
192            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
193            where
194                D: Deserializer<'de>,
195            {
196                Self::new(String::deserialize(deserializer)?).map_err(de::Error::custom)
197            }
198        }
199    };
200}
201
202nonempty_id!(
203    CollaborationIdempotencyKey,
204    "idempotency key must not be empty"
205);
206nonempty_id!(LegacyDiscussionId, "legacy discussion id must not be empty");
207
208#[derive(Clone, Debug, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
209pub struct LegacySourceLocator {
210    pub state_id: StateId,
211    pub attachment_id: StateAttachmentId,
212    pub blob_hash: ContentHash,
213}
214
215impl LegacySourceLocator {
216    pub fn new(
217        state_id: StateId,
218        attachment_id: StateAttachmentId,
219        blob_hash: ContentHash,
220    ) -> Self {
221        Self {
222            state_id,
223            attachment_id,
224            blob_hash,
225        }
226    }
227
228    pub fn identity_bytes(&self) -> [u8; 96] {
229        let mut bytes = [0; 96];
230        bytes[..32].copy_from_slice(self.state_id.as_bytes());
231        bytes[32..64].copy_from_slice(self.attachment_id.as_hash().as_bytes());
232        bytes[64..].copy_from_slice(self.blob_hash.as_bytes());
233        bytes
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn serialized_nonempty_ids_cannot_bypass_validation() {
243        let bytes = rmp_serde::to_vec_named("").unwrap();
244        assert!(rmp_serde::from_slice::<CollaborationIdempotencyKey>(&bytes).is_err());
245        assert!(rmp_serde::from_slice::<LegacyDiscussionId>(&bytes).is_err());
246    }
247
248    #[test]
249    fn legacy_locator_preserves_full_typed_identities() {
250        let locator = LegacySourceLocator::new(
251            StateId::from_bytes([1; 32]),
252            StateAttachmentId::from_hash(ContentHash::from_bytes([2; 32])),
253            ContentHash::from_bytes([3; 32]),
254        );
255        let bytes = locator.identity_bytes();
256        assert_eq!(&bytes[..32], &[1; 32]);
257        assert_eq!(&bytes[32..64], &[2; 32]);
258        assert_eq!(&bytes[64..], &[3; 32]);
259    }
260}