objects/object/collaboration/
ids.rs1use 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}