1use coven_core::{
7 Circle, CircleCloseStatus, CircleControlCoord, CircleId, CircleMemberInfo,
8 CircleOperationBlock, CircleOperationId, CircleOperationInfo, CircleRole, StoreDeviceId,
9};
10
11use crate::handle::CovenHandle;
12use crate::sync::store::CircleOperationError;
13use crate::sync::sync_manager::SyncError;
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 operation {operation_id} is not blocked")]
69 NotBlocked { operation_id: CircleOperationId },
70 #[error("circle operation {operation_id} discard requires verified permanent nonactivation")]
74 DiscardRequiresNonactivation { operation_id: CircleOperationId },
75 #[error("circle operation for {circle_id} is blocked: {block}")]
78 Blocked {
79 circle_id: CircleId,
80 block: CircleOperationBlock,
81 },
82 #[error("the local identity is not established: {0}")]
84 Identity(String),
85 #[error("circle protocol error: {0}")]
87 Protocol(String),
88}
89
90impl From<CircleOperationError> for CircleError {
91 fn from(error: CircleOperationError) -> Self {
92 match error {
93 CircleOperationError::BrowsableStorage => Self::BrowsableStorage,
94 CircleOperationError::RotationRequired {
95 circle_id,
96 removed_members,
97 } => Self::RotationRequired {
98 circle_id,
99 removed_members,
100 },
101 CircleOperationError::Conflicted { circle_id } => Self::Conflicted { circle_id },
102 CircleOperationError::Deleted { circle_id } => Self::Deleted { circle_id },
103 CircleOperationError::NotConflicted { circle_id } => Self::NotConflicted { circle_id },
104 CircleOperationError::ChosenBranchNotRetained { circle_id } => {
105 Self::ChosenBranchNotRetained { circle_id }
106 }
107 CircleOperationError::NoCloseToCancel { circle_id } => {
108 Self::NoCloseToCancel { circle_id }
109 }
110 CircleOperationError::NoCloseToExclude { circle_id } => {
111 Self::NoCloseToExclude { circle_id }
112 }
113 CircleOperationError::DeviceNotACloseParticipant {
114 circle_id,
115 device_id,
116 } => Self::DeviceNotACloseParticipant {
117 circle_id,
118 device_id,
119 },
120 CircleOperationError::NotBlocked { operation_id } => Self::NotBlocked { operation_id },
121 CircleOperationError::DiscardRequiresNonactivation { operation_id } => {
122 Self::DiscardRequiresNonactivation { operation_id }
123 }
124 CircleOperationError::Blocked { circle_id, block } => {
125 Self::Blocked { circle_id, block }
126 }
127 CircleOperationError::CommandChannelClosed
128 | CircleOperationError::ReplyChannelClosed => Self::LoopNotRunning,
129 other => Self::Protocol(other.to_string()),
132 }
133 }
134}
135
136impl From<SyncError> for CircleError {
137 fn from(error: SyncError) -> Self {
138 match error {
139 SyncError::NotConfigured => Self::NotConfigured,
140 SyncError::LoopNotRunning => Self::LoopNotRunning,
141 SyncError::Circle(error) => error.into(),
142 SyncError::Key(error) => Self::Identity(error.to_string()),
143 other => Self::Protocol(other.to_string()),
144 }
145 }
146}
147
148pub struct Circles<'a> {
151 handle: &'a CovenHandle,
152}
153
154impl<'a> Circles<'a> {
155 pub(crate) fn new(handle: &'a CovenHandle) -> Self {
156 Self { handle }
157 }
158
159 fn manager(
160 &self,
161 ) -> Result<std::sync::Arc<crate::sync::sync_manager::SyncManager>, CircleError> {
162 self.handle.sync_manager().ok_or(CircleError::NotConfigured)
163 }
164
165 pub async fn create(&self, name: &str) -> Result<CircleId, CircleError> {
169 self.manager()?.create_circle(name).await
170 }
171
172 pub async fn rename(&self, circle_id: CircleId, name: &str) -> Result<(), CircleError> {
175 self.manager()?.rename_circle(circle_id, name).await
176 }
177
178 pub async fn add_member(
181 &self,
182 circle_id: CircleId,
183 member_pubkey: &str,
184 ) -> Result<(), CircleError> {
185 self.manager()?
186 .add_circle_member(circle_id, member_pubkey.to_string(), CircleRole::Member)
187 .await
188 }
189
190 pub async fn remove_member(
193 &self,
194 circle_id: CircleId,
195 member_pubkey: &str,
196 ) -> Result<CircleOperationId, CircleError> {
197 self.manager()?
198 .remove_circle_member(circle_id, member_pubkey.to_string())
199 .await
200 }
201
202 pub async fn resolve(
206 &self,
207 circle_id: CircleId,
208 chosen: CircleControlCoord,
209 ) -> Result<(), CircleError> {
210 self.manager()?
211 .resolve_circle_control(circle_id, chosen)
212 .await
213 }
214
215 pub async fn cancel_close(
218 &self,
219 circle_id: CircleId,
220 ) -> Result<CircleOperationId, CircleError> {
221 self.manager()?.cancel_circle_epoch_close(circle_id).await
222 }
223
224 pub async fn exclude_close_device(
228 &self,
229 circle_id: CircleId,
230 device_id: StoreDeviceId,
231 ) -> Result<(), CircleError> {
232 self.manager()?
233 .exclude_circle_close_device(circle_id, device_id)
234 .await
235 }
236
237 pub async fn delete(&self, circle_id: CircleId) -> Result<(), CircleError> {
239 self.manager()?.delete_circle(circle_id).await
240 }
241
242 pub async fn retry_operation(
244 &self,
245 operation_id: CircleOperationId,
246 ) -> Result<(), CircleError> {
247 self.manager()?.retry_circle_operation(operation_id).await
248 }
249
250 pub async fn discard_operation(
254 &self,
255 operation_id: CircleOperationId,
256 ) -> Result<(), CircleError> {
257 self.manager()?.discard_circle_operation(operation_id).await
258 }
259
260 pub async fn list(&self) -> Result<Vec<Circle>, CircleError> {
263 self.manager()?.list_circles().await
264 }
265
266 pub async fn members(&self, circle_id: CircleId) -> Result<Vec<CircleMemberInfo>, CircleError> {
268 self.manager()?.circle_members(circle_id).await
269 }
270
271 pub async fn operations(&self) -> Result<Vec<CircleOperationInfo>, CircleError> {
274 self.manager()?.circle_operations().await
275 }
276
277 pub async fn close_status(
279 &self,
280 circle_id: CircleId,
281 ) -> Result<CircleCloseStatus, CircleError> {
282 self.manager()?.circle_close_status(circle_id).await
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn circle_id(byte: u8) -> CircleId {
291 CircleId::from_bytes([byte; 16])
292 }
293
294 fn device_id(byte: u8) -> StoreDeviceId {
295 format!("{byte:02x}")
296 .repeat(32)
297 .parse()
298 .expect("a 64-character hexadecimal device id")
299 }
300
301 #[test]
306 fn internal_refusals_map_to_public_variants() {
307 let circle = circle_id(1);
308
309 let deleted: CircleError = CircleOperationError::Deleted { circle_id: circle }.into();
310 assert!(matches!(deleted, CircleError::Deleted { circle_id } if circle_id == circle));
311
312 let not_conflicted: CircleError =
313 CircleOperationError::NotConflicted { circle_id: circle }.into();
314 assert!(
315 matches!(not_conflicted, CircleError::NotConflicted { circle_id } if circle_id == circle)
316 );
317
318 let no_close: CircleError =
319 CircleOperationError::NoCloseToCancel { circle_id: circle }.into();
320 assert!(
321 matches!(no_close, CircleError::NoCloseToCancel { circle_id } if circle_id == circle)
322 );
323
324 let device = device_id(7);
325 let not_participant: CircleError = CircleOperationError::DeviceNotACloseParticipant {
326 circle_id: circle,
327 device_id: device,
328 }
329 .into();
330 assert!(matches!(
331 not_participant,
332 CircleError::DeviceNotACloseParticipant { circle_id, device_id }
333 if circle_id == circle && device_id == device
334 ));
335
336 let conflicted: CircleError = CircleOperationError::Conflicted { circle_id: circle }.into();
337 assert!(matches!(conflicted, CircleError::Conflicted { circle_id } if circle_id == circle));
338
339 let browsable: CircleError = CircleOperationError::BrowsableStorage.into();
340 assert!(matches!(browsable, CircleError::BrowsableStorage));
341
342 let rotation: CircleError = CircleOperationError::RotationRequired {
343 circle_id: circle,
344 removed_members: vec!["pk".to_string()],
345 }
346 .into();
347 assert!(matches!(
348 rotation,
349 CircleError::RotationRequired { circle_id, removed_members }
350 if circle_id == circle && removed_members == vec!["pk".to_string()]
351 ));
352
353 let no_exclude: CircleError =
354 CircleOperationError::NoCloseToExclude { circle_id: circle }.into();
355 assert!(
356 matches!(no_exclude, CircleError::NoCloseToExclude { circle_id } if circle_id == circle)
357 );
358
359 let chosen: CircleError =
360 CircleOperationError::ChosenBranchNotRetained { circle_id: circle }.into();
361 assert!(
362 matches!(chosen, CircleError::ChosenBranchNotRetained { circle_id } if circle_id == circle)
363 );
364
365 let operation_id = CircleOperationId::placeholder("discard-map-seed");
366 let discard: CircleError = CircleOperationError::DiscardRequiresNonactivation {
367 operation_id: operation_id.clone(),
368 }
369 .into();
370 assert!(matches!(
371 discard,
372 CircleError::DiscardRequiresNonactivation { operation_id: mapped }
373 if mapped == operation_id
374 ));
375
376 let closed: CircleError = CircleOperationError::CommandChannelClosed.into();
379 assert!(matches!(closed, CircleError::LoopNotRunning));
380 let internal: CircleError = CircleOperationError::InvalidState("bad".to_string()).into();
381 assert!(matches!(internal, CircleError::Protocol(_)));
382 }
383
384 #[test]
387 fn no_public_error_display_names_removed_protocol_vocabulary() {
388 let circle = circle_id(2);
389 let displays = [
390 CircleError::NotConfigured.to_string(),
391 CircleError::LoopNotRunning.to_string(),
392 CircleError::BrowsableStorage.to_string(),
393 CircleError::RotationRequired {
394 circle_id: circle,
395 removed_members: vec!["pk".to_string()],
396 }
397 .to_string(),
398 CircleError::Conflicted { circle_id: circle }.to_string(),
399 CircleError::Deleted { circle_id: circle }.to_string(),
400 CircleError::NotConflicted { circle_id: circle }.to_string(),
401 CircleError::ChosenBranchNotRetained { circle_id: circle }.to_string(),
402 CircleError::NoCloseToCancel { circle_id: circle }.to_string(),
403 CircleError::NoCloseToExclude { circle_id: circle }.to_string(),
404 CircleError::DeviceNotACloseParticipant {
405 circle_id: circle,
406 device_id: device_id(3),
407 }
408 .to_string(),
409 CircleError::Identity("locked".to_string()).to_string(),
410 CircleError::Protocol("state invalid".to_string()).to_string(),
411 ];
412 for display in displays {
413 let lowered = display.to_lowercase();
414 for forbidden in ["serial", "policy", "engine", "coordination"] {
415 assert!(
416 !lowered.contains(forbidden),
417 "public Circle error names removed protocol vocabulary {forbidden:?}: {display}"
418 );
419 }
420 }
421 }
422}