Refactor error handling in IAM and CMS crates
- Introduced `RepositoryError` and `ServiceError` enums in both IAM and CMS domains for better error management. - Updated domain traits and services to return specific error types instead of `anyhow::Result`. - Enhanced session and OAuth repository implementations to handle errors more explicitly. - Refactored session service methods to return `Result<T, ServiceError>` for improved error handling. - Updated HTTP handlers to utilize the new error types. - Modified password hashing functions to run in a blocking context using `tokio::task::spawn_blocking`. - Added tests for new error handling mechanisms and async password functions.
This commit is contained in:
Generated
+1
@@ -4769,6 +4769,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<u32>) -> (f32, Option<u32>) {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <id>` flags to select one of three
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<R: ConversationRepository> 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<Conversation> {
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
|
||||
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<R: ConversationRepository> 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
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<R: MemoryRepository> MemoryService for MemoryServiceImpl<R> {
|
||||
/// List all stored memory names.
|
||||
///
|
||||
/// Flow: delegate to repo.list() → wrap error with context.
|
||||
fn list_memories(&self) -> Result<Vec<String>> {
|
||||
fn list_memories(&self) -> Result<Vec<String>, 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}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<S: SettingsRepository, C: AppConfigRepository> SettingsService for Settings
|
||||
/// Load application settings from disk.
|
||||
///
|
||||
/// Flow: delegate to settings_repo.load() at base_dir.
|
||||
fn load_settings(&self) -> Result<Settings> {
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError> {
|
||||
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<S: SettingsRepository, C: AppConfigRepository> 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<S: SettingsRepository, C: AppConfigRepository> 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}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<E: StdError + Send + Sync + 'static>`
|
||||
//! 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::<std::io::Error>() {
|
||||
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<std::io::Error> for RepositoryError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
RepositoryError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> 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<RepositoryError> for ServiceError {
|
||||
fn from(e: RepositoryError) -> Self {
|
||||
ServiceError::Repository(e)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Settings>;
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError>;
|
||||
|
||||
/// 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<AppConfig>;
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError>;
|
||||
|
||||
/// 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<Conversation>;
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||
|
||||
/// 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<Vec<String>>;
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
|
||||
/// Load a single `Memory` by name from the memory directory.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory>;
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError>;
|
||||
|
||||
/// 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<Option<Vec<u8>>>;
|
||||
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>, RepositoryError>;
|
||||
|
||||
/// List all blob keys for this session, ordered oldest-first.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result<Vec<String>>;
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, 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<EditLog>;
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
|
||||
|
||||
/// 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<EditLogEntry>;
|
||||
|
||||
@@ -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<Settings>;
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError>;
|
||||
|
||||
/// 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<Conversation>;
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError>;
|
||||
|
||||
/// 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<Vec<String>>;
|
||||
fn list_memories(&self) -> Result<Vec<String>, 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>;
|
||||
}
|
||||
|
||||
@@ -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<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
||||
tracing::debug!("handling GET /settings");
|
||||
let settings = service.load_settings().context("failed to load settings")?;
|
||||
@@ -46,6 +47,7 @@ pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsRe
|
||||
///
|
||||
/// ## Validation
|
||||
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_update_settings<S: SettingsService>(
|
||||
service: &S,
|
||||
req: SettingsUpdateRequest,
|
||||
@@ -130,6 +132,7 @@ pub fn handle_update_settings<S: SettingsService>(
|
||||
/// use a dedicated endpoint.
|
||||
///
|
||||
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
||||
tracing::debug!("handling GET /memories");
|
||||
let slugs = service.list_memories().context("failed to list memories")?;
|
||||
@@ -166,6 +169,7 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
|
||||
/// ## Defaults
|
||||
/// - `kind` defaults to "reference" if not specified
|
||||
/// - `lifecycle` defaults to "new" if not specified
|
||||
#[instrument(skip(service), fields(name = %req.name))]
|
||||
pub fn handle_create_memory<M: MemoryService>(
|
||||
service: &M,
|
||||
req: MemoryCreateRequest,
|
||||
|
||||
@@ -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<AppConfig> {
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
|
||||
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 `<base_dir>/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(())
|
||||
}
|
||||
|
||||
@@ -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 `<session_dir>/conversation.json`.
|
||||
///
|
||||
/// Flow: read file → parse JSON → return Conversation.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation> {
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
|
||||
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 `<session_dir>/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(())
|
||||
}
|
||||
|
||||
@@ -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<EditLog> {
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
|
||||
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
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, 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<Memory> {
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
|
||||
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!(
|
||||
|
||||
@@ -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<Option<Vec<u8>>> {
|
||||
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>, 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<Vec<String>> {
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, 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());
|
||||
|
||||
@@ -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<Settings> {
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
@@ -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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
&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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
);
|
||||
|
||||
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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> anyhow::Result<OAuthToken> {
|
||||
) -> Result<OAuthToken, ServiceError> {
|
||||
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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
.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<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
|
||||
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<Option<OAuthToken>> {
|
||||
self.token_repo.load_token(&self.token_path)
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError> {
|
||||
self.token_repo
|
||||
.load_token(&self.token_path)
|
||||
.map_err(ServiceError::Repository)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,11 +255,11 @@ mod tests {
|
||||
saved: RefCell<Option<OAuthToken>>,
|
||||
}
|
||||
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<Option<OAuthToken>> {
|
||||
fn load_token(&self, _path: &std::path::Path) -> Result<Option<OAuthToken>, 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<FakeOAuthRepo> = 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<FakeOAuthRepo> = OAuthServiceImpl::new(FakeOAuthRepo::default(), tmp_token_path());
|
||||
let config = OAuthConfig {
|
||||
auth_url: "https://example.test/authorize".to_string(),
|
||||
..OAuthConfig::default()
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
//!
|
||||
//! - `SessionServiceImpl<R, L>` — 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<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
|
||||
}
|
||||
|
||||
impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionServiceImpl<R, L> {
|
||||
fn create_session(&self, title: &str) -> anyhow::Result<Session> {
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let title_owned = if title.is_empty() {
|
||||
"New Session".to_string()
|
||||
@@ -56,24 +57,30 @@ impl<R: SessionRepository, L: SessionLockRepository> 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<Vec<Session>> {
|
||||
fn list_all(&self) -> Result<Vec<Session>, 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<E: StdError + Send + Sync + 'static>` 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::<std::io::Error>() {
|
||||
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<std::io::Error> for RepositoryError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
RepositoryError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for RepositoryError {
|
||||
fn from(e: serde_json::Error) -> Self {
|
||||
RepositoryError::Serialization(e)
|
||||
}
|
||||
}
|
||||
|
||||
// `From<RepositoryError> for anyhow::Error` is covered by anyhow's blanket
|
||||
// `impl<E: StdError + Send + Sync + 'static> From<E> 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<RepositoryError> for ServiceError {
|
||||
fn from(e: RepositoryError) -> Self {
|
||||
ServiceError::Repository(e)
|
||||
}
|
||||
}
|
||||
|
||||
// `From<ServiceError> for anyhow::Error` is covered by anyhow's blanket impl.
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `<base_dir>/sessions/`.
|
||||
fn list_sessions(&self, base_dir: &Path) -> anyhow::Result<Vec<Session>>;
|
||||
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError>;
|
||||
|
||||
/// Load a single session by id.
|
||||
fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<Session>;
|
||||
fn load_session(&self, base_dir: &Path, id: &str) -> Result<Session, RepositoryError>;
|
||||
|
||||
/// 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<bool>;
|
||||
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError>;
|
||||
|
||||
/// 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<Option<OAuthToken>>;
|
||||
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError>;
|
||||
}
|
||||
|
||||
@@ -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<Session>;
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
|
||||
|
||||
/// List all available sessions.
|
||||
fn list_all(&self) -> anyhow::Result<Vec<Session>>;
|
||||
fn list_all(&self) -> Result<Vec<Session>, 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<OAuthToken>;
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
|
||||
/// Retrieve the currently stored OAuth token (if any).
|
||||
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>>;
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
|
||||
}
|
||||
|
||||
@@ -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<S: SessionService>(
|
||||
service: &S,
|
||||
req: CreateSessionRequest,
|
||||
) -> anyhow::Result<SessionResponse> {
|
||||
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<S: SessionService>(service: &S) -> anyhow::Result<SessionListResponse> {
|
||||
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<S: SessionService>(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<O: OAuthService>(
|
||||
service: &O,
|
||||
req: OAuthStartRequest,
|
||||
) -> anyhow::Result<OAuthStartResponse> {
|
||||
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<O: OAuthService>(
|
||||
service: &O,
|
||||
req: OAuthCompleteRequest,
|
||||
) -> anyhow::Result<OAuthTokenResponse> {
|
||||
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<O: OAuthService>(service: &O) -> anyhow::Result<OAuthTokenResponse> {
|
||||
tracing::debug!("handle_get_token");
|
||||
let token = service
|
||||
.get_token()?
|
||||
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
|
||||
|
||||
@@ -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('%');
|
||||
}
|
||||
|
||||
@@ -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<Option<OAuthToken>> {
|
||||
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError> {
|
||||
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<io::Error>
|
||||
file.sync_all()?; // → RepositoryError via From<io::Error>
|
||||
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<io::Error>
|
||||
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"));
|
||||
|
||||
@@ -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<Vec<Session>> {
|
||||
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, 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<Session> {
|
||||
// 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<Session, RepositoryError> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user