Skip to main content

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}