auths_id/storage/driver.rs
1//! Async storage driver trait for backend-agnostic storage.
2//!
3//! This trait abstracts storage operations to support both local (Git) and
4//! remote (S3, DynamoDB) backends. Local backends use `spawn_blocking` to
5//! provide async semantics over synchronous operations.
6//!
7//! # Design
8//!
9//! The trait uses simple blob-based operations rather than mirroring the
10//! full `RegistryBackend` API. Higher-level operations (identity storage,
11//! attestation management) are built on top of these primitives.
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use auths_id::storage::{StorageDriver, StorageError};
17//!
18//! async fn store_data(driver: &dyn StorageDriver) -> Result<(), StorageError> {
19//! driver.put_blob("identities/abc123", b"data").await?;
20//! let data = driver.get_blob("identities/abc123").await?;
21//! Ok(())
22//! }
23//! ```
24
25use async_trait::async_trait;
26use auths_core::error::AuthsErrorInfo;
27use std::fmt;
28
29/// Error type for storage operations.
30#[derive(Debug)]
31#[non_exhaustive]
32pub enum StorageError {
33 /// The requested path was not found.
34 NotFound(String),
35
36 /// Compare-and-swap conflict: the expected value didn't match.
37 CasConflict {
38 /// The expected value (None = expected to not exist).
39 expected: Option<Vec<u8>>,
40 /// The actual value found.
41 found: Option<Vec<u8>>,
42 },
43
44 /// An I/O or backend-specific error.
45 Io(Box<dyn std::error::Error + Send + Sync>),
46}
47
48impl fmt::Display for StorageError {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 StorageError::NotFound(path) => write!(f, "not found: {}", path),
52 StorageError::CasConflict { expected, found } => {
53 write!(
54 f,
55 "CAS conflict: expected {:?}, found {:?}",
56 expected.as_ref().map(|v| v.len()),
57 found.as_ref().map(|v| v.len())
58 )
59 }
60 StorageError::Io(e) => write!(f, "storage I/O error: {}", e),
61 }
62 }
63}
64
65impl std::error::Error for StorageError {
66 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
67 match self {
68 StorageError::Io(e) => Some(e.as_ref()),
69 _ => None,
70 }
71 }
72}
73
74impl AuthsErrorInfo for StorageError {
75 fn error_code(&self) -> &'static str {
76 match self {
77 Self::NotFound(_) => "AUTHS-E4409",
78 Self::CasConflict { .. } => "AUTHS-E4410",
79 Self::Io(_) => "AUTHS-E4411",
80 }
81 }
82
83 fn suggestion(&self) -> Option<&'static str> {
84 match self {
85 Self::NotFound(_) => Some("Verify the storage path exists and is initialized"),
86 Self::CasConflict { .. } => {
87 Some("A concurrent modification was detected; retry the operation")
88 }
89 Self::Io(_) => {
90 Some("Check file permissions, disk space, and storage backend connectivity")
91 }
92 }
93 }
94}
95
96impl StorageError {
97 /// Create a NotFound error for the given path.
98 pub fn not_found(path: impl Into<String>) -> Self {
99 StorageError::NotFound(path.into())
100 }
101
102 /// Create a CAS conflict error.
103 pub fn cas_conflict(expected: Option<Vec<u8>>, found: Option<Vec<u8>>) -> Self {
104 StorageError::CasConflict { expected, found }
105 }
106
107 /// Create an I/O error from any error type.
108 pub fn io<E: std::error::Error + Send + Sync + 'static>(e: E) -> Self {
109 StorageError::Io(Box::new(e))
110 }
111}
112
113/// Async storage driver trait.
114///
115/// This trait provides low-level blob storage operations with async semantics.
116/// Implementations can be backed by local filesystems (via `spawn_blocking`),
117/// cloud storage (S3, GCS), or databases (DynamoDB).
118///
119/// # Thread Safety
120///
121/// Implementations must be `Send + Sync` to allow sharing across async tasks.
122///
123/// # Error Handling
124///
125/// All methods return `StorageError` which distinguishes between:
126/// - `NotFound`: The path doesn't exist (normal condition)
127/// - `CasConflict`: Concurrent modification detected
128/// - `Io`: Backend-specific errors
129#[async_trait]
130pub trait StorageDriver: Send + Sync {
131 /// Read a blob from the given path.
132 ///
133 /// Returns `StorageError::NotFound` if the path doesn't exist.
134 async fn get_blob(&self, path: &str) -> Result<Vec<u8>, StorageError>;
135
136 /// Write a blob to the given path.
137 ///
138 /// Creates parent directories/prefixes as needed.
139 /// Overwrites any existing content at the path.
140 async fn put_blob(&self, path: &str, data: &[u8]) -> Result<(), StorageError>;
141
142 /// Atomic compare-and-swap update.
143 ///
144 /// Updates the value at `ref_key` only if it currently matches `expected`.
145 /// - `expected = None`: Only succeeds if the key doesn't exist (create)
146 /// - `expected = Some(bytes)`: Only succeeds if the current value equals `bytes`
147 ///
148 /// Returns `StorageError::CasConflict` if the condition isn't met.
149 async fn cas_update(
150 &self,
151 ref_key: &str,
152 expected: Option<&[u8]>,
153 new: &[u8],
154 ) -> Result<(), StorageError>;
155
156 /// List all paths under a prefix.
157 ///
158 /// Returns paths relative to the storage root, not relative to the prefix.
159 async fn list_prefix(&self, prefix: &str) -> Result<Vec<String>, StorageError>;
160
161 /// Check if a path exists.
162 async fn exists(&self, path: &str) -> Result<bool, StorageError>;
163
164 /// Delete a blob at the given path.
165 ///
166 /// Returns `Ok(())` even if the path didn't exist (idempotent).
167 async fn delete(&self, path: &str) -> Result<(), StorageError>;
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn storage_error_display() {
176 let err = StorageError::not_found("foo/bar");
177 assert_eq!(err.to_string(), "not found: foo/bar");
178
179 let err = StorageError::cas_conflict(Some(vec![1, 2, 3]), None);
180 assert!(err.to_string().contains("CAS conflict"));
181 }
182
183 #[test]
184 fn storage_error_io() {
185 let io_err = std::io::Error::other("test");
186 let err = StorageError::io(io_err);
187 assert!(err.to_string().contains("I/O error"));
188 }
189}