1use crate::{
7 Circle, CircleCloseStatus, CircleControlCoord, CircleEpochCloseId, CircleId, CircleMemberInfo,
8 CircleOperationBlock, CircleOperationId, CircleOperationInfo, CircleRole, StoreDeviceId,
9};
10
11use crate::store_circles::StoreCircles;
12use crate::store_sync::SyncError;
13use coven_replication::sync::store::CircleOperationError;
14
15#[derive(Debug, thiserror::Error)]
21pub enum CircleError {
22 #[error("sync is not configured")]
24 NotConfigured,
25 #[error("the sync loop is not running")]
27 LoopNotRunning,
28 #[error("circles require opaque (non-browsable) cloud storage")]
31 BrowsableStorage,
32 #[error("circle {circle_id} requires rotation: its roster names removed Store members {removed_members:?}")]
36 RotationRequired {
37 circle_id: CircleId,
38 removed_members: Vec<String>,
39 },
40 #[error("circle {circle_id} has an unresolved control conflict")]
42 Conflicted { circle_id: CircleId },
43 #[error("circle {circle_id} is deleted")]
45 Deleted { circle_id: CircleId },
46 #[error("circle {circle_id} has no retained control conflict to resolve")]
49 NotConflicted { circle_id: CircleId },
50 #[error("circle {circle_id} control conflict does not retain the chosen branch")]
53 ChosenBranchNotRetained { circle_id: CircleId },
54 #[error("circle {circle_id} has no in-flight epoch close to cancel")]
56 NoCloseToCancel { circle_id: CircleId },
57 #[error("circle {circle_id} has no in-flight epoch close for device exclusion")]
59 NoCloseToExclude { circle_id: CircleId },
60 #[error("device {device_id} is not a participant in circle {circle_id}'s epoch close")]
63 DeviceNotACloseParticipant {
64 circle_id: CircleId,
65 device_id: StoreDeviceId,
66 },
67 #[error("circle {circle_id} control conflict must resolve to an active branch")]
70 ResolveToClosingBranch { circle_id: CircleId },
71 #[error("device was excluded from circle {circle_id} close {close_id} and must reset")]
74 ExcludedDeviceMustReset {
75 circle_id: CircleId,
76 close_id: CircleEpochCloseId,
77 },
78 #[error("circle operation {operation_id} is not blocked")]
80 NotBlocked { operation_id: CircleOperationId },
81 #[error("circle operation {operation_id} discard requires verified permanent nonactivation")]
85 DiscardRequiresNonactivation { operation_id: CircleOperationId },
86 #[error("circle operation for {circle_id} is blocked: {block}")]
90 Blocked {
91 circle_id: CircleId,
92 block: CircleOperationBlock,
93 },
94 #[error("the local identity is not established: {0}")]
96 Identity(#[from] coven_keys::keys::KeyError),
97 #[error("circle operation failed: {0}")]
98 Operation(#[source] Box<CircleOperationError>),
99 #[error("circle sync failed: {0}")]
100 Sync(#[source] Box<SyncError>),
101 #[error("circle database query failed: {0}")]
102 Database(#[from] coven_database::DbError),
103}
104
105impl From<CircleOperationError> for CircleError {
106 fn from(error: CircleOperationError) -> Self {
107 match error {
108 CircleOperationError::BrowsableStorage => Self::BrowsableStorage,
109 CircleOperationError::RotationRequired {
110 circle_id,
111 removed_members,
112 } => Self::RotationRequired {
113 circle_id,
114 removed_members,
115 },
116 CircleOperationError::Conflicted { circle_id } => Self::Conflicted { circle_id },
117 CircleOperationError::Deleted { circle_id } => Self::Deleted { circle_id },
118 CircleOperationError::NotConflicted { circle_id } => Self::NotConflicted { circle_id },
119 CircleOperationError::ChosenBranchNotRetained { circle_id } => {
120 Self::ChosenBranchNotRetained { circle_id }
121 }
122 CircleOperationError::NoCloseToCancel { circle_id } => {
123 Self::NoCloseToCancel { circle_id }
124 }
125 CircleOperationError::NoCloseToExclude { circle_id } => {
126 Self::NoCloseToExclude { circle_id }
127 }
128 CircleOperationError::DeviceNotACloseParticipant {
129 circle_id,
130 device_id,
131 } => Self::DeviceNotACloseParticipant {
132 circle_id,
133 device_id,
134 },
135 CircleOperationError::ResolveToClosingBranch { circle_id } => {
136 Self::ResolveToClosingBranch { circle_id }
137 }
138 CircleOperationError::ExcludedDeviceMustReset {
139 circle_id,
140 close_id,
141 } => Self::ExcludedDeviceMustReset {
142 circle_id,
143 close_id,
144 },
145 CircleOperationError::NotBlocked { operation_id } => Self::NotBlocked { operation_id },
146 CircleOperationError::DiscardRequiresNonactivation { operation_id } => {
147 Self::DiscardRequiresNonactivation { operation_id }
148 }
149 CircleOperationError::Blocked { circle_id, block } => {
150 Self::Blocked { circle_id, block }
151 }
152 CircleOperationError::CommandChannelClosed
153 | CircleOperationError::ReplyChannelClosed => Self::LoopNotRunning,
154 other => Self::Operation(Box::new(other)),
155 }
156 }
157}
158
159impl From<SyncError> for CircleError {
160 fn from(error: SyncError) -> Self {
161 match error {
162 SyncError::NotConfigured => Self::NotConfigured,
163 SyncError::LoopNotRunning => Self::LoopNotRunning,
164 SyncError::Circle(error) => (*error).into(),
165 SyncError::Key(error) => Self::Identity(error),
166 other => Self::Sync(Box::new(other)),
167 }
168 }
169}
170
171pub struct Circles<'a> {
173 owner: &'a StoreCircles,
174}
175
176impl<'a> Circles<'a> {
177 pub(crate) fn new(owner: &'a StoreCircles) -> Self {
178 Self { owner }
179 }
180
181 pub async fn create(&self, name: &str) -> Result<CircleId, CircleError> {
185 self.owner.create(name).await
186 }
187
188 pub async fn rename(&self, circle_id: CircleId, name: &str) -> Result<(), CircleError> {
191 self.owner.rename(circle_id, name).await
192 }
193
194 pub async fn add_member(
197 &self,
198 circle_id: CircleId,
199 member_pubkey: &str,
200 ) -> Result<(), CircleError> {
201 self.owner
202 .add_member(circle_id, member_pubkey.to_string(), CircleRole::Member)
203 .await
204 }
205
206 pub async fn remove_member(
209 &self,
210 circle_id: CircleId,
211 member_pubkey: &str,
212 ) -> Result<CircleOperationId, CircleError> {
213 self.owner
214 .remove_member(circle_id, member_pubkey.to_string())
215 .await
216 }
217
218 pub async fn resolve(
222 &self,
223 circle_id: CircleId,
224 chosen: CircleControlCoord,
225 ) -> Result<(), CircleError> {
226 self.owner.resolve(circle_id, chosen).await
227 }
228
229 pub async fn cancel_close(
232 &self,
233 circle_id: CircleId,
234 ) -> Result<CircleOperationId, CircleError> {
235 self.owner.cancel_close(circle_id).await
236 }
237
238 pub async fn exclude_close_device(
242 &self,
243 circle_id: CircleId,
244 device_id: StoreDeviceId,
245 ) -> Result<(), CircleError> {
246 self.owner.exclude_close_device(circle_id, device_id).await
247 }
248
249 pub async fn delete(&self, circle_id: CircleId) -> Result<(), CircleError> {
251 self.owner.delete(circle_id).await
252 }
253
254 pub async fn retry_operation(
256 &self,
257 operation_id: CircleOperationId,
258 ) -> Result<(), CircleError> {
259 self.owner.retry(operation_id).await
260 }
261
262 pub async fn discard_operation(
266 &self,
267 operation_id: CircleOperationId,
268 ) -> Result<(), CircleError> {
269 self.owner.discard(operation_id).await
270 }
271
272 pub async fn list(&self) -> Result<Vec<Circle>, CircleError> {
275 self.owner.list().await
276 }
277
278 pub async fn members(&self, circle_id: CircleId) -> Result<Vec<CircleMemberInfo>, CircleError> {
280 self.owner.members(circle_id).await
281 }
282
283 pub async fn operations(&self) -> Result<Vec<CircleOperationInfo>, CircleError> {
286 self.owner.operations().await
287 }
288
289 pub async fn close_status(
291 &self,
292 circle_id: CircleId,
293 ) -> Result<CircleCloseStatus, CircleError> {
294 self.owner.close_status(circle_id).await
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 fn circle_id(byte: u8) -> CircleId {
303 CircleId::from_bytes([byte; 16])
304 }
305
306 fn device_id(byte: u8) -> StoreDeviceId {
307 format!("{byte:02x}")
308 .repeat(32)
309 .parse()
310 .expect("a 64-character hexadecimal device id")
311 }
312
313 fn close_id(byte: u8) -> CircleEpochCloseId {
314 serde_json::from_str(&format!("\"{}\"", format!("{byte:02x}").repeat(32)))
315 .expect("a 64-character hexadecimal close id")
316 }
317
318 fn authority_lost_block() -> CircleOperationBlock {
319 serde_json::from_str(&format!(
320 r#"{{"authority_lost":{{"grant_id":"{}"}}}}"#,
321 "cd".repeat(32)
322 ))
323 .expect("an authority-lost block with a 64-character hexadecimal grant id")
324 }
325
326 #[test]
331 fn internal_refusals_map_to_public_variants() {
332 let circle = circle_id(1);
333
334 let deleted: CircleError = CircleOperationError::Deleted { circle_id: circle }.into();
335 assert!(matches!(deleted, CircleError::Deleted { circle_id } if circle_id == circle));
336
337 let not_conflicted: CircleError =
338 CircleOperationError::NotConflicted { circle_id: circle }.into();
339 assert!(
340 matches!(not_conflicted, CircleError::NotConflicted { circle_id } if circle_id == circle)
341 );
342
343 let no_close: CircleError =
344 CircleOperationError::NoCloseToCancel { circle_id: circle }.into();
345 assert!(
346 matches!(no_close, CircleError::NoCloseToCancel { circle_id } if circle_id == circle)
347 );
348
349 let device = device_id(7);
350 let not_participant: CircleError = CircleOperationError::DeviceNotACloseParticipant {
351 circle_id: circle,
352 device_id: device,
353 }
354 .into();
355 assert!(matches!(
356 not_participant,
357 CircleError::DeviceNotACloseParticipant { circle_id, device_id }
358 if circle_id == circle && device_id == device
359 ));
360
361 let conflicted: CircleError = CircleOperationError::Conflicted { circle_id: circle }.into();
362 assert!(matches!(conflicted, CircleError::Conflicted { circle_id } if circle_id == circle));
363
364 let browsable: CircleError = CircleOperationError::BrowsableStorage.into();
365 assert!(matches!(browsable, CircleError::BrowsableStorage));
366
367 let rotation: CircleError = CircleOperationError::RotationRequired {
368 circle_id: circle,
369 removed_members: vec!["pk".to_string()],
370 }
371 .into();
372 assert!(matches!(
373 rotation,
374 CircleError::RotationRequired { circle_id, removed_members }
375 if circle_id == circle && removed_members == vec!["pk".to_string()]
376 ));
377
378 let no_exclude: CircleError =
379 CircleOperationError::NoCloseToExclude { circle_id: circle }.into();
380 assert!(
381 matches!(no_exclude, CircleError::NoCloseToExclude { circle_id } if circle_id == circle)
382 );
383
384 let chosen: CircleError =
385 CircleOperationError::ChosenBranchNotRetained { circle_id: circle }.into();
386 assert!(
387 matches!(chosen, CircleError::ChosenBranchNotRetained { circle_id } if circle_id == circle)
388 );
389
390 let operation_id = CircleOperationId::placeholder("discard-map-seed");
391 let not_blocked: CircleError = CircleOperationError::NotBlocked {
392 operation_id: operation_id.clone(),
393 }
394 .into();
395 assert!(matches!(
396 not_blocked,
397 CircleError::NotBlocked {
398 operation_id: mapped
399 } if mapped == operation_id
400 ));
401
402 let discard: CircleError = CircleOperationError::DiscardRequiresNonactivation {
403 operation_id: operation_id.clone(),
404 }
405 .into();
406 assert!(matches!(
407 discard,
408 CircleError::DiscardRequiresNonactivation { operation_id: mapped }
409 if mapped == operation_id
410 ));
411
412 let block = authority_lost_block();
413 let blocked: CircleError = CircleOperationError::Blocked {
414 circle_id: circle,
415 block: block.clone(),
416 }
417 .into();
418 assert!(matches!(
419 blocked,
420 CircleError::Blocked {
421 circle_id,
422 block: mapped
423 } if circle_id == circle && mapped == block
424 ));
425
426 let close_id = close_id(0xab);
427 let excluded_reset: CircleError = CircleOperationError::ExcludedDeviceMustReset {
428 circle_id: circle,
429 close_id,
430 }
431 .into();
432 assert!(matches!(
433 excluded_reset,
434 CircleError::ExcludedDeviceMustReset {
435 circle_id,
436 close_id: mapped
437 } if circle_id == circle && mapped == close_id
438 ));
439
440 let closing_resolution: CircleError =
441 CircleOperationError::ResolveToClosingBranch { circle_id: circle }.into();
442 assert!(matches!(
443 closing_resolution,
444 CircleError::ResolveToClosingBranch { circle_id } if circle_id == circle
445 ));
446
447 let closed: CircleError = CircleOperationError::CommandChannelClosed.into();
450 assert!(matches!(closed, CircleError::LoopNotRunning));
451 let internal: CircleError = CircleOperationError::InvalidState("bad".to_string()).into();
452 assert!(matches!(internal, CircleError::Operation(_)));
453 }
454
455 #[test]
458 fn no_public_error_display_names_removed_protocol_vocabulary() {
459 let circle = circle_id(2);
460 let close_id = close_id(0xab);
461 let displays = [
462 CircleError::NotConfigured.to_string(),
463 CircleError::LoopNotRunning.to_string(),
464 CircleError::BrowsableStorage.to_string(),
465 CircleError::RotationRequired {
466 circle_id: circle,
467 removed_members: vec!["pk".to_string()],
468 }
469 .to_string(),
470 CircleError::Conflicted { circle_id: circle }.to_string(),
471 CircleError::Deleted { circle_id: circle }.to_string(),
472 CircleError::NotConflicted { circle_id: circle }.to_string(),
473 CircleError::ChosenBranchNotRetained { circle_id: circle }.to_string(),
474 CircleError::NoCloseToCancel { circle_id: circle }.to_string(),
475 CircleError::NoCloseToExclude { circle_id: circle }.to_string(),
476 CircleError::DeviceNotACloseParticipant {
477 circle_id: circle,
478 device_id: device_id(3),
479 }
480 .to_string(),
481 CircleError::ResolveToClosingBranch { circle_id: circle }.to_string(),
482 CircleError::ExcludedDeviceMustReset {
483 circle_id: circle,
484 close_id,
485 }
486 .to_string(),
487 CircleError::NotBlocked {
488 operation_id: CircleOperationId::placeholder("not-blocked-display"),
489 }
490 .to_string(),
491 CircleError::DiscardRequiresNonactivation {
492 operation_id: CircleOperationId::placeholder("discard-display"),
493 }
494 .to_string(),
495 CircleError::Blocked {
496 circle_id: circle,
497 block: authority_lost_block(),
498 }
499 .to_string(),
500 CircleError::Operation(Box::new(CircleOperationError::InvalidState(
501 "state invalid".to_string(),
502 )))
503 .to_string(),
504 ];
505 for display in displays {
506 let lowered = display.to_lowercase();
507 for forbidden in ["serial", "policy", "engine", "coordination"] {
508 assert!(
509 !lowered.contains(forbidden),
510 "public Circle error names removed protocol vocabulary {forbidden:?}: {display}"
511 );
512 }
513 }
514 }
515}