Change SecretError::Resolve to preserve full error chain via Arc
Arc<dyn Error + Send + Sync> satisfies Clone without requiring the
inner error to be Clone. OnceLock in SecretSource::File now stores
Arc<dyn Error> instead of a formatted string, and quire-ci wraps
reqwest errors in Arc rather than stringifying them.
https://claude.ai/code/session_01GfccpUReesMY5Rb4FhajhT
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 2218574..9700927 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -19,6 +19,7 @@ use quire_core::fennel::FennelError;
use quire_core::secret::{Error as SecretError, Result as SecretResult, SecretRegistry};
use quire_core::telemetry::{self, FmtMode, MietteLayer};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
+use std::sync::Arc;
/// Errors from running a job's `run_fn`. Lua errors are re-wrapped
/// via [`FennelError::from_lua`] so they carry the same source-code
@@ -255,15 +256,15 @@ impl RunClient {
fn fetch_secret(&self, name: &str) -> SecretResult<String> {
let resp = self
.get(&format!("secrets/{name}"))
- .map_err(|e| SecretError::Resolve(e.to_string()))?;
+ .map_err(|e| SecretError::Resolve(Arc::new(e)))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(SecretError::UnknownSecret(name.to_string()));
}
resp.error_for_status()
- .map_err(|e| SecretError::Resolve(e.to_string()))?
+ .map_err(|e| SecretError::Resolve(Arc::new(e)))?
.json::<SecretResponse>()
.map(|r| r.value)
- .map_err(|e| SecretError::Resolve(e.to_string()))
+ .map_err(|e| SecretError::Resolve(Arc::new(e)))
}
}
diff --git a/quire-core/src/secret.rs b/quire-core/src/secret.rs
index ab6b826..73ffc33 100644
--- a/quire-core/src/secret.rs
+++ b/quire-core/src/secret.rs
@@ -1,18 +1,15 @@
use std::collections::HashMap;
use std::path::PathBuf;
-use std::sync::OnceLock;
+use std::sync::{Arc, OnceLock};
/// Errors produced by secret resolution.
#[derive(Debug, Clone, thiserror::Error)]
pub enum Error {
- /// File-backed secret could not be read.
- ///
- /// Stored as a string because `OnceLock` in `SecretString::reveal` caches
- /// the error and `std::io::Error` is not `Clone`. Once `once_cell_try`
- /// stabilizes (allowing `OnceLock::get_or_try_init` with a separate error
- /// type), we can store a structured error instead of a string.
- #[error("secret resolution failed: {0}")]
- Resolve(String),
+ /// Secret could not be resolved. The source error is preserved for
+ /// diagnostics. `Arc` provides `Clone` without requiring the inner
+ /// error to be `Clone`.
+ #[error("secret resolution failed")]
+ Resolve(#[source] Arc<dyn std::error::Error + Send + Sync>),
#[error("unknown secret: {0:?}")]
UnknownSecret(String),
@@ -38,7 +35,7 @@ enum SecretSource {
Plain(String),
File {
path: PathBuf,
- resolved: OnceLock<std::result::Result<String, String>>,
+ resolved: OnceLock<std::result::Result<String, Arc<dyn std::error::Error + Send + Sync>>>,
},
}
@@ -60,12 +57,6 @@ impl SecretString {
///
/// For the file variant, reads from disk on first call and caches the
/// result. Errors are also cached — subsequent calls return the same error.
- ///
- /// The error is stored as a formatted string inside `OnceLock` because
- /// `std::io::Error` is not `Clone`, and `OnceLock::get_or_init` requires
- /// the closure output to be `Sized` + ownable. Once `once_cell_try`
- /// stabilizes (allowing `OnceLock::get_or_try_init` with a separate error
- /// type), we can store a structured error instead of a string.
pub fn reveal(&self) -> Result<&str> {
match &self.0 {
SecretSource::Plain(s) => Ok(s.as_str()),
@@ -73,11 +64,11 @@ impl SecretString {
.get_or_init(|| {
fs_err::read_to_string(path)
.map(|s| s.strip_suffix('\n').unwrap_or(&s).to_string())
- .map_err(|e| format!("{}: {e}", path.display()))
+ .map_err(|e| Arc::new(e) as Arc<dyn std::error::Error + Send + Sync>)
})
.as_ref()
.map(|s| s.as_str())
- .map_err(|msg| Error::Resolve(msg.clone())),
+ .map_err(|arc| Error::Resolve(Arc::clone(arc))),
}
}
}