diff --git a/crates/secret-kit/CHANGELOG.md b/crates/secret-kit/CHANGELOG.md index 1da8dba..7e50473 100644 --- a/crates/secret-kit/CHANGELOG.md +++ b/crates/secret-kit/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on *Keep a Changelog*, and this project adheres to *Semantic ## [Unreleased] ### Changed +- `SecretError` 到 `ErrorCode` / `ErrorRecord` 的字面量错误码映射不再依赖 `expect`;crate 现在通过集中缓存的映射描述符构造错误记录,并在字面量失效时 fail-closed 退化到 `secret.internal`,同时补上对应回归测试。 - `secret-kit` 的 `SecretResolver` / `CacheAwareSecretResolver` 默认桥接现在带有 fail-closed 递归保护;第三方如果同时依赖两侧默认实现,不再因为默认互调而栈溢出,而是返回显式 `not_resolvable` 错误,同时保留既有 typed-first 与 `&str`-first 单侧实现兼容。 - `secret-kit` 在 Unix 上对内建 CLI 的 trusted ambient `PATH` 目录新增 stat/类型/权限校验:不可 stat、非目录、world-writable 且不满足 sticky-root-owned 例外的 allowlist 目录不再被信任;对应单元测试已覆盖默认递归保护与目录安全判定。 - `secret-kit`:`SecretResolver` / `CacheAwareSecretResolver` 现在把 `SecretSpec` 提升为公开扩展主边界,同时保留对既有 `&str`-first 实现者的默认兼容转发;typed hooks 会通过 canonical `secret://` 渲染回退到旧入口,避免把这次边界收口变成一次公开 trait breaking change。 diff --git a/crates/secret-kit/src/types.rs b/crates/secret-kit/src/types.rs index 2f83afa..63a2b63 100644 --- a/crates/secret-kit/src/types.rs +++ b/crates/secret-kit/src/types.rs @@ -1,5 +1,6 @@ use std::error::Error as StdError; use std::fmt::{self, Display, Formatter}; +use std::sync::OnceLock; use error_kit::{ErrorCategory, ErrorCode, ErrorRecord, ErrorRetryAdvice}; use structured_text_kit::{CatalogTextRef, StructuredText, structured_text}; @@ -21,7 +22,90 @@ pub enum SecretError { pub type Result = std::result::Result; +static SECRET_IO_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret.io"); +static SECRET_JSON_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret.json"); +static SECRET_LOOKUP_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret.lookup"); +static SECRET_INVALID_SPEC_ERROR_CODE: SecretErrorCode = + SecretErrorCode::new("secret.invalid_spec"); +static SECRET_COMMAND_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret.command"); +static SECRET_INTERNAL_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret.internal"); + +struct SecretErrorCode { + literal: &'static str, + parsed: OnceLock, +} + +impl SecretErrorCode { + const fn new(literal: &'static str) -> Self { + Self { + literal, + parsed: OnceLock::new(), + } + } + + fn get(&'static self) -> ErrorCode { + self.parsed + .get_or_init(|| parse_error_code_literal(self.literal)) + .clone() + } +} + +#[derive(Clone, Copy)] +struct SecretErrorMapping { + code: &'static SecretErrorCode, + category: ErrorCategory, +} + +impl SecretErrorMapping { + const fn new(code: &'static SecretErrorCode, category: ErrorCategory) -> Self { + Self { code, category } + } + + fn new_record(self, text: StructuredText, retry_advice: ErrorRetryAdvice) -> ErrorRecord { + ErrorRecord::new(self.code.get(), text) + .with_category(self.category) + .with_retry_advice(retry_advice) + } +} + +fn parse_error_code_literal(literal: &'static str) -> ErrorCode { + ErrorCode::try_new(literal).unwrap_or_else(|_| SECRET_INTERNAL_ERROR_CODE.get()) +} + impl SecretError { + fn mapping(&self) -> SecretErrorMapping { + match self { + Self::Io { .. } => Self::io_mapping(), + Self::Json { .. } => Self::json_mapping(), + Self::Lookup(_) => Self::lookup_mapping(), + Self::InvalidSpec(_) => Self::invalid_spec_mapping(), + Self::Command(_) => Self::command_mapping(), + } + } + + const fn io_mapping() -> SecretErrorMapping { + SecretErrorMapping::new(&SECRET_IO_ERROR_CODE, ErrorCategory::ExternalDependency) + } + + const fn json_mapping() -> SecretErrorMapping { + SecretErrorMapping::new(&SECRET_JSON_ERROR_CODE, ErrorCategory::InvalidInput) + } + + const fn lookup_mapping() -> SecretErrorMapping { + SecretErrorMapping::new(&SECRET_LOOKUP_ERROR_CODE, ErrorCategory::NotFound) + } + + const fn invalid_spec_mapping() -> SecretErrorMapping { + SecretErrorMapping::new(&SECRET_INVALID_SPEC_ERROR_CODE, ErrorCategory::InvalidInput) + } + + const fn command_mapping() -> SecretErrorMapping { + SecretErrorMapping::new( + &SECRET_COMMAND_ERROR_CODE, + ErrorCategory::ExternalDependency, + ) + } + fn io_retry_advice(source: &std::io::Error) -> ErrorRetryAdvice { match source.kind() { std::io::ErrorKind::NotFound @@ -55,33 +139,12 @@ impl SecretError { #[must_use] pub fn error_code(&self) -> ErrorCode { - match self { - Self::Io { .. } => { - ErrorCode::try_new("secret.io").expect("literal error code should validate") - } - Self::Json { .. } => { - ErrorCode::try_new("secret.json").expect("literal error code should validate") - } - Self::Lookup(_) => { - ErrorCode::try_new("secret.lookup").expect("literal error code should validate") - } - Self::InvalidSpec(_) => ErrorCode::try_new("secret.invalid_spec") - .expect("literal error code should validate"), - Self::Command(_) => { - ErrorCode::try_new("secret.command").expect("literal error code should validate") - } - } + self.mapping().code.get() } #[must_use] pub fn error_category(&self) -> ErrorCategory { - match self { - Self::Io { .. } => ErrorCategory::ExternalDependency, - Self::Json { .. } => ErrorCategory::InvalidInput, - Self::Lookup(_) => ErrorCategory::NotFound, - Self::InvalidSpec(_) => ErrorCategory::InvalidInput, - Self::Command(_) => ErrorCategory::ExternalDependency, - } + self.mapping().category } #[must_use] @@ -97,49 +160,23 @@ impl SecretError { #[must_use] pub fn error_record(&self) -> ErrorRecord { - ErrorRecord::new(self.error_code(), self.structured_text().clone()) - .with_category(self.error_category()) - .with_retry_advice(self.retry_advice()) + self.mapping() + .new_record(self.structured_text().clone(), self.retry_advice()) } #[must_use] pub fn into_error_record(self) -> ErrorRecord { - let category = self.error_category(); let retry_advice = self.retry_advice(); match self { - Self::Io { text, source } => ErrorRecord::new( - ErrorCode::try_new("secret.io").expect("literal error code should validate"), - text, - ) - .with_category(category) - .with_retry_advice(retry_advice) - .with_source(source), - Self::Json { text, source } => ErrorRecord::new( - ErrorCode::try_new("secret.json").expect("literal error code should validate"), - text, - ) - .with_category(category) - .with_retry_advice(retry_advice) - .with_source(source), - Self::Lookup(text) => ErrorRecord::new( - ErrorCode::try_new("secret.lookup").expect("literal error code should validate"), - text, - ) - .with_category(category) - .with_retry_advice(retry_advice), - Self::InvalidSpec(text) => ErrorRecord::new( - ErrorCode::try_new("secret.invalid_spec") - .expect("literal error code should validate"), - text, - ) - .with_category(category) - .with_retry_advice(retry_advice), - Self::Command(text) => ErrorRecord::new( - ErrorCode::try_new("secret.command").expect("literal error code should validate"), - text, - ) - .with_category(category) - .with_retry_advice(retry_advice), + Self::Io { text, source } => Self::io_mapping() + .new_record(text, retry_advice) + .with_source(source), + Self::Json { text, source } => Self::json_mapping() + .new_record(text, retry_advice) + .with_source(source), + Self::Lookup(text) => Self::lookup_mapping().new_record(text, retry_advice), + Self::InvalidSpec(text) => Self::invalid_spec_mapping().new_record(text, retry_advice), + Self::Command(text) => Self::command_mapping().new_record(text, retry_advice), } } @@ -219,3 +256,39 @@ impl From for ErrorRecord { error.into_error_record() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_literal_error_code_falls_back_to_internal_code() { + static INVALID_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret invalid spec"); + + assert_eq!(INVALID_ERROR_CODE.get().as_str(), "secret.internal"); + } + + #[test] + fn invalid_literal_error_record_uses_internal_code_without_losing_metadata() { + static INVALID_ERROR_CODE: SecretErrorCode = SecretErrorCode::new("secret invalid spec"); + + let record = SecretErrorMapping::new(&INVALID_ERROR_CODE, ErrorCategory::InvalidInput) + .new_record( + structured_text!("error_detail.secret.not_resolvable"), + ErrorRetryAdvice::DoNotRetry, + ); + + assert_eq!(record.code().as_str(), "secret.internal"); + assert_eq!(record.category(), ErrorCategory::InvalidInput); + assert_eq!(record.retry_advice(), ErrorRetryAdvice::DoNotRetry); + } + + #[test] + fn secret_error_keeps_existing_code_for_valid_literals() { + let error = SecretError::Lookup(structured_text!("error_detail.secret.not_resolvable")); + + assert_eq!(error.error_code().as_str(), "secret.lookup"); + assert_eq!(error.error_record().code().as_str(), "secret.lookup"); + assert_eq!(error.into_error_record().code().as_str(), "secret.lookup"); + } +}