- secrets-core: update flow validates and applies secret links - secrets-mcp: MCP tool params and UI for managing links on edit - Align errors and templates; minor crypto/.gitignore tweaks Made-with: Cursor
143 lines
4.6 KiB
Rust
143 lines
4.6 KiB
Rust
use sqlx::error::DatabaseError;
|
|
|
|
/// Structured business errors for the secrets service.
|
|
///
|
|
/// These replace ad-hoc `anyhow` strings for expected failure modes,
|
|
/// allowing MCP and Web layers to map to appropriate protocol-level errors.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
#[error("A secret with the name '{secret_name}' already exists for this user")]
|
|
ConflictSecretName { secret_name: String },
|
|
|
|
#[error("An entry with folder='{folder}' and name='{name}' already exists")]
|
|
ConflictEntryName { folder: String, name: String },
|
|
|
|
#[error("Entry not found")]
|
|
NotFoundEntry,
|
|
|
|
#[error("Validation failed: {message}")]
|
|
Validation { message: String },
|
|
|
|
#[error("Concurrent modification detected")]
|
|
ConcurrentModification,
|
|
|
|
#[error("Decryption failed — the encryption key may be incorrect")]
|
|
DecryptionFailed,
|
|
|
|
#[error(transparent)]
|
|
Internal(#[from] anyhow::Error),
|
|
}
|
|
|
|
impl AppError {
|
|
/// Try to convert a sqlx database error into a structured `AppError`.
|
|
///
|
|
/// The caller should provide the context (which table was being written,
|
|
/// what values were being inserted) so we can produce a meaningful error.
|
|
pub fn from_db_error(err: sqlx::Error, ctx: DbErrorContext<'_>) -> Self {
|
|
if let sqlx::Error::Database(ref db_err) = err
|
|
&& db_err.code().as_deref() == Some("23505")
|
|
{
|
|
return Self::from_unique_violation(db_err.as_ref(), ctx);
|
|
}
|
|
AppError::Internal(err.into())
|
|
}
|
|
|
|
fn from_unique_violation(db_err: &dyn DatabaseError, ctx: DbErrorContext<'_>) -> Self {
|
|
let constraint = db_err.constraint();
|
|
|
|
match constraint {
|
|
Some("idx_secrets_unique_user_name") => AppError::ConflictSecretName {
|
|
secret_name: ctx.secret_name.unwrap_or("unknown").to_string(),
|
|
},
|
|
Some("idx_entries_unique_user") | Some("idx_entries_unique_legacy") => {
|
|
AppError::ConflictEntryName {
|
|
folder: ctx.folder.unwrap_or("").to_string(),
|
|
name: ctx.name.unwrap_or("unknown").to_string(),
|
|
}
|
|
}
|
|
_ => {
|
|
// Fall back to message-based detection for unnamed constraints
|
|
let msg = db_err.message();
|
|
if msg.contains("secrets") {
|
|
AppError::ConflictSecretName {
|
|
secret_name: ctx.secret_name.unwrap_or("unknown").to_string(),
|
|
}
|
|
} else {
|
|
AppError::ConflictEntryName {
|
|
folder: ctx.folder.unwrap_or("").to_string(),
|
|
name: ctx.name.unwrap_or("unknown").to_string(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Context hints used when converting a database error to `AppError`.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct DbErrorContext<'a> {
|
|
pub secret_name: Option<&'a str>,
|
|
pub folder: Option<&'a str>,
|
|
pub name: Option<&'a str>,
|
|
}
|
|
|
|
impl<'a> DbErrorContext<'a> {
|
|
pub fn secret_name(name: &'a str) -> Self {
|
|
Self {
|
|
secret_name: Some(name),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn entry(folder: &'a str, name: &'a str) -> Self {
|
|
Self {
|
|
folder: Some(folder),
|
|
name: Some(name),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn app_error_display_messages() {
|
|
let err = AppError::ConflictSecretName {
|
|
secret_name: "token".to_string(),
|
|
};
|
|
assert!(err.to_string().contains("token"));
|
|
|
|
let err = AppError::ConflictEntryName {
|
|
folder: "refining".to_string(),
|
|
name: "gitea".to_string(),
|
|
};
|
|
assert!(err.to_string().contains("refining"));
|
|
assert!(err.to_string().contains("gitea"));
|
|
|
|
let err = AppError::NotFoundEntry;
|
|
assert_eq!(err.to_string(), "Entry not found");
|
|
|
|
let err = AppError::Validation {
|
|
message: "too long".to_string(),
|
|
};
|
|
assert!(err.to_string().contains("too long"));
|
|
|
|
let err = AppError::ConcurrentModification;
|
|
assert!(err.to_string().contains("Concurrent modification"));
|
|
}
|
|
|
|
#[test]
|
|
fn db_error_context_helpers() {
|
|
let ctx = DbErrorContext::secret_name("my_key");
|
|
assert_eq!(ctx.secret_name, Some("my_key"));
|
|
assert!(ctx.folder.is_none());
|
|
|
|
let ctx = DbErrorContext::entry("prod", "db-creds");
|
|
assert_eq!(ctx.folder, Some("prod"));
|
|
assert_eq!(ctx.name, Some("db-creds"));
|
|
assert!(ctx.secret_name.is_none());
|
|
}
|
|
}
|