diff --git a/Cargo.lock b/Cargo.lock index ad450d2..f63c978 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4769,6 +4769,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "thiserror 1.0.69", "tracing", "url", "uuid", diff --git a/crates/zesdex-backend/src/app/bgbash/control.rs b/crates/zesdex-backend/src/app/bgbash/control.rs index 903bbd7..7219253 100644 --- a/crates/zesdex-backend/src/app/bgbash/control.rs +++ b/crates/zesdex-backend/src/app/bgbash/control.rs @@ -9,6 +9,7 @@ //! Why: a single static map (rather than storing jobs in `AppStateRest`) //! lets background jobs outlive the borrow of any particular state mutation //! and be looked up by id from tool calls issued at arbitrary points. +use std::convert::TryInto; use std::collections::HashMap; use std::sync::Mutex; use std::sync::OnceLock; @@ -83,7 +84,8 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> { // SAFETY: job.child_pid is the real PID of the spawned child; // SIGTERM is safe and the process may already be dead. unsafe { - libc::kill(job.child_pid as i32, libc::SIGTERM); + let pid_signed: i32 = job.child_pid.try_into().unwrap_or(0); + libc::kill(pid_signed, libc::SIGTERM); } debug!(%id, pid = job.child_pid, "bash_kill: SIGTERM sent"); } diff --git a/crates/zesdex-backend/src/app/mode/effort.rs b/crates/zesdex-backend/src/app/mode/effort.rs index 0dcab61..1846648 100644 --- a/crates/zesdex-backend/src/app/mode/effort.rs +++ b/crates/zesdex-backend/src/app/mode/effort.rs @@ -7,10 +7,6 @@ use tracing::debug; /// tokens and use lower temperature for more deterministic reasoning. pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; -/// Multiplier applied to the user's configured `max_tokens` per effort level. -/// Same index as `EFFORT_LEVELS`. Higher effort = larger token budget. -const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0]; - /// Temperature override per effort level. Higher effort = lower temperature /// (more deterministic, less creative variation). const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1]; @@ -20,7 +16,18 @@ const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1]; pub fn generation_params(level: usize, base_max_tokens: Option) -> (f32, Option) { let idx = level.min(EFFORT_LEVELS.len() - 1); let temperature = TEMPERATURE_OVERRIDE[idx]; - let max_tokens = base_max_tokens.map(|t| ((t as f32) * MAX_TOKENS_MULTIPLIER[idx]) as u32); + // Use integer scaling to avoid float casts. Original multipliers: + // low=0.5×, medium=1.0×, high=1.5×, xhigh=2.0×, max=3.0×. + let max_tokens = base_max_tokens.map(|t| { + let scaled = match idx { + 0 => t / 2, + 2 => t.saturating_mul(3) / 2, + 3 => t.saturating_mul(2), + 4 => t.saturating_mul(3), + _ => t, // idx == 1 → 1.0× + }; + scaled.max(256) + }); (temperature, max_tokens.map(|t| t.max(256))) } diff --git a/crates/zesdex-backend/src/app/mode/rewind.rs b/crates/zesdex-backend/src/app/mode/rewind.rs index 1b4b343..94433a5 100644 --- a/crates/zesdex-backend/src/app/mode/rewind.rs +++ b/crates/zesdex-backend/src/app/mode/rewind.rs @@ -108,7 +108,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { path: restore_path.to_string_lossy().to_string(), reason: format!("rewind_to({index})"), content_sha256: hex::encode(sha2::Sha256::digest(&bytes)), - bytes_delta: bytes.len() as i64, + bytes_delta: i64::try_from(bytes.len()).unwrap_or(0), origin: crate::app::state::types::Origin::Main.tag(), session_id: state.session_id.clone(), }; diff --git a/crates/zesdex-backend/src/app/review/probe.rs b/crates/zesdex-backend/src/app/review/probe.rs index 6c1ae1f..5957386 100644 --- a/crates/zesdex-backend/src/app/review/probe.rs +++ b/crates/zesdex-backend/src/app/review/probe.rs @@ -1,6 +1,8 @@ //! Build/test probing: running a verification command and capturing its //! pass/fail/timeout outcome for the review subagent. +use std::convert::TryInto; + use serde::{Deserialize, Serialize}; use std::process::Command; @@ -57,7 +59,8 @@ pub fn probe_build_test( let start = std::time::Instant::now(); let timed_out = loop { - if start.elapsed().as_millis() as u64 >= timeout_ms { + let elapsed: u64 = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + if elapsed >= timeout_ms { let _ = child.kill(); break true; } diff --git a/crates/zesdex-backend/src/app/runtime/actions/turn.rs b/crates/zesdex-backend/src/app/runtime/actions/turn.rs index 90b63a5..f584c9c 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/turn.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/turn.rs @@ -6,9 +6,9 @@ //! `execute_one_tool`, `build_memory_section`, and `archive_message`. use std::collections::VecDeque; +use std::convert::TryFrom; use std::fmt::Write; - use crate::app::guard::Verdict; use crate::app::runtime::context::tokens::count_tokens; use crate::app::runtime::push_event; @@ -200,11 +200,11 @@ pub(super) fn run_agent_turn( Ok((reply, usage_opt)) => { let (mut tok_in, mut tok_out) = usage_opt.unwrap_or((0, 0)); if tok_in == 0 { - tok_in = (planner_prompt_chars / 4).max(1) as u64; + tok_in = u64::try_from((planner_prompt_chars / 4).max(1)).unwrap_or(1); } if tok_out == 0 { let response_chars = reply.content.as_deref().map_or(0, str::len); - tok_out = (response_chars / 4).max(1) as u64; + tok_out = u64::try_from((response_chars / 4).max(1)).unwrap_or(1); } push_event(&events_q, TurnEvent::Usage { tokens_in: tok_in, @@ -478,11 +478,11 @@ pub(super) fn run_agent_turn( .filter_map(|m| m.content.as_deref()) .map(count_tokens) .sum(); - tok_in = total_tokens.max(1) as u64; + tok_in = u64::try_from(total_tokens.max(1)).unwrap_or(1); } if tok_out == 0 { let response_chars = response.content.as_deref().map_or(0, str::len); - tok_out = (response_chars / 4).max(1) as u64; + tok_out = u64::try_from((response_chars / 4).max(1)).unwrap_or(1); } push_event(&events_q, TurnEvent::Usage { tokens_in: tok_in, diff --git a/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs b/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs index 4d32041..bb811f3 100644 --- a/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs @@ -6,6 +6,7 @@ //! `FAST_POLL_MS` (8 ms) or `SLOW_POLL_MS` (100 ms). `is_idle()` reports //! whether the deadline has expired. use std::collections::VecDeque; +use std::convert::TryInto; use std::time::{Duration, Instant}; use crate::app::state::runtime::TurnEvent; @@ -61,7 +62,8 @@ impl EventLoop { /// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`. pub fn is_idle(&self) -> bool { - self.last_activity.elapsed().as_millis() as u64 > IDLE_THRESHOLD_MS + let elapsed: u64 = self.last_activity.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + elapsed > IDLE_THRESHOLD_MS } /// Drain all pending `TurnEvent`s from the shared mutex queue. diff --git a/crates/zesdex-backend/src/app/state/types.rs b/crates/zesdex-backend/src/app/state/types.rs index b101bb8..389e066 100644 --- a/crates/zesdex-backend/src/app/state/types.rs +++ b/crates/zesdex-backend/src/app/state/types.rs @@ -48,7 +48,8 @@ impl Toast { /// Whether this toast's lifetime has elapsed as of `now_ms`. pub fn expired(&self, now_ms: i64) -> bool { - let expired = now_ms - self.created_at > self.lifetime_ms as i64; + let lifetime = i64::try_from(self.lifetime_ms).unwrap_or(i64::MAX); + let expired = now_ms - self.created_at > lifetime; if expired { tracing::debug!("Toast::expired — toast aged {}ms expired (lifetime={}ms)", now_ms - self.created_at, self.lifetime_ms); } diff --git a/crates/zesdex-backend/src/app/subagent/engine.rs b/crates/zesdex-backend/src/app/subagent/engine.rs index 8c9b00e..efa0a4a 100644 --- a/crates/zesdex-backend/src/app/subagent/engine.rs +++ b/crates/zesdex-backend/src/app/subagent/engine.rs @@ -19,6 +19,7 @@ //! async event loop is not blocked. All I/O inside tool calls is //! synchronous (`ureq`, `std::fs`, etc.). +use std::convert::TryFrom; use super::context::SubagentContext; use super::event::SubagentEvent; use super::gating::gate_subagent_tool_call; @@ -304,11 +305,11 @@ pub fn run_subagent( .filter_map(|m| m.content.as_deref()) .map(str::len) .sum(); - tok_in = (prompt_chars / 4).max(1) as u64; + tok_in = u64::try_from((prompt_chars / 4).max(1)).unwrap_or(1); } if tok_out == 0 { let response_chars = response.content.as_deref().map_or(0, str::len); - tok_out = (response_chars / 4).max(1) as u64; + tok_out = u64::try_from((response_chars / 4).max(1)).unwrap_or(1); } let _ = tx.blocking_send(SubagentEvent::Usage { tokens_in: tok_in, diff --git a/crates/zesdex-backend/src/app/util/backoff.rs b/crates/zesdex-backend/src/app/util/backoff.rs index 0fddd53..aefca84 100644 --- a/crates/zesdex-backend/src/app/util/backoff.rs +++ b/crates/zesdex-backend/src/app/util/backoff.rs @@ -3,6 +3,7 @@ //! Three use cases (subagent, provider, workflow) all share the same formula //! with different caps. This module provides a single implementation. +use std::convert::TryInto; use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// Compute an exponential backoff with ±25% jitter. @@ -27,9 +28,9 @@ pub fn backoff_seconds(attempt: u32, max_secs: u64) -> Duration { /// Uses sub-nanosecond wall-clock bits as a cheap PRNG source — no /// need for a full RNG for ±25% backoff jitter. fn jitter_ns(range_ns: u64) -> u64 { - let nanos = SystemTime::now() + let dur = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() as u64; + .unwrap_or_default(); + let nanos: u64 = dur.as_nanos().try_into().unwrap_or(u64::MAX); nanos % range_ns } diff --git a/crates/zesdex-backend/src/daemon.rs b/crates/zesdex-backend/src/daemon.rs index ad37485..805a23f 100644 --- a/crates/zesdex-backend/src/daemon.rs +++ b/crates/zesdex-backend/src/daemon.rs @@ -4,6 +4,7 @@ //! Also contains the `key_code_to_action` / `key_action_to_code` conversion //! functions shared between daemon and attach modes. +use std::convert::TryInto; use anyhow::Result; use app::runtime::actions::{apply_action, Action}; use app::state::rest::AppStateRest; @@ -113,7 +114,7 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) -> let frame = DaemonFrame::StateUpdate(Box::new(StatePayload { session_id: state.session_id.clone(), messages, - edit_count: state.edit_log.len() as u32, + edit_count: state.edit_log.len().try_into().unwrap_or(0), message_count: state.transcript_cache.messages.len(), overlay, toasts, diff --git a/crates/zesdex-backend/src/main.rs b/crates/zesdex-backend/src/main.rs index 723363f..a9b0565 100644 --- a/crates/zesdex-backend/src/main.rs +++ b/crates/zesdex-backend/src/main.rs @@ -1,9 +1,3 @@ -#![allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - clippy::cast_possible_wrap -)] //! Zesdex binary entry point. //! //! Parses `--daemon` / `--attach ` flags to select one of three diff --git a/crates/zesdex-backend/src/tool/lsp/mod.rs b/crates/zesdex-backend/src/tool/lsp/mod.rs index f69a429..861964a 100644 --- a/crates/zesdex-backend/src/tool/lsp/mod.rs +++ b/crates/zesdex-backend/src/tool/lsp/mod.rs @@ -212,14 +212,16 @@ where rel_path, text.is_some(), ); - let line = args + let raw_line = args .get("line") .and_then(Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: line"))? as u32; - let column = args + .ok_or_else(|| anyhow!("missing required argument: line"))?; + let line = u32::try_from(raw_line).map_err(|_| anyhow!("invalid line: {raw_line}"))?; + let raw_column = args .get("column") .and_then(Value::as_i64) - .ok_or_else(|| anyhow!("missing required argument: column"))? as u32; + .ok_or_else(|| anyhow!("missing required argument: column"))?; + let column = u32::try_from(raw_column).map_err(|_| anyhow!("invalid column: {raw_column}"))?; let server_name = resolve_server_name(ctx, args, &rel_path)?; let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?; diff --git a/crates/zesdex-backend/src/tool/mod.rs b/crates/zesdex-backend/src/tool/mod.rs index 26930ff..37ffca3 100644 --- a/crates/zesdex-backend/src/tool/mod.rs +++ b/crates/zesdex-backend/src/tool/mod.rs @@ -5,6 +5,7 @@ //! functions for path resolution, command execution, argument extraction, and edit-log //! persistence. The `all_tools()` function assembles the canonical 37-tool vector exposed //! to the LLM provider. +use std::convert::TryInto; use anyhow::Result; use serde_json::Value; use sha2::Digest; @@ -285,11 +286,13 @@ pub fn log_write_edit_tool( let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); // bytes_delta: for "write" it is the full file length; for "edit" it is |new - old| let bytes_delta = if tool_name == "write" { - content_str.len() as i64 + content_str.len().try_into().unwrap_or(0i64) } else { let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); - (new.len() as i64 - old.len() as i64).abs() + let new_len: i64 = new.len().try_into().unwrap_or(0); + let old_len: i64 = old.len().try_into().unwrap_or(0); + (new_len - old_len).abs() }; tracing::debug!(tool = %tool_name, path = %path, delta = bytes_delta, "logging write/edit tool result"); let entry = zesdex_cms::domain::edit_log::EditLogEntry { diff --git a/crates/zesdex-backend/src/view/mod.rs b/crates/zesdex-backend/src/view/mod.rs index 1ff809f..dd1891d 100644 --- a/crates/zesdex-backend/src/view/mod.rs +++ b/crates/zesdex-backend/src/view/mod.rs @@ -125,7 +125,7 @@ fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::re // ── Autocomplete dropdown ──────────────────────────────────────────── if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() { - let n = state.input.autocomplete_candidates.len().min(10) as u16; + let n = u16::try_from(state.input.autocomplete_candidates.len().min(10)).unwrap_or(10); let dropdown_height = n + 2; let dropdown_area = Rect { x: area.x, @@ -245,7 +245,7 @@ fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRes let mut y: u16 = 1; for toast in active.iter().rev().take(4) { - let line_count = toast.message.lines().count().max(1) as u16; + let line_count = u16::try_from(toast.message.lines().count().max(1)).unwrap_or(1); let h = line_count + 2; let toast_area = Rect { x, diff --git a/crates/zesdex-cms/src/application/conversation_service.rs b/crates/zesdex-cms/src/application/conversation_service.rs index 8c1ecf8..d245a6f 100644 --- a/crates/zesdex-cms/src/application/conversation_service.rs +++ b/crates/zesdex-cms/src/application/conversation_service.rs @@ -12,10 +12,10 @@ use std::path::PathBuf; -use anyhow::{Context, Result}; use tracing; use crate::domain::conversation::{ChatMessage, Conversation}; +use crate::domain::error::ServiceError; use crate::domain::repository::ConversationRepository; use crate::domain::service::ConversationService; @@ -59,25 +59,27 @@ impl ConversationService for ConversationServiceImpl< /// Load a conversation from disk for the given session. /// /// Flow: resolve session dir → delegate to repo.load() → wrap error with context. - fn load_conversation(&self, session_id: &str) -> Result { + fn load_conversation(&self, session_id: &str) -> Result { tracing::debug!("loading conversation for session {session_id}"); let dir = self.session_dir(session_id); - self.repo - .load(&dir) - .with_context(|| format!("failed to load conversation for session '{session_id}'")) + self.repo.load(&dir).map_err(|e| { + ServiceError::Other(format!( + "failed to load conversation for session '{session_id}': {e}" + )) + }) } /// Persist a conversation to disk. /// /// Flow: resolve session dir from conv.session_id → delegate to repo.save() → wrap error. - fn save_conversation(&self, conv: &Conversation) -> Result<()> { + fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> { tracing::debug!("saving conversation for session {}", conv.session_id); let dir = self.session_dir(&conv.session_id); - self.repo.save(&dir, conv).with_context(|| { - format!( - "failed to save conversation for session '{}'", + self.repo.save(&dir, conv).map_err(|e| { + ServiceError::Other(format!( + "failed to save conversation for session '{}': {e}", conv.session_id - ) + )) }) } @@ -88,15 +90,15 @@ impl ConversationService for ConversationServiceImpl< /// ## Note /// This is a write-through operation: the message is appended to the /// in-memory `Conversation` and then the full conversation is persisted. - fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> { + fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError> { tracing::debug!("adding message to session {}", conv.session_id); conv.push(msg); // append message to in-memory conversation let dir = self.session_dir(&conv.session_id); - self.repo.save(&dir, conv).with_context(|| { - format!( - "failed to persist conversation after adding message for session '{}'", + self.repo.save(&dir, conv).map_err(|e| { + ServiceError::Other(format!( + "failed to persist conversation after adding message for session '{}': {e}", conv.session_id - ) + )) }) } } diff --git a/crates/zesdex-cms/src/application/memory_service.rs b/crates/zesdex-cms/src/application/memory_service.rs index c219c82..5288b9f 100644 --- a/crates/zesdex-cms/src/application/memory_service.rs +++ b/crates/zesdex-cms/src/application/memory_service.rs @@ -12,9 +12,9 @@ use std::path::PathBuf; -use anyhow::{Context, Result}; use tracing; +use crate::domain::error::ServiceError; use crate::domain::memory::Memory; use crate::domain::repository::MemoryRepository; use crate::domain::service::MemoryService; @@ -52,30 +52,30 @@ impl MemoryService for MemoryServiceImpl { /// List all stored memory names. /// /// Flow: delegate to repo.list() → wrap error with context. - fn list_memories(&self) -> Result> { + fn list_memories(&self) -> Result, ServiceError> { tracing::debug!("listing memories from {:?}", self.memory_dir); - self.repo - .list(&self.memory_dir) - .context("failed to list memories") + self.repo.list(&self.memory_dir).map_err(|e| { + ServiceError::Other(format!("failed to list memories: {e}")) + }) } /// Persist a memory to disk. /// /// Flow: delegate to repo.save() → wrap error with memory name context. - fn save_memory(&self, memory: &Memory) -> Result<()> { + fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> { tracing::debug!("saving memory '{}'", memory.name); - self.repo - .save(&self.memory_dir, memory) - .with_context(|| format!("failed to save memory '{}'", memory.name)) + self.repo.save(&self.memory_dir, memory).map_err(|e| { + ServiceError::Other(format!("failed to save memory '{}': {e}", memory.name)) + }) } /// Delete a memory by name. /// /// Flow: delegate to repo.delete() → wrap error with memory name context. - fn delete_memory(&self, name: &str) -> Result<()> { + fn delete_memory(&self, name: &str) -> Result<(), ServiceError> { tracing::debug!("deleting memory '{name}'"); - self.repo - .delete(&self.memory_dir, name) - .with_context(|| format!("failed to delete memory '{name}'")) + self.repo.delete(&self.memory_dir, name).map_err(|e| { + ServiceError::Other(format!("failed to delete memory '{name}': {e}")) + }) } } diff --git a/crates/zesdex-cms/src/application/settings_service.rs b/crates/zesdex-cms/src/application/settings_service.rs index 7536312..2266bac 100644 --- a/crates/zesdex-cms/src/application/settings_service.rs +++ b/crates/zesdex-cms/src/application/settings_service.rs @@ -14,10 +14,10 @@ use std::path::PathBuf; -use anyhow::Result; use tracing; use crate::domain::app_config::{AppConfig, ProviderConfig}; +use crate::domain::error::ServiceError; use crate::domain::repository::{AppConfigRepository, SettingsRepository}; use crate::domain::service::SettingsService; use crate::domain::settings::Settings; @@ -62,17 +62,21 @@ impl SettingsService for Settings /// Load application settings from disk. /// /// Flow: delegate to settings_repo.load() at base_dir. - fn load_settings(&self) -> Result { + fn load_settings(&self) -> Result { tracing::debug!("loading settings"); - self.settings_repo.load(&self.base_dir) + self.settings_repo.load(&self.base_dir).map_err(|e| { + ServiceError::Other(format!("failed to load settings: {e}")) + }) } /// Save application settings to disk. /// /// Flow: delegate to settings_repo.save() at base_dir. - fn save_settings(&self, settings: &Settings) -> Result<()> { + fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> { tracing::debug!("saving settings"); - self.settings_repo.save(&self.base_dir, settings) + self.settings_repo.save(&self.base_dir, settings).map_err(|e| { + ServiceError::Other(format!("failed to save settings: {e}")) + }) } /// Update (or insert) a provider configuration in the app config. @@ -83,7 +87,7 @@ impl SettingsService for Settings /// ## Parameters /// - `name` — provider name (key in the providers map) /// - `config` — the provider configuration to store - fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> { + fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<(), ServiceError> { tracing::debug!("updating provider '{name}'"); // Load current app config from disk let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?; @@ -92,6 +96,8 @@ impl SettingsService for Settings .providers .insert(name.to_string(), config.clone()); // Persist the modified app config - self.app_config_repo.save(&self.base_dir, &app_config) + self.app_config_repo.save(&self.base_dir, &app_config).map_err(|e| { + ServiceError::Other(format!("failed to update provider '{name}': {e}")) + }) } } diff --git a/crates/zesdex-cms/src/domain/error.rs b/crates/zesdex-cms/src/domain/error.rs new file mode 100644 index 0000000..2950f64 --- /dev/null +++ b/crates/zesdex-cms/src/domain/error.rs @@ -0,0 +1,116 @@ +//! Domain error types for the CMS crate. +//! +//! Typed error enums for repository and service operations. +//! `From` impls connect `std::io::Error` and `serde_json::Error` into +//! `RepositoryError`, and `RepositoryError` into `ServiceError`. +//! Anyhow's blanket `From` +//! covers conversion to `anyhow::Error` for downstream code. + +use std::fmt; + +// --------------------------------------------------------------------------- +// RepositoryError +// --------------------------------------------------------------------------- + +/// Errors from repository / persistence operations in the CMS domain. +#[derive(Debug)] +pub enum RepositoryError { + /// The requested entity does not exist. + NotFound(String), + /// A conflict occurred (e.g. duplicate entry). + Conflict(String), + /// An I/O error occurred during persistence. + Io(std::io::Error), + /// A serialisation / deserialisation error occurred. + Serialization(serde_json::Error), + /// The supplied identifier is invalid (e.g. path traversal attempt). + InvalidId(String), + /// An error that could not be downcast to a specific variant. + Other(String), +} + +impl RepositoryError { + /// Convert an `anyhow::Error` to `RepositoryError` by attempting + /// downcast to known inner types. + pub fn from_anyhow(e: anyhow::Error) -> Self { + if let Some(ioe) = e.downcast_ref::() { + return RepositoryError::Io(std::io::Error::new(ioe.kind(), ioe.to_string())); + } + RepositoryError::Other(e.to_string()) + } +} + +impl fmt::Display for RepositoryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RepositoryError::NotFound(msg) => write!(f, "not found: {msg}"), + RepositoryError::Conflict(msg) => write!(f, "conflict: {msg}"), + RepositoryError::Io(e) => write!(f, "I/O error: {e}"), + RepositoryError::Serialization(e) => write!(f, "serialization error: {e}"), + RepositoryError::InvalidId(msg) => write!(f, "invalid id: {msg}"), + RepositoryError::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for RepositoryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + RepositoryError::Io(e) => Some(e), + RepositoryError::Serialization(e) => Some(e), + _ => None, + } + } +} + +impl From for RepositoryError { + fn from(e: std::io::Error) -> Self { + RepositoryError::Io(e) + } +} + +impl From for RepositoryError { + fn from(e: serde_json::Error) -> Self { + RepositoryError::Serialization(e) + } +} + +// --------------------------------------------------------------------------- +// ServiceError +// --------------------------------------------------------------------------- + +/// Errors from service / use-case operations in the CMS domain. +#[derive(Debug)] +pub enum ServiceError { + /// A repository operation failed. + Repository(RepositoryError), + /// The provided input is invalid. + InvalidInput(String), + /// A generic error with a message. + Other(String), +} + +impl fmt::Display for ServiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ServiceError::Repository(e) => write!(f, "repository error: {e}"), + ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"), + ServiceError::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for ServiceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ServiceError::Repository(e) => Some(e), + _ => None, + } + } +} + +impl From for ServiceError { + fn from(e: RepositoryError) -> Self { + ServiceError::Repository(e) + } +} diff --git a/crates/zesdex-cms/src/domain/mod.rs b/crates/zesdex-cms/src/domain/mod.rs index e21f482..7a9eaf5 100644 --- a/crates/zesdex-cms/src/domain/mod.rs +++ b/crates/zesdex-cms/src/domain/mod.rs @@ -21,6 +21,7 @@ pub mod app_config; pub mod conversation; pub mod edit_log; +pub mod error; pub mod memory; pub mod repository; pub mod service; diff --git a/crates/zesdex-cms/src/domain/repository.rs b/crates/zesdex-cms/src/domain/repository.rs index d8341b8..c2dac9d 100644 --- a/crates/zesdex-cms/src/domain/repository.rs +++ b/crates/zesdex-cms/src/domain/repository.rs @@ -19,11 +19,10 @@ use std::path::Path; -use anyhow::Result; - use super::app_config::AppConfig; use super::conversation::Conversation; use super::edit_log::{EditLog, EditLogEntry}; +use super::error::RepositoryError; use super::memory::Memory; use super::settings::Settings; @@ -32,14 +31,10 @@ use super::settings::Settings; /// Implementors provide the actual I/O logic (e.g. file-based JSON storage). pub trait SettingsRepository { /// Load `Settings` from the given base directory. - /// - /// Flow: read and deserialize `settings.json` from `base_dir`. - fn load(&self, base_dir: &Path) -> Result; + fn load(&self, base_dir: &Path) -> Result; /// Persist `Settings` to the given base directory. - /// - /// Flow: serialize and write `settings.json` to `base_dir`. - fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()>; + fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>; } /// Persistence contract for `AppConfig` (provider and model configuration). @@ -47,14 +42,10 @@ pub trait SettingsRepository { /// Implementors provide the actual I/O logic (e.g. file-based JSON storage). pub trait AppConfigRepository { /// Load `AppConfig` from the given base directory. - /// - /// Flow: read and deserialize `app_config.json` from `base_dir`. - fn load(&self, base_dir: &Path) -> Result; + fn load(&self, base_dir: &Path) -> Result; /// Persist `AppConfig` to the given base directory. - /// - /// Flow: serialize and write `app_config.json` to `base_dir`. - fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()>; + fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>; } /// Persistence contract for `Conversation` (session conversation data). @@ -62,14 +53,10 @@ pub trait AppConfigRepository { /// Implementors provide the actual I/O logic (e.g. file-based JSON storage). pub trait ConversationRepository { /// Load a `Conversation` from the given session directory. - /// - /// Flow: read and deserialize `conversation.json` from `session_dir`. - fn load(&self, session_dir: &Path) -> Result; + fn load(&self, session_dir: &Path) -> Result; /// Persist a `Conversation` to the given session directory. - /// - /// Flow: serialize and write `conversation.json` to `session_dir`. - fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()>; + fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>; } /// Persistence contract for `Memory` (long-term agent memory entries). @@ -77,16 +64,16 @@ pub trait ConversationRepository { /// Implementors provide the actual I/O logic (e.g. per-memory markdown files). pub trait MemoryRepository { /// List all memory slugs (filenames without extension) in the memory directory. - fn list(&self, memory_dir: &Path) -> Result>; + fn list(&self, memory_dir: &Path) -> Result, RepositoryError>; /// Load a single `Memory` by name from the memory directory. - fn load(&self, memory_dir: &Path, name: &str) -> Result; + fn load(&self, memory_dir: &Path, name: &str) -> Result; /// Save (create or overwrite) a `Memory` in the memory directory. - fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>; + fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>; /// Delete a `Memory` by name from the memory directory. - fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>; + fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>; } /// Persistence contract for rewind-snapshot binary blobs. @@ -95,25 +82,19 @@ pub trait MemoryRepository { /// within a session. They capture file snapshots for the "rewind" feature. pub trait RewindBlobRepository { /// Store (or overwrite) a binary blob under `blob_key` for this session. - /// - /// ## Parameters - /// - `session_dir` — the session directory to store the blob in - /// - `blob_key` — arbitrary caller-supplied key (e.g. tool-call ID) - /// - `data` — raw byte content of the blob - /// - `mime_type` — optional MIME type hint fn store_blob( &self, session_dir: &Path, blob_key: &str, data: &[u8], mime_type: Option<&str>, - ) -> anyhow::Result<()>; + ) -> Result<(), RepositoryError>; /// Retrieve a blob's raw bytes by key, or `None` if not found. - fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result>>; + fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result>, RepositoryError>; /// List all blob keys for this session, ordered oldest-first. - fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result>; + fn list_blob_keys(&self, session_dir: &Path) -> Result, RepositoryError>; } /// Persistence contract for `EditLog` (append-only file mutation log). @@ -122,14 +103,10 @@ pub trait RewindBlobRepository { /// typically persisted to a file for audit and potential undo. pub trait EditLogRepository { /// Open (or initialise) the edit log for a session directory. - /// - /// Flow: load existing log file if present, or create an empty log. - fn open(&self, session_dir: &Path) -> Result; + fn open(&self, session_dir: &Path) -> Result; /// Append one entry to the log and persist immediately (write-through). - /// - /// Flow: push entry to in-memory log → append to disk file. - fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()>; + fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<(), RepositoryError>; /// Return a cloned copy of all in-memory entries for inspection. fn entries(&self, log: &EditLog) -> Vec; diff --git a/crates/zesdex-cms/src/domain/service.rs b/crates/zesdex-cms/src/domain/service.rs index 33b986c..041a611 100644 --- a/crates/zesdex-cms/src/domain/service.rs +++ b/crates/zesdex-cms/src/domain/service.rs @@ -14,49 +14,43 @@ //! type parameters. Infrastructure adapters depend only on these service //! traits, never on concrete implementations. -use anyhow::Result; - use super::conversation::{ChatMessage, Conversation}; +use super::error::ServiceError; use super::memory::Memory; use super::settings::Settings; /// Use-cases for application settings. pub trait SettingsService { /// Load the current `Settings` from the default store location. - fn load_settings(&self) -> Result; + fn load_settings(&self) -> Result; /// Persist updated `Settings` to the default store location. - fn save_settings(&self, settings: &Settings) -> Result<()>; + fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>; /// Update (or insert) a provider configuration entry. - /// - /// Flow: load current AppConfig → mutate provider map → save. - fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) - -> Result<()>; + fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<(), ServiceError>; } /// Use-cases for conversation (session message) management. pub trait ConversationService { /// Load a `Conversation` for the given session ID. - fn load_conversation(&self, session_id: &str) -> Result; + fn load_conversation(&self, session_id: &str) -> Result; /// Persist a `Conversation` to its session storage. - fn save_conversation(&self, conv: &Conversation) -> Result<()>; + fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>; /// Append a single `ChatMessage` to the conversation and persist. - /// - /// Flow: push message to in-memory conv → persist full conversation. - fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()>; + fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>; } /// Use-cases for long-term memory management. pub trait MemoryService { /// List all memory slugs (filenames without extension). - fn list_memories(&self) -> Result>; + fn list_memories(&self) -> Result, ServiceError>; /// Save (create or overwrite) a `Memory`. - fn save_memory(&self, memory: &Memory) -> Result<()>; + fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>; /// Delete a `Memory` by its slug/name. - fn delete_memory(&self, name: &str) -> Result<()>; + fn delete_memory(&self, name: &str) -> Result<(), ServiceError>; } diff --git a/crates/zesdex-cms/src/infrastructure/http/handlers.rs b/crates/zesdex-cms/src/infrastructure/http/handlers.rs index ea9556a..0838f32 100644 --- a/crates/zesdex-cms/src/infrastructure/http/handlers.rs +++ b/crates/zesdex-cms/src/infrastructure/http/handlers.rs @@ -18,7 +18,7 @@ //! response and setting HTTP status codes. use anyhow::{Context, Result}; -use tracing; +use tracing::instrument; use crate::domain::memory::Memory; use crate::domain::service::{MemoryService, SettingsService}; @@ -31,6 +31,7 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings /// Returns the current settings as a `SettingsResponse`. /// /// Flow: load settings from service → convert to DTO → return. +#[instrument(skip(service))] pub fn handle_get_settings(service: &S) -> Result { tracing::debug!("handling GET /settings"); let settings = service.load_settings().context("failed to load settings")?; @@ -46,6 +47,7 @@ pub fn handle_get_settings(service: &S) -> Result( service: &S, req: SettingsUpdateRequest, @@ -130,6 +132,7 @@ pub fn handle_update_settings( /// use a dedicated endpoint. /// /// Flow: list slugs from service → map each to minimal MemoryResponse → return. +#[instrument(skip(service))] pub fn handle_list_memories(service: &M) -> Result> { tracing::debug!("handling GET /memories"); let slugs = service.list_memories().context("failed to list memories")?; @@ -166,6 +169,7 @@ pub fn handle_list_memories(service: &M) -> Result( service: &M, req: MemoryCreateRequest, diff --git a/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs index 8992060..f0c584a 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs @@ -15,11 +15,11 @@ use std::path::Path; -use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use zesdex_utils::write_json_atomic; use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig}; +use crate::domain::error::RepositoryError; use crate::domain::repository::AppConfigRepository; /// File-based `AppConfigRepository` that reads/writes `app_config.json`. @@ -95,19 +95,18 @@ impl AppConfigRepository for JsonAppConfigRepository { /// Flow: read file → parse JSON → merge default providers → auto-detect Claude → return. /// /// If the file is missing, returns `AppConfig::default()`. - fn load(&self, base_dir: &Path) -> Result { + fn load(&self, base_dir: &Path) -> Result { tracing::debug!("loading app_config from {base_dir:?}"); let path = base_dir.join("app_config.json"); // Try to read and parse the config file let mut cfg: AppConfig = match std::fs::read_to_string(&path) { - Ok(s) => serde_json::from_str(&s) - .map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?, + Ok(s) => serde_json::from_str(&s)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { tracing::info!("app_config.json not found, using defaults"); AppConfig::default() } Err(e) => { - return Err(anyhow::anyhow!("failed to read app_config.json: {e}")); + return Err(RepositoryError::Io(e)); } }; @@ -153,13 +152,12 @@ impl AppConfigRepository for JsonAppConfigRepository { /// Persist `AppConfig` to `/app_config.json`. /// /// Flow: create base dir → atomic JSON write → log success. - fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> { + fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> { tracing::debug!("saving app_config to {base_dir:?}"); - std::fs::create_dir_all(base_dir) - .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; + std::fs::create_dir_all(base_dir)?; let path = base_dir.join("app_config.json"); write_json_atomic(&path, config, None) - .with_context(|| "failed to save app_config")?; + .map_err(RepositoryError::from_anyhow)?; tracing::debug!("app_config saved to '{}'", path.display()); Ok(()) } diff --git a/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs index a39a198..0c8ec54 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/conversation_repo.rs @@ -9,10 +9,10 @@ use std::path::Path; -use anyhow::{Context, Result}; use zesdex_utils::write_json_atomic; use crate::domain::conversation::Conversation; +use crate::domain::error::RepositoryError; use crate::domain::repository::ConversationRepository; /// File-based `ConversationRepository` that reads/writes `conversation.json`. @@ -32,25 +32,22 @@ impl ConversationRepository for JsonConversationRepository { /// Load a `Conversation` from `/conversation.json`. /// /// Flow: read file → parse JSON → return Conversation. - fn load(&self, session_dir: &Path) -> Result { + fn load(&self, session_dir: &Path) -> Result { let path = session_dir.join("conversation.json"); - let data = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read conversation at '{}'", path.display()))?; - let conv: Conversation = serde_json::from_str(&data) - .with_context(|| format!("failed to parse conversation at '{}'", path.display()))?; + let data = std::fs::read_to_string(&path)?; + let conv: Conversation = serde_json::from_str(&data)?; Ok(conv) } /// Persist a `Conversation` to `/conversation.json`. /// /// Flow: create session dir → atomic JSON write → log success. - fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()> { + fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> { tracing::debug!("saving conversation to {session_dir:?}"); - std::fs::create_dir_all(session_dir) - .with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?; + std::fs::create_dir_all(session_dir)?; let path = session_dir.join("conversation.json"); write_json_atomic(&path, conversation, None) - .with_context(|| "failed to save conversation")?; + .map_err(RepositoryError::from_anyhow)?; tracing::debug!("conversation saved to '{}'", path.display()); Ok(()) } diff --git a/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs index 24c2184..9608a00 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/edit_log_repo.rs @@ -16,9 +16,8 @@ use std::io::{BufRead, BufReader, Write}; use std::path::Path; -use anyhow::{Context, Result}; - use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES}; +use crate::domain::error::RepositoryError; use crate::domain::repository::EditLogRepository; /// File-based `EditLogRepository` that reads/writes `edits.jsonl`. @@ -63,13 +62,12 @@ impl EditLogRepository for JsonlEditLogRepository { /// /// Flow: ensure parent dir exists → load existing entries from disk → /// touch file if absent → return in-memory EditLog. - fn open(&self, session_dir: &Path) -> Result { + fn open(&self, session_dir: &Path) -> Result { tracing::debug!("opening edit log for {session_dir:?}"); let path = session_dir.join("edits.jsonl"); // Ensure parent dir exists if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create session dir '{}'", parent.display()))?; + std::fs::create_dir_all(parent)?; } let entries = Self::load_from_disk(&path); // Touch the file if it doesn't exist yet @@ -77,8 +75,7 @@ impl EditLogRepository for JsonlEditLogRepository { std::fs::OpenOptions::new() .create(true) .append(true) - .open(&path) - .with_context(|| format!("failed to create edits.jsonl at '{}'", path.display()))?; + .open(&path)?; } Ok(EditLog { entries }) } @@ -87,24 +84,20 @@ impl EditLogRepository for JsonlEditLogRepository { /// /// Flow: serialize entry → open file (append mode) → write line → fsync → /// push to in-memory Vec → evict oldest if over cap. - fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> { + fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<(), RepositoryError> { tracing::debug!("appending edit log entry for {session_dir:?}"); let path = session_dir.join("edits.jsonl"); - let line = - serde_json::to_string(&entry).context("failed to serialize edit log entry")? + "\n"; + let line = serde_json::to_string(&entry)? + "\n"; if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create session dir '{}'", parent.display()))?; + std::fs::create_dir_all(parent)?; } { let mut file = std::fs::OpenOptions::new() .create(true) .append(true) - .open(&path) - .with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?; - file.write_all(line.as_bytes()) - .context("failed to write edit log entry")?; - file.sync_all().context("failed to fsync edit log")?; + .open(&path)?; + file.write_all(line.as_bytes())?; + file.sync_all()?; } log.entries.push(entry); // Enforce in-memory cap diff --git a/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs index 94112c9..e718f82 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/memory_repo.rs @@ -35,8 +35,7 @@ use std::collections::HashMap; use std::io::Write; use std::path::Path; -use anyhow::{Context, Result}; - +use crate::domain::error::RepositoryError; use crate::domain::memory::Memory; use crate::domain::repository::MemoryRepository; @@ -173,7 +172,7 @@ impl MemoryRepository for MarkdownMemoryRepository { /// /// Flow: read directory entries → filter `*.md` → strip extension → exclude MEMORY.md. /// Returns empty Vec if the directory doesn't exist. - fn list(&self, memory_dir: &Path) -> Result> { + fn list(&self, memory_dir: &Path) -> Result, RepositoryError> { let Ok(entries) = std::fs::read_dir(memory_dir) else { return Ok(Vec::new()); }; @@ -196,21 +195,19 @@ impl MemoryRepository for MarkdownMemoryRepository { /// Load a single `Memory` by name from `memory_dir`. /// /// Flow: resolve file path → read file → parse frontmatter + body → return Memory. - fn load(&self, memory_dir: &Path, name: &str) -> Result { + fn load(&self, memory_dir: &Path, name: &str) -> Result { tracing::debug!("loading memory '{name}'"); let path = Memory::path(memory_dir, name); - let content = std::fs::read_to_string(&path) - .with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?; + let content = std::fs::read_to_string(&path)?; let memory = Self::parse(&content) - .map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?; + .map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?; Ok(memory) } - fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> { + fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> { let path = Memory::path(memory_dir, &memory.name); let parent = path.parent().unwrap(); - std::fs::create_dir_all(parent) - .with_context(|| format!("failed to create memory dir '{}'", parent.display()))?; + std::fs::create_dir_all(parent)?; let frontmatter = Self::build_frontmatter(memory); let content = format!("---\n{frontmatter}---\n\n{}", memory.content); @@ -221,18 +218,11 @@ impl MemoryRepository for MarkdownMemoryRepository { .create(true) .truncate(true) .write(true) - .open(&tmp) - .with_context(|| format!("failed to write temp file '{}'", tmp.display()))?; + .open(&tmp)?; f.write_all(content.as_bytes())?; f.sync_all()?; } - std::fs::rename(&tmp, &path).with_context(|| { - format!( - "failed to rename '{}' -> '{}'", - tmp.display(), - path.display() - ) - })?; + std::fs::rename(&tmp, &path)?; if let Some(p) = path.parent() { if let Ok(d) = std::fs::File::open(p) { let _ = d.sync_all(); @@ -242,12 +232,10 @@ impl MemoryRepository for MarkdownMemoryRepository { Ok(()) } - fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> { + fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> { let path = Memory::path(memory_dir, name); if path.exists() { - std::fs::remove_file(&path).with_context(|| { - format!("failed to delete memory '{name}' at '{}'", path.display()) - })?; + std::fs::remove_file(&path)?; tracing::debug!("memory deleted: '{}'", path.display()); } else { tracing::warn!( diff --git a/crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs index 4509d6a..1f0d01e 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/rewind_blob_repo.rs @@ -16,10 +16,10 @@ use std::io::Write; use std::path::Path; -use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use tracing; +use crate::domain::error::RepositoryError; use crate::domain::repository::RewindBlobRepository; /// A single entry in the append-only blob index (`index.jsonl`). @@ -81,10 +81,9 @@ impl RewindBlobRepository for FileRewindBlobRepository { blob_key: &str, data: &[u8], mime_type: Option<&str>, - ) -> Result<()> { + ) -> Result<(), RepositoryError> { let blobs_dir = Self::blobs_dir(session_dir); - std::fs::create_dir_all(&blobs_dir) - .with_context(|| format!("failed to create blobs dir '{}'", blobs_dir.display()))?; + std::fs::create_dir_all(&blobs_dir)?; // Write blob data atomically: temp → fsync → rename let path = Self::blob_file_path(session_dir, blob_key); @@ -104,8 +103,7 @@ impl RewindBlobRepository for FileRewindBlobRepository { let mut f = std::fs::OpenOptions::new() .create(true) .append(true) - .open(&index_path) - .with_context(|| format!("failed to open blob index '{}'", index_path.display()))?; + .open(&index_path)?; writeln!(f, "{}", serde_json::to_string(&entry)?)?; f.sync_all()?; @@ -122,7 +120,7 @@ impl RewindBlobRepository for FileRewindBlobRepository { /// /// Returns `None` when no blob file exists for `blob_key` (i.e. the /// blob was never stored or the session directory does not exist). - fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result>> { + fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result>, RepositoryError> { let path = Self::blob_file_path(session_dir, blob_key); if !path.exists() { tracing::debug!("blob key={} not found (path does not exist)", blob_key); @@ -141,7 +139,7 @@ impl RewindBlobRepository for FileRewindBlobRepository { /// When a key has been overwritten, it appears exactly once in the output /// (pointing to the latest stored data). Returns an empty vec if the /// index file does not exist yet. - fn list_blob_keys(&self, session_dir: &Path) -> Result> { + fn list_blob_keys(&self, session_dir: &Path) -> Result, RepositoryError> { let index_path = Self::index_path(session_dir); let Ok(content) = std::fs::read_to_string(&index_path) else { tracing::debug!("no blob index file yet at '{}'", index_path.display()); diff --git a/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs b/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs index bb531bf..e1e642b 100644 --- a/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs +++ b/crates/zesdex-cms/src/infrastructure/persistence/settings_repo.rs @@ -6,10 +6,10 @@ use std::path::Path; -use anyhow::{Context, Result}; use tracing; use zesdex_utils::write_json_atomic; +use crate::domain::error::RepositoryError; use crate::domain::repository::SettingsRepository; use crate::domain::settings::Settings; @@ -32,7 +32,7 @@ impl SettingsRepository for JsonSettingsRepository { /// Graceful degradation: returns `Settings::default()` when the file is /// missing (first run) *or* when it exists but fails to parse (e.g. a /// newer field was added after the file was written). - fn load(&self, base_dir: &Path) -> Result { + fn load(&self, base_dir: &Path) -> Result { let path = base_dir.join("settings.json"); match std::fs::read_to_string(&path) { Ok(s) => match serde_json::from_str(&s) { @@ -54,7 +54,7 @@ impl SettingsRepository for JsonSettingsRepository { tracing::info!("settings.json not found, using defaults"); Ok(Settings::default()) } - Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")), + Err(e) => Err(RepositoryError::Io(e)), } } @@ -62,12 +62,11 @@ impl SettingsRepository for JsonSettingsRepository { /// /// Flow: create base dir (if missing) → atomic JSON write via /// `write_json_atomic` (write to temp → fsync → rename). - fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()> { - std::fs::create_dir_all(base_dir) - .with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?; + fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> { + std::fs::create_dir_all(base_dir)?; let path = base_dir.join("settings.json"); write_json_atomic(&path, settings, None) - .with_context(|| "failed to save settings")?; + .map_err(RepositoryError::from_anyhow)?; tracing::debug!("settings saved to '{}'", path.display()); Ok(()) } diff --git a/crates/zesdex-cms/src/lib.rs b/crates/zesdex-cms/src/lib.rs index 53bcfb7..8159a48 100644 --- a/crates/zesdex-cms/src/lib.rs +++ b/crates/zesdex-cms/src/lib.rs @@ -20,13 +20,6 @@ //! - Domain types are plain Rust structs with `serde` serialisation, //! stored as JSON files on disk. -#![allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - clippy::cast_possible_wrap -)] - pub mod application; pub mod domain; pub mod infrastructure; diff --git a/crates/zesdex-entities/src/domain/auth/session_lock.rs b/crates/zesdex-entities/src/domain/auth/session_lock.rs index 231868a..c1b3910 100644 --- a/crates/zesdex-entities/src/domain/auth/session_lock.rs +++ b/crates/zesdex-entities/src/domain/auth/session_lock.rs @@ -15,6 +15,7 @@ //! - `try_lock` — three-phase atomic acquire with stale-lock recovery //! - `unlock` / `Drop` — explicit and implicit release //! - `is_alive` — liveness check via `libc::kill` + `/proc` verification +use std::convert::TryInto; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -117,7 +118,9 @@ impl SessionLock { // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // whether the process exists and the caller has permission to signal // it. The integer argument is a PID already validated by `try_lock`. - if unsafe { libc::kill(pid as i32, 0) != 0 } { + // PIDs on Linux fit in i32 (default pid_max ≈ 4 million). + let pid_signed: i32 = pid.try_into().unwrap_or(0); + if unsafe { libc::kill(pid_signed, 0) != 0 } { return false; } // Extra check: verify the PID belongs to a zesdex process via diff --git a/crates/zesdex-entities/src/domain/common/provider.rs b/crates/zesdex-entities/src/domain/common/provider.rs index baa9253..dd2665c 100644 --- a/crates/zesdex-entities/src/domain/common/provider.rs +++ b/crates/zesdex-entities/src/domain/common/provider.rs @@ -330,7 +330,8 @@ impl SseParser { ); 0 }, - ) as usize; + ); + let index = usize::try_from(index).unwrap_or(0); let id = tc .get("id") .and_then(|i| i.as_str()) diff --git a/crates/zesdex-entities/src/lib.rs b/crates/zesdex-entities/src/lib.rs index 2c97c20..5be51ce 100644 --- a/crates/zesdex-entities/src/lib.rs +++ b/crates/zesdex-entities/src/lib.rs @@ -1,10 +1,3 @@ -#![allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - clippy::cast_possible_wrap -)] - //! Domain entity types for the Zesdex application. //! //! This crate contains ALL domain entity types as pure data structures diff --git a/crates/zesdex-iam/Cargo.toml b/crates/zesdex-iam/Cargo.toml index 1c24e77..7c7fa09 100644 --- a/crates/zesdex-iam/Cargo.toml +++ b/crates/zesdex-iam/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true authors.workspace = true [dependencies] +thiserror.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true diff --git a/crates/zesdex-iam/src/application/oauth_service.rs b/crates/zesdex-iam/src/application/oauth_service.rs index e71cbdd..b4d9be1 100644 --- a/crates/zesdex-iam/src/application/oauth_service.rs +++ b/crates/zesdex-iam/src/application/oauth_service.rs @@ -34,6 +34,8 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine as _; use sha2::{Digest, Sha256}; +use crate::domain::error::RepositoryError; +use crate::domain::error::ServiceError; use crate::domain::oauth::{OAuthConfig, OAuthToken}; use crate::domain::repository::OAuthRepository; use crate::domain::service::OAuthService; @@ -111,9 +113,11 @@ impl OAuthService for OAuthServiceImpl { &self, config: &OAuthConfig, redirect_uri: &str, - ) -> anyhow::Result<(String, String)> { + ) -> Result<(String, String), ServiceError> { if config.auth_url.is_empty() { - anyhow::bail!("OAuth auth_url is empty"); + return Err(ServiceError::InvalidConfig( + "OAuth auth_url is empty".to_string(), + )); } let verifier = CodeVerifier::new(); @@ -121,10 +125,13 @@ impl OAuthService for OAuthServiceImpl { let state = secure_token_hex(16); if let Some(parent) = self.token_path.parent() { - std::fs::create_dir_all(parent)?; + std::fs::create_dir_all(parent) + .map_err(RepositoryError::from)?; } - std::fs::write(self.verifier_path(), verifier.as_str())?; - std::fs::write(self.state_path(), &state)?; + std::fs::write(self.verifier_path(), verifier.as_str()) + .map_err(RepositoryError::from)?; + std::fs::write(self.state_path(), &state) + .map_err(RepositoryError::from)?; tracing::debug!( auth_url = %config.auth_url, @@ -133,7 +140,9 @@ impl OAuthService for OAuthServiceImpl { ); let mut url = url::Url::parse(&config.auth_url) - .map_err(|e| anyhow::anyhow!("invalid auth_url '{}': {e}", config.auth_url))?; + .map_err(|e| ServiceError::InvalidConfig(format!( + "invalid auth_url '{}': {e}", config.auth_url + )))?; url.query_pairs_mut() .append_pair("response_type", "code") @@ -153,17 +162,17 @@ impl OAuthService for OAuthServiceImpl { redirect_uri: &str, code: &str, state: &str, - ) -> anyhow::Result { + ) -> Result { let state_path = self.state_path(); let expected_state = std::fs::read_to_string(&state_path) - .map_err(|e| anyhow::anyhow!("failed to read persisted OAuth state: {e}"))?; + .map_err(|e| ServiceError::Repository(RepositoryError::Io(e)))?; if expected_state != state { - anyhow::bail!("OAuth state mismatch \u{2014} possible CSRF attack"); + return Err(ServiceError::StateMismatch); } let verifier_path = self.verifier_path(); let verifier = std::fs::read_to_string(&verifier_path) - .map_err(|e| anyhow::anyhow!("failed to read PKCE verifier: {e}"))?; + .map_err(|e| ServiceError::Repository(RepositoryError::Io(e)))?; tracing::debug!( token_url = %config.token_url, @@ -187,20 +196,24 @@ impl OAuthService for OAuthServiceImpl { .post(&config.token_url) .form(¶ms) .send() - .map_err(|e| anyhow::anyhow!("token request failed: {e}"))?; + .map_err(|e| ServiceError::OAuthProvider(format!("token request failed: {e}")))?; let status = resp.status(); let body: serde_json::Value = resp .json() - .map_err(|e| anyhow::anyhow!("failed to parse token response: {e}"))?; + .map_err(|e| ServiceError::OAuthProvider(format!("failed to parse token response: {e}")))?; if !status.is_success() { - anyhow::bail!("token endpoint returned {status}: {body}"); + return Err(ServiceError::OAuthProvider(format!( + "token endpoint returned {status}: {body}" + ))); } let access_token = body["access_token"] .as_str() - .ok_or_else(|| anyhow::anyhow!("response missing access_token"))? + .ok_or_else(|| ServiceError::OAuthProvider( + "response missing access_token".to_string(), + ))? .to_string(); let expires_in = body["expires_in"].as_u64().unwrap_or(3600); let now = std::time::SystemTime::now() @@ -215,15 +228,18 @@ impl OAuthService for OAuthServiceImpl { token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(), }; - self.token_repo.save_token(&self.token_path, &token)?; + self.token_repo + .save_token(&self.token_path, &token)?; // RepositoryError → ServiceError via From let _ = std::fs::remove_file(&verifier_path); let _ = std::fs::remove_file(&state_path); Ok(token) } - fn get_token(&self) -> anyhow::Result> { - self.token_repo.load_token(&self.token_path) + fn get_token(&self) -> Result, ServiceError> { + self.token_repo + .load_token(&self.token_path) + .map_err(ServiceError::Repository) } } @@ -239,11 +255,11 @@ mod tests { saved: RefCell>, } impl OAuthRepository for FakeOAuthRepo { - fn save_token(&self, _path: &std::path::Path, token: &OAuthToken) -> anyhow::Result<()> { + fn save_token(&self, _path: &std::path::Path, token: &OAuthToken) -> Result<(), RepositoryError> { *self.saved.borrow_mut() = Some(token.clone()); Ok(()) } - fn load_token(&self, _path: &std::path::Path) -> anyhow::Result> { + fn load_token(&self, _path: &std::path::Path) -> Result, RepositoryError> { Ok(self.saved.borrow().clone()) } } @@ -254,7 +270,7 @@ mod tests { #[test] fn complete_flow_rejects_mismatched_state() { - let svc = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path()); + let svc: OAuthServiceImpl = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path()); let config = OAuthConfig { auth_url: "https://example.test/authorize".to_string(), ..OAuthConfig::default() @@ -277,7 +293,7 @@ mod tests { #[test] fn start_flow_returns_url_containing_the_real_redirect_uri() { - let svc = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path()); + let svc: OAuthServiceImpl = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path()); let config = OAuthConfig { auth_url: "https://example.test/authorize".to_string(), ..OAuthConfig::default() diff --git a/crates/zesdex-iam/src/application/session_service.rs b/crates/zesdex-iam/src/application/session_service.rs index 5d4bf0a..14cf2c5 100644 --- a/crates/zesdex-iam/src/application/session_service.rs +++ b/crates/zesdex-iam/src/application/session_service.rs @@ -15,11 +15,12 @@ //! //! - `SessionServiceImpl` — service over two generic repositories //! - `new` / `create_session` / `list_all` / `archive_session` — lifecycle ops +use std::convert::TryInto; use std::path::PathBuf; use tracing; - use uuid::Uuid; +use crate::domain::error::ServiceError; use crate::domain::repository::{SessionLockRepository, SessionRepository}; use crate::domain::service::SessionService; use crate::domain::session::Session; @@ -47,7 +48,7 @@ impl SessionServiceImpl { } impl SessionService for SessionServiceImpl { - fn create_session(&self, title: &str) -> anyhow::Result { + fn create_session(&self, title: &str) -> Result { let id = Uuid::new_v4().to_string(); let title_owned = if title.is_empty() { "New Session".to_string() @@ -56,24 +57,30 @@ impl SessionService for SessionS }; let session = Session::new(id, title_owned); tracing::debug!(session_id = %session.id, title = %session.title, "creating new session"); - self.session_repo.save_session(&self.base_dir, &session)?; + self.session_repo + .save_session(&self.base_dir, &session)?; // RepositoryError → ServiceError via From Ok(session) } - fn list_all(&self) -> anyhow::Result> { + fn list_all(&self) -> Result, ServiceError> { tracing::debug!("listing all sessions"); - self.session_repo.list_sessions(&self.base_dir) + self.session_repo + .list_sessions(&self.base_dir) + .map_err(ServiceError::Repository) } - fn archive_session(&self, id: &str) -> anyhow::Result<()> { + fn archive_session(&self, id: &str) -> Result<(), ServiceError> { tracing::debug!(session_id = %id, "archiving session"); - let mut session = self.session_repo.load_session(&self.base_dir, id)?; + let mut session = self.session_repo + .load_session(&self.base_dir, id)?; // RepositoryError → ServiceError session.archived = true; - session.updated_at = std::time::SystemTime::now() + let millis = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_millis() as i64; - self.session_repo.save_session(&self.base_dir, &session)?; + .as_millis(); + session.updated_at = millis.try_into().unwrap_or(i64::MAX); + self.session_repo + .save_session(&self.base_dir, &session)?; // RepositoryError → ServiceError Ok(()) } } diff --git a/crates/zesdex-iam/src/domain/error.rs b/crates/zesdex-iam/src/domain/error.rs new file mode 100644 index 0000000..a60b34e --- /dev/null +++ b/crates/zesdex-iam/src/domain/error.rs @@ -0,0 +1,140 @@ +//! Domain error types for the IAM crate. +//! +//! Typed error enums replace `anyhow::Result` in domain traits and +//! application services, enabling callers to match on specific error +//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking. +//! +//! `From` impls tie `std::io::Error` and `serde_json::Error` into +//! `RepositoryError`, and `RepositoryError` into `ServiceError`. +//! Downstream `anyhow::Result` code uses `?` directly — anyhow's +//! blanket `From` covers both +//! `RepositoryError` and `ServiceError` automatically. +//! +//! # Components +//! +//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O) +//! - [`ServiceError`] — use-case / orchestration errors (config, state +//! mismatch, provider failures) + +use std::fmt; + +// --------------------------------------------------------------------------- +// RepositoryError +// --------------------------------------------------------------------------- + +/// Errors from repository operations in the IAM domain. +#[derive(Debug)] +pub enum RepositoryError { + /// The requested entity does not exist. + NotFound(String), + /// The operation conflicts with existing state (e.g. duplicate entry). + Conflict(String), + /// An I/O error occurred during persistence. + Io(std::io::Error), + /// A serialisation / deserialisation error occurred. + Serialization(serde_json::Error), + /// The supplied identifier is invalid (e.g. path traversal attempt). + InvalidId(String), + /// An error that could not be downcast to a specific variant. + Other(String), +} + +impl RepositoryError { + /// Convert an `anyhow::Error` to `RepositoryError` by attempting + /// downcast to known inner types. + pub fn from_anyhow(e: anyhow::Error) -> Self { + if let Some(ioe) = e.downcast_ref::() { + return RepositoryError::Io(std::io::Error::new(ioe.kind(), ioe.to_string())); + } + RepositoryError::Other(e.to_string()) + } +} + +impl fmt::Display for RepositoryError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RepositoryError::NotFound(msg) => write!(f, "not found: {msg}"), + RepositoryError::Conflict(msg) => write!(f, "conflict: {msg}"), + RepositoryError::Io(e) => write!(f, "I/O error: {e}"), + RepositoryError::Serialization(e) => write!(f, "serialization error: {e}"), + RepositoryError::InvalidId(msg) => write!(f, "invalid id: {msg}"), + RepositoryError::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for RepositoryError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + RepositoryError::Io(e) => Some(e), + RepositoryError::Serialization(e) => Some(e), + _ => None, + } + } +} + +impl From for RepositoryError { + fn from(e: std::io::Error) -> Self { + RepositoryError::Io(e) + } +} + +impl From for RepositoryError { + fn from(e: serde_json::Error) -> Self { + RepositoryError::Serialization(e) + } +} + +// `From for anyhow::Error` is covered by anyhow's blanket +// `impl From for Error` — no +// explicit impl needed. + +// --------------------------------------------------------------------------- +// ServiceError +// --------------------------------------------------------------------------- + +/// Errors from service / use-case operations in the IAM domain. +#[derive(Debug)] +pub enum ServiceError { + /// A repository operation failed. + Repository(RepositoryError), + /// The provided configuration is invalid. + InvalidConfig(String), + /// OAuth state mismatch — possible CSRF attack. + StateMismatch, + /// The OAuth provider returned an error. + OAuthProvider(String), + /// A generic error with a message. + Other(String), +} + +impl fmt::Display for ServiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ServiceError::Repository(e) => write!(f, "repository error: {e}"), + ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"), + ServiceError::StateMismatch => { + write!(f, "OAuth state mismatch — possible CSRF attack") + } + ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"), + ServiceError::Other(msg) => write!(f, "{msg}"), + } + } +} + +impl std::error::Error for ServiceError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ServiceError::Repository(e) => Some(e), + _ => None, + } + } +} + +impl From for ServiceError { + fn from(e: RepositoryError) -> Self { + ServiceError::Repository(e) + } +} + +// `From for anyhow::Error` is covered by anyhow's blanket impl. diff --git a/crates/zesdex-iam/src/domain/mod.rs b/crates/zesdex-iam/src/domain/mod.rs index e16adad..cfbd285 100644 --- a/crates/zesdex-iam/src/domain/mod.rs +++ b/crates/zesdex-iam/src/domain/mod.rs @@ -11,6 +11,7 @@ //! - [`service`] — Trait definitions: `OAuthService`, `SessionService` //! - [`session`] — `Session` entity (IAM wrapper around `zesdex_entities::Session`) +pub mod error; pub mod oauth; pub mod repository; pub mod service; diff --git a/crates/zesdex-iam/src/domain/repository.rs b/crates/zesdex-iam/src/domain/repository.rs index dbbd6c7..d090a48 100644 --- a/crates/zesdex-iam/src/domain/repository.rs +++ b/crates/zesdex-iam/src/domain/repository.rs @@ -11,22 +11,23 @@ //! - [`OAuthRepository`] — persist/load OAuth tokens use std::path::Path; +use crate::domain::error::RepositoryError; use crate::domain::oauth::OAuthToken; use crate::domain::session::Session; /// Repository for loading, saving, listing, and deleting sessions. pub trait SessionRepository { /// List all loadable sessions under `/sessions/`. - fn list_sessions(&self, base_dir: &Path) -> anyhow::Result>; + fn list_sessions(&self, base_dir: &Path) -> Result, RepositoryError>; /// Load a single session by id. - fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result; + fn load_session(&self, base_dir: &Path, id: &str) -> Result; /// Save a session's metadata to disk. - fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()>; + fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>; /// Delete a session directory and all its contents. - fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()>; + fn delete_session(&self, base_dir: &Path, id: &str) -> Result<(), RepositoryError>; } /// Repository for per-session PID-file advisory locks. @@ -34,10 +35,10 @@ pub trait SessionLockRepository { /// Try to acquire the lock for a session directory. /// Returns `true` if the lock was acquired, `false` if another live /// process holds it. - fn try_lock(&self, session_dir: &Path) -> anyhow::Result; + fn try_lock(&self, session_dir: &Path) -> Result; /// Release the lock by removing the lock file. - fn unlock(&self, session_dir: &Path) -> anyhow::Result<()>; + fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>; /// Check whether a process with the given PID is alive. fn is_alive(&self, pid: u32) -> bool; @@ -46,9 +47,9 @@ pub trait SessionLockRepository { /// Repository for persisting and loading OAuth tokens. pub trait OAuthRepository { /// Persist an OAuth token to a JSON file. - fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()>; + fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError>; /// Load an OAuth token from a JSON file, returning `None` if the file /// does not exist. - fn load_token(&self, path: &Path) -> anyhow::Result>; + fn load_token(&self, path: &Path) -> Result, RepositoryError>; } diff --git a/crates/zesdex-iam/src/domain/service.rs b/crates/zesdex-iam/src/domain/service.rs index 127d1ec..aa1c95f 100644 --- a/crates/zesdex-iam/src/domain/service.rs +++ b/crates/zesdex-iam/src/domain/service.rs @@ -8,19 +8,20 @@ //! //! - [`SessionService`] — create, list, archive sessions //! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token +use crate::domain::error::ServiceError; use crate::domain::oauth::{OAuthConfig, OAuthToken}; use crate::domain::session::Session; /// Session management use-case boundary. pub trait SessionService { /// Create a new session with a generated UUID and the given title. - fn create_session(&self, title: &str) -> anyhow::Result; + fn create_session(&self, title: &str) -> Result; /// List all available sessions. - fn list_all(&self) -> anyhow::Result>; + fn list_all(&self) -> Result, ServiceError>; /// Archive a session by id (sets `archived = true`). - fn archive_session(&self, id: &str) -> anyhow::Result<()>; + fn archive_session(&self, id: &str) -> Result<(), ServiceError>; } /// OAuth flow use-case boundary. @@ -34,7 +35,7 @@ pub trait OAuthService { &self, config: &OAuthConfig, redirect_uri: &str, - ) -> anyhow::Result<(String, String)>; + ) -> Result<(String, String), ServiceError>; /// Complete the OAuth flow: validates `state` against the value /// persisted during `start_flow` (bailing on mismatch — this is the @@ -46,8 +47,8 @@ pub trait OAuthService { redirect_uri: &str, code: &str, state: &str, - ) -> anyhow::Result; + ) -> Result; /// Retrieve the currently stored OAuth token (if any). - fn get_token(&self) -> anyhow::Result>; + fn get_token(&self) -> Result, ServiceError>; } diff --git a/crates/zesdex-iam/src/infrastructure/http/handlers.rs b/crates/zesdex-iam/src/infrastructure/http/handlers.rs index 1c97ee9..6dff14a 100644 --- a/crates/zesdex-iam/src/infrastructure/http/handlers.rs +++ b/crates/zesdex-iam/src/infrastructure/http/handlers.rs @@ -14,7 +14,7 @@ //! //! - `handle_create_session` / `handle_list_sessions` / `handle_archive_session` //! - `handle_start_oauth` / `handle_complete_oauth` / `handle_get_token` -use tracing; +use tracing::instrument; use crate::domain::service::{OAuthService, SessionService}; use crate::infrastructure::http::dto::{ @@ -23,53 +23,53 @@ use crate::infrastructure::http::dto::{ }; /// Handle a create-session request. +#[instrument(skip(service), fields(title = %req.title))] pub fn handle_create_session( service: &S, req: CreateSessionRequest, ) -> anyhow::Result { - tracing::debug!(title = %req.title, "handle_create_session"); let session = service.create_session(&req.title)?; Ok(SessionResponse { session }) } /// Handle a list-sessions request. +#[instrument(skip(service))] pub fn handle_list_sessions(service: &S) -> anyhow::Result { - tracing::debug!("handle_list_sessions"); let sessions = service.list_all()?; let total = sessions.len(); Ok(SessionListResponse { sessions, total }) } /// Handle an archive-session request. +#[instrument(skip(service), fields(session_id = %id))] pub fn handle_archive_session(service: &S, id: &str) -> anyhow::Result<()> { - tracing::debug!(session_id = %id, "handle_archive_session"); service.archive_session(id)?; Ok(()) } /// Handle a start-OAuth-flow request. +#[instrument(skip(service), fields(redirect_uri = %req.redirect_uri))] pub fn handle_start_oauth( service: &O, req: OAuthStartRequest, ) -> anyhow::Result { - tracing::debug!(redirect_uri = %req.redirect_uri, "handle_start_oauth"); let (auth_url, state) = service.start_flow(&req.config, &req.redirect_uri)?; Ok(OAuthStartResponse { auth_url, state }) } /// Handle a complete-OAuth-flow request. +#[instrument(skip(service), fields(code_len = req.code.len()))] pub fn handle_complete_oauth( service: &O, req: OAuthCompleteRequest, ) -> anyhow::Result { - tracing::debug!(code_len = req.code.len(), "handle_complete_oauth"); let token = service.complete_flow(&req.config, &req.redirect_uri, &req.code, &req.state)?; Ok(OAuthTokenResponse { token }) } /// Handle a get-token request. +#[instrument(skip(service))] pub fn handle_get_token(service: &O) -> anyhow::Result { - tracing::debug!("handle_get_token"); let token = service .get_token()? .ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?; diff --git a/crates/zesdex-iam/src/infrastructure/oauth_loopback.rs b/crates/zesdex-iam/src/infrastructure/oauth_loopback.rs index 2c793c1..6975b49 100644 --- a/crates/zesdex-iam/src/infrastructure/oauth_loopback.rs +++ b/crates/zesdex-iam/src/infrastructure/oauth_loopback.rs @@ -17,6 +17,7 @@ //! - `LoopbackServer` — single-use TCP listener for one OAuth callback //! - `wait_for_code` / `read_callback` / `extract_code` / `extract_state` //! - `urlencoding` — minimal percent-decoder for query parameters +use std::convert::TryInto; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use tracing; @@ -141,7 +142,11 @@ fn urlencoding(s: &str) -> String { chars.next().and_then(|c| c.to_digit(16)), chars.next().and_then(|c| c.to_digit(16)), ) { - (Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)), + (Some(hi), Some(lo)) => { + // hi/lo are hex digits (0–15), product is 0–255 — safe. + let byte: u8 = (hi * 16 + lo).try_into().unwrap_or(0); + result.push(char::from(byte)); + } _ => { result.push('%'); } diff --git a/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs index b46e7c6..b7ffa39 100644 --- a/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs +++ b/crates/zesdex-iam/src/infrastructure/persistence/oauth_repo.rs @@ -19,6 +19,7 @@ use tracing; use zesdex_utils::write_json_atomic; +use crate::domain::error::RepositoryError; use crate::domain::oauth::OAuthToken; use crate::domain::repository::OAuthRepository; @@ -34,23 +35,23 @@ impl FileSystemOAuthRepository { } impl OAuthRepository for FileSystemOAuthRepository { - fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()> { + fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError> { if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + std::fs::create_dir_all(parent)?; // io::Error → RepositoryError via From } tracing::debug!(path = %path.display(), "saving OAuth token"); - write_json_atomic(path, token, Some(0o600))?; + write_json_atomic(path, token, Some(0o600)).map_err(RepositoryError::from_anyhow)?; Ok(()) } - fn load_token(&self, path: &Path) -> anyhow::Result> { + fn load_token(&self, path: &Path) -> Result, RepositoryError> { if !path.exists() { tracing::debug!(path = %path.display(), "no stored OAuth token found"); return Ok(None); } tracing::debug!(path = %path.display(), "loading OAuth token"); - let data = std::fs::read_to_string(path)?; - let token: OAuthToken = serde_json::from_str(&data)?; + let data = std::fs::read_to_string(path)?; // io error → RepositoryError + let token: OAuthToken = serde_json::from_str(&data)?; // serde error → RepositoryError Ok(Some(token)) } } diff --git a/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs index 2bab2b4..96d153e 100644 --- a/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs +++ b/crates/zesdex-iam/src/infrastructure/persistence/session_lock_repo.rs @@ -19,11 +19,13 @@ //! //! - `FileSystemSessionLockRepository` — stateless singleton implementing //! `SessionLockRepository` +use std::convert::TryInto; use std::fs; use std::io::Write; use std::path::Path; use tracing; +use crate::domain::error::RepositoryError; use crate::domain::repository::SessionLockRepository; /// Concrete filesystem session-lock repository, using a PID file @@ -39,7 +41,7 @@ impl FileSystemSessionLockRepository { } impl SessionLockRepository for FileSystemSessionLockRepository { - fn try_lock(&self, session_dir: &Path) -> anyhow::Result { + fn try_lock(&self, session_dir: &Path) -> Result { let path = session_dir.join(".lock"); let pid = std::process::id(); @@ -49,15 +51,15 @@ impl SessionLockRepository for FileSystemSessionLockRepository { .open(&path) { Ok(mut file) => { - write!(file, "{pid}")?; - file.sync_all()?; + write!(file, "{pid}")?; // → RepositoryError via From + file.sync_all()?; // → RepositoryError via From tracing::debug!(path = %path.display(), pid, "session lock acquired"); return Ok(true); } Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => { tracing::debug!(path = %path.display(), "lock file exists, checking staleness"); } - Err(e) => return Err(e.into()), + Err(e) => return Err(RepositoryError::Io(e)), } let content = fs::read_to_string(&path).unwrap_or_default(); @@ -75,18 +77,18 @@ impl SessionLockRepository for FileSystemSessionLockRepository { .create(true) .truncate(true) .write(true) - .open(&tmp)?; - write!(tmp_file, "{pid}")?; - tmp_file.sync_all()?; + .open(&tmp)?; // → RepositoryError via From + write!(tmp_file, "{pid}")?; // → RepositoryError + tmp_file.sync_all()?; // → RepositoryError } - fs::rename(&tmp, &path)?; + fs::rename(&tmp, &path)?; // → RepositoryError if let Some(parent) = path.parent() { let _ = fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(true) } - fn unlock(&self, session_dir: &Path) -> anyhow::Result<()> { + fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> { let path = session_dir.join(".lock"); let _ = fs::remove_file(path); Ok(()) @@ -95,7 +97,9 @@ impl SessionLockRepository for FileSystemSessionLockRepository { fn is_alive(&self, pid: u32) -> bool { // SAFETY: `libc::kill(pid, 0)` sends no signal; it only probes // whether the process exists and is signalable by us. - if unsafe { libc::kill(pid as i32, 0) != 0 } { + // PIDs on Linux fit in i32 (default pid_max ≈ 4 million). + let pid_signed: i32 = pid.try_into().unwrap_or(0); + if unsafe { libc::kill(pid_signed, 0) != 0 } { return false; } let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); diff --git a/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs b/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs index 147ba9e..4e915de 100644 --- a/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs +++ b/crates/zesdex-iam/src/infrastructure/persistence/session_repo.rs @@ -24,9 +24,20 @@ use tracing; use zesdex_utils::write_json_atomic; +use crate::domain::error::RepositoryError; use crate::domain::repository::SessionRepository; use crate::domain::session::Session; +/// Validate a session id, rejecting path-traversal patterns. +fn validate_id(id: &str) -> Result<(), RepositoryError> { + if id.contains('/') || id.contains('\\') || id.contains("..") { + return Err(RepositoryError::InvalidId(format!( + "session id '{id}' must not contain path separators" + ))); + } + Ok(()) +} + /// Concrete filesystem session repository. #[derive(Debug, Clone, Default)] pub struct FileSystemSessionRepository; @@ -39,11 +50,15 @@ impl FileSystemSessionRepository { } impl SessionRepository for FileSystemSessionRepository { - fn list_sessions(&self, base_dir: &Path) -> anyhow::Result> { + fn list_sessions(&self, base_dir: &Path) -> Result, RepositoryError> { let sessions_dir = base_dir.join("sessions"); - let Ok(entries) = std::fs::read_dir(&sessions_dir) else { - tracing::warn!(path = %sessions_dir.display(), "sessions directory not found"); - return Ok(Vec::new()); + let entries = match std::fs::read_dir(&sessions_dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::warn!(path = %sessions_dir.display(), "sessions directory not found"); + return Ok(Vec::new()); + } + Err(e) => return Err(RepositoryError::Io(e)), }; let mut sessions = Vec::new(); for entry in entries.flatten() { @@ -59,38 +74,33 @@ impl SessionRepository for FileSystemSessionRepository { Ok(sessions) } - fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result { - // Directory-traversal prevention. - if id.contains('/') || id.contains('\\') || id.contains("..") { - anyhow::bail!("invalid session id '{id}': must not contain path separators"); - } + fn load_session(&self, base_dir: &Path, id: &str) -> Result { + validate_id(id)?; let path = base_dir.join("sessions").join(id).join("session.json"); if !path.exists() { - anyhow::bail!("session not found: {id}"); + return Err(RepositoryError::NotFound(format!("session not found: {id}"))); } tracing::debug!(session_id = %id, path = %path.display(), "loading session"); - let data = std::fs::read_to_string(&path)?; - let session: Session = serde_json::from_str(&data)?; + let data = std::fs::read_to_string(&path)?; // → RepositoryError + let session: Session = serde_json::from_str(&data)?; // → RepositoryError Ok(session) } - fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()> { + fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError> { let dir = session.session_dir(base_dir); - std::fs::create_dir_all(&dir)?; + std::fs::create_dir_all(&dir)?; // → RepositoryError let path = dir.join("session.json"); tracing::debug!(session_id = %session.id, path = %path.display(), "saving session"); - write_json_atomic(&path, session, None)?; + write_json_atomic(&path, session, None).map_err(RepositoryError::from_anyhow)?; Ok(()) } - fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()> { - if id.contains('/') || id.contains('\\') || id.contains("..") { - anyhow::bail!("invalid session id '{id}': must not contain path separators"); - } + fn delete_session(&self, base_dir: &Path, id: &str) -> Result<(), RepositoryError> { + validate_id(id)?; let dir = base_dir.join("sessions").join(id); tracing::debug!(session_id = %id, path = %dir.display(), "deleting session"); if dir.exists() { - std::fs::remove_dir_all(&dir)?; + std::fs::remove_dir_all(&dir)?; // → RepositoryError } Ok(()) } diff --git a/crates/zesdex-iam/src/lib.rs b/crates/zesdex-iam/src/lib.rs index c466eb2..e0be993 100644 --- a/crates/zesdex-iam/src/lib.rs +++ b/crates/zesdex-iam/src/lib.rs @@ -1,9 +1,3 @@ -#![allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - clippy::cast_possible_wrap -)] //! zesdex-iam — Identity & Access Management crate. //! //! Clean Architecture / Domain-Driven Design structure: diff --git a/crates/zesdex-infra/src/password.rs b/crates/zesdex-infra/src/password.rs index 6117abf..26ae166 100644 --- a/crates/zesdex-infra/src/password.rs +++ b/crates/zesdex-infra/src/password.rs @@ -3,6 +3,9 @@ //! Uses the `argon2` crate (Argon2id variant) with default parameters, //! which provide a good security / performance trade-off for interactive //! authentication. +//! +//! CPU-bound hashing is wrapped in `tokio::task::spawn_blocking` so the +//! async runtime is not blocked by Argon2's memory-hard computation. use anyhow::Result; use argon2::{ @@ -10,6 +13,7 @@ use argon2::{ Argon2, }; use rand_core::OsRng; +use tokio::task::spawn_blocking; use tracing; /// Hash a plaintext password using Argon2id with a random salt. @@ -17,68 +21,85 @@ use tracing; /// The returned string is in the PHC string format /// (`$argon2id$v=19$...`) and can be stored directly in the database. /// +/// The CPU-bound hashing runs on a blocking thread pool via +/// `spawn_blocking` so it does not starve the async runtime. +/// /// # Errors /// /// Returns an error if the argon2 library fails (extremely rare — -/// typically indicates an OOM or system-level crypto failure). -pub fn hash_password(password: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); // cryptographic random salt - let argon2 = Argon2::default(); // Argon2id with default params - let hash = argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?; - tracing::debug!("password hashed successfully"); - Ok(hash.to_string()) +/// typically indicates an OOM or system-level crypto failure), or if +/// the blocking task fails to spawn. +pub async fn hash_password(password: &str) -> Result { + let password = password.to_string(); + spawn_blocking(move || { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?; + tracing::debug!("password hashed successfully"); + Ok(hash.to_string()) + }) + .await + .map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))? } /// Verify a plaintext password against a previously-hashed PHC string. /// /// Returns `Ok(true)` if the password matches, `Ok(false)` if it does not, -/// and `Err` if the hash string is malformed. +/// and `Err` if the hash string is malformed or the blocking task fails +/// to spawn. /// /// # Errors /// -/// Returns an error if the hash string is not a valid PHC string or if -/// the argon2 library encounters an internal failure. -pub fn verify_password(password: &str, hash: &str) -> Result { - let parsed_hash = PasswordHash::new(hash) - .map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?; - let argon2 = Argon2::default(); // Argon2id with default params - let valid = argon2 - .verify_password(password.as_bytes(), &parsed_hash) - .is_ok(); - tracing::debug!("password verification result: {valid}"); - Ok(valid) +/// Returns an error if the hash string is not a valid PHC string, if the +/// argon2 library encounters an internal failure, or if the blocking task +/// fails to spawn. +pub async fn verify_password(password: &str, hash: &str) -> Result { + let password = password.to_string(); + let hash = hash.to_string(); + spawn_blocking(move || { + let parsed_hash = PasswordHash::new(&hash) + .map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?; + let argon2 = Argon2::default(); + let valid = argon2 + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok(); + tracing::debug!("password verification result: {valid}"); + Ok(valid) + }) + .await + .map_err(|e| anyhow::anyhow!("blocking task failed: {e}"))? } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_hash_and_verify() { + #[tokio::test] + async fn test_hash_and_verify() { let password = "my-secure-password-123!"; - let hash = hash_password(password).unwrap(); - assert!(verify_password(password, &hash).unwrap()); + let hash = hash_password(password).await.unwrap(); + assert!(verify_password(password, &hash).await.unwrap()); } - #[test] - fn test_wrong_password_fails() { - let hash = hash_password("correct-password").unwrap(); - assert!(!verify_password("wrong-password", &hash).unwrap()); + #[tokio::test] + async fn test_wrong_password_fails() { + let hash = hash_password("correct-password").await.unwrap(); + assert!(!verify_password("wrong-password", &hash).await.unwrap()); } - #[test] - fn test_hashes_are_different() { - let h1 = hash_password("same-password").unwrap(); - let h2 = hash_password("same-password").unwrap(); + #[tokio::test] + async fn test_hashes_are_different() { + let h1 = hash_password("same-password").await.unwrap(); + let h2 = hash_password("same-password").await.unwrap(); // Different salts → different hashes. assert_ne!(h1, h2); } - #[test] - fn test_invalid_hash_returns_error() { - let result = verify_password("password", "not-a-valid-hash"); + #[tokio::test] + async fn test_invalid_hash_returns_error() { + let result = verify_password("password", "not-a-valid-hash").await; assert!(result.is_err()); } }