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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user