refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "zesdex-iam"
version.workspace = true
edition.workspace = true
authors.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
chrono.workspace = true
uuid.workspace = true
zesdex-entities = { path = "../zesdex-entities" }
zesdex-utils = { path = "../zesdex-utils" }
reqwest.workspace = true
libc.workspace = true
tracing.workspace = true
url.workspace = true
base64.workspace = true
sha2.workspace = true
+2
View File
@@ -0,0 +1,2 @@
pub mod oauth_service;
pub mod session_service;
@@ -0,0 +1,199 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! OAuth flow use-cases.
//!
//! `OAuthServiceImpl` drives the authorization-code + PKCE flow:
//! generating the verifier, building the auth URL, exchanging the code
//! for a token, and persisting the result via the injected repository.
use std::path::PathBuf;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest, Sha256};
use crate::domain::oauth::{OAuthConfig, OAuthToken};
use crate::domain::repository::OAuthRepository;
use crate::domain::service::OAuthService;
// ---------------------------------------------------------------------------
// PKCE primitives (private to this use-case module)
// ---------------------------------------------------------------------------
const VERIFIER_LENGTH: usize = 64;
/// A randomly generated, base64url-encoded PKCE code verifier.
struct CodeVerifier(String);
impl CodeVerifier {
fn new() -> Self {
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
}
fn as_str(&self) -> &str {
&self.0
}
/// Derive the S256 code challenge (SHA-256 → base64url).
fn challenge(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.0.as_bytes());
let digest = hasher.finalize();
URL_SAFE_NO_PAD.encode(digest)
}
}
/// Produce one pseudo-random byte from the system clock mixed with a
/// monotonic counter, providing ~64 bits of per-call unpredictability
/// without a full `rand` dependency.
fn rand_byte() -> u8 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
((seed ^ counter) & 0xFF) as u8
}
// ---------------------------------------------------------------------------
// OAuthServiceImpl
// ---------------------------------------------------------------------------
/// Concrete OAuth service backed by a generic token repository.
///
/// The code verifier is stored to a sidecar file (`token_path` with
/// `.verifier` extension) in `start_flow` and consumed in `complete_flow`.
pub struct OAuthServiceImpl<R: OAuthRepository> {
pub token_repo: R,
pub token_path: PathBuf,
}
impl<R: OAuthRepository> OAuthServiceImpl<R> {
/// Create a new OAuth service.
///
/// * `token_repo` — repository used to persist / load tokens.
/// * `token_path` — file path where the token JSON is stored.
pub fn new(token_repo: R, token_path: PathBuf) -> Self {
OAuthServiceImpl {
token_repo,
token_path,
}
}
/// Path to the sidecar file that holds the PKCE verifier between
/// `start_flow` and `complete_flow`.
fn verifier_path(&self) -> PathBuf {
let mut p = self.token_path.clone();
let ext = p
.extension()
.map(|e| format!("{}.verifier", e.to_string_lossy()))
.unwrap_or_else(|| "verifier".to_string());
p.set_extension(ext);
p
}
}
impl<R: OAuthRepository> OAuthService for OAuthServiceImpl<R> {
fn start_flow(&self, config: &OAuthConfig) -> anyhow::Result<String> {
if config.auth_url.is_empty() {
anyhow::bail!("OAuth auth_url is empty");
}
let verifier = CodeVerifier::new();
let challenge = verifier.challenge();
// Persist the verifier so complete_flow can retrieve it.
if let Some(parent) = self.token_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(self.verifier_path(), verifier.as_str())?;
// Persist a random state token for CSRF protection.
let state = uuid::Uuid::new_v4().to_string();
let mut url = url::Url::parse(&config.auth_url)
.map_err(|e| anyhow::anyhow!("invalid auth_url '{}': {e}", config.auth_url))?;
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &config.client_id)
.append_pair("redirect_uri", "http://127.0.0.1:0/callback")
.append_pair("scope", &config.scopes.join(" "))
.append_pair("state", &state)
.append_pair("code_challenge_method", "S256")
.append_pair("code_challenge", &challenge);
Ok(url.to_string())
}
fn complete_flow(&self, config: &OAuthConfig, code: &str) -> anyhow::Result<OAuthToken> {
// Load the verifier that was stored during start_flow.
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}"))?;
// Exchange the authorization code for a token.
let client = reqwest::blocking::Client::new();
let mut params = std::collections::HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("code", code);
params.insert("redirect_uri", "http://127.0.0.1:0/callback");
params.insert("client_id", &config.client_id);
params.insert("code_verifier", &verifier);
if let Some(ref secret) = config.client_secret {
params.insert("client_secret", secret);
}
let resp = client
.post(&config.token_url)
.form(&params)
.send()
.map_err(|e| anyhow::anyhow!("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}"))?;
if !status.is_success() {
anyhow::bail!("token endpoint returned {status}: {body}");
}
let access_token = body["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("response missing access_token"))?
.to_string();
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let token = OAuthToken {
access_token,
refresh_token: body["refresh_token"].as_str().map(String::from),
expires_at: now + expires_in,
token_type: body["token_type"]
.as_str()
.unwrap_or("Bearer")
.to_string(),
};
// Persist the token and clean up the verifier.
self.token_repo.save_token(&self.token_path, &token)?;
let _ = std::fs::remove_file(&verifier_path);
Ok(token)
}
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>> {
self.token_repo.load_token(&self.token_path)
}
}
@@ -0,0 +1,66 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Session management use-cases.
//!
//! `SessionServiceImpl` implements `SessionService` by delegating to
//! injected repository implementations, keeping the orchestration logic
//! independent of any concrete persistence mechanism.
use std::path::PathBuf;
use uuid::Uuid;
use crate::domain::repository::{SessionLockRepository, SessionRepository};
use crate::domain::service::SessionService;
use crate::domain::session::Session;
/// Concrete session service backed by generic repository implementations.
pub struct SessionServiceImpl<R: SessionRepository, L: SessionLockRepository> {
pub session_repo: R,
pub lock_repo: L,
pub base_dir: PathBuf,
}
impl<R: SessionRepository, L: SessionLockRepository> SessionServiceImpl<R, L> {
/// Create a new session service with the given repositories and base
/// data directory.
pub fn new(session_repo: R, lock_repo: L, base_dir: PathBuf) -> Self {
SessionServiceImpl {
session_repo,
lock_repo,
base_dir,
}
}
}
impl<R: SessionRepository, L: SessionLockRepository> SessionService for SessionServiceImpl<R, L> {
fn create_session(&self, title: &str) -> anyhow::Result<Session> {
let id = Uuid::new_v4().to_string();
let title_owned = if title.is_empty() {
"New Session".to_string()
} else {
title.to_string()
};
let session = Session::new(id, title_owned);
self.session_repo.save_session(&self.base_dir, &session)?;
Ok(session)
}
fn list_all(&self) -> anyhow::Result<Vec<Session>> {
self.session_repo.list_sessions(&self.base_dir)
}
fn archive_session(&self, id: &str) -> anyhow::Result<()> {
let mut session = self.session_repo.load_session(&self.base_dir, id)?;
session.archived = true;
session.updated_at = 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)?;
Ok(())
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod oauth;
pub mod repository;
pub mod service;
pub mod session;
pub mod session_lock;
+44
View File
@@ -0,0 +1,44 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Pure OAuth entities — no HTTP or persistence logic.
use serde::{Deserialize, Serialize};
/// An OAuth 2.0 access token with optional refresh token and absolute
/// expiry time (epoch seconds).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: u64,
pub token_type: String,
}
/// Static configuration for an OAuth provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
pub auth_url: String,
pub token_url: String,
pub client_id: String,
pub client_secret: Option<String>,
pub scopes: Vec<String>,
}
impl Default for OAuthConfig {
fn default() -> Self {
OAuthConfig {
auth_url: String::new(),
token_url: String::new(),
client_id: String::new(),
client_secret: None,
scopes: vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
],
}
}
}
@@ -0,0 +1,50 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Repository trait definitions (pure — no impls, no concrete persistence).
use std::path::Path;
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>>;
/// Load a single session by id.
fn load_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<Session>;
/// Save a session's metadata to disk.
fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()>;
/// Delete a session directory and all its contents.
fn delete_session(&self, base_dir: &Path, id: &str) -> anyhow::Result<()>;
}
/// Repository for per-session PID-file advisory locks.
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>;
/// Release the lock by removing the lock file.
fn unlock(&self, session_dir: &Path) -> anyhow::Result<()>;
/// Check whether a process with the given PID is alive.
fn is_alive(&self, pid: u32) -> bool;
}
/// 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<()>;
/// 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>>;
}
+36
View File
@@ -0,0 +1,36 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Service trait definitions — use-case interfaces for session management
//! and OAuth flows.
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>;
/// List all available sessions.
fn list_all(&self) -> anyhow::Result<Vec<Session>>;
/// Archive a session by id (sets `archived = true`).
fn archive_session(&self, id: &str) -> anyhow::Result<()>;
}
/// OAuth flow use-case boundary.
pub trait OAuthService {
/// Start an OAuth authorization-code + PKCE flow.
/// Returns the provider's authorization URL to visit.
fn start_flow(&self, config: &OAuthConfig) -> anyhow::Result<String>;
/// Complete the OAuth flow by exchanging an authorization code for a
/// token.
fn complete_flow(&self, config: &OAuthConfig, code: &str) -> anyhow::Result<OAuthToken>;
/// Retrieve the currently stored OAuth token (if any).
fn get_token(&self) -> anyhow::Result<Option<OAuthToken>>;
}
+59
View File
@@ -0,0 +1,59 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Pure Session entity — no persistence logic.
//!
//! This type represents the metadata of one conversation session.
//! All save / load / list operations belong to the repository traits.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Metadata for one conversation session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: i64,
pub updated_at: i64,
pub title: String,
pub model: String,
pub workspace_roots: Vec<PathBuf>,
pub message_count: u32,
pub token_count: u32,
pub archived: bool,
pub summary: Option<String>,
}
impl Session {
/// Create a new session with default field values.
pub fn new(id: String, title: String) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
Session {
id,
created_at: now,
updated_at: now,
title,
model: "anthropic/claude-opus-4-8".to_string(),
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
message_count: 0,
token_count: 0,
archived: false,
summary: None,
}
}
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
base_dir.join("sessions").join(&self.id)
}
/// Compute this session's conversation.json path.
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("conversation.json")
}
}
@@ -0,0 +1,29 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Pure SessionLock entity — no lock / unlock logic.
//!
//! Lock acquisition and release are handled by the repository.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// A PID-file based session lock handle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionLock {
pub path: PathBuf,
pub pid: u32,
}
impl SessionLock {
/// Construct a lock handle for a session directory (does not acquire
/// the lock yet — use the repository's `try_lock`).
pub fn new(session_dir: &Path) -> Self {
SessionLock {
path: session_dir.join(".lock"),
pid: std::process::id(),
}
}
}
@@ -0,0 +1,63 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! IAM-specific HTTP / IPC DTOs (Data Transfer Objects).
use serde::{Deserialize, Serialize};
use crate::domain::oauth::{OAuthConfig, OAuthToken};
use crate::domain::session::Session;
// ---------------------------------------------------------------------------
// Session DTOs
// ---------------------------------------------------------------------------
/// Request body for creating a new session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSessionRequest {
pub title: String,
}
/// Response containing one session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionResponse {
pub session: Session,
}
/// Response containing a list of sessions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionListResponse {
pub sessions: Vec<Session>,
pub total: usize,
}
// ---------------------------------------------------------------------------
// OAuth DTOs
// ---------------------------------------------------------------------------
/// Request body for starting an OAuth flow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthStartRequest {
pub config: OAuthConfig,
}
/// Response containing the authorization URL for an OAuth flow.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthStartResponse {
pub auth_url: String,
}
/// Request body for completing an OAuth flow with an authorization code.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthCompleteRequest {
pub config: OAuthConfig,
pub code: String,
}
/// Response containing the acquired OAuth token.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthTokenResponse {
pub token: OAuthToken,
}
@@ -0,0 +1,73 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! IPC / HTTP handler functions.
//!
//! Each handler is a plain function that takes a service reference and a
//! request DTO, delegates to the service, and returns a response DTO.
//! Handlers are generic over the service trait so they remain independent
//! of concrete implementations.
use crate::domain::service::{OAuthService, SessionService};
use crate::infrastructure::http::dto::{
CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse,
OAuthTokenResponse, SessionListResponse, SessionResponse,
};
/// Handle a create-session request.
pub fn handle_create_session<S: SessionService>(
service: &S,
req: CreateSessionRequest,
) -> anyhow::Result<SessionResponse> {
let session = service.create_session(&req.title)?;
Ok(SessionResponse { session })
}
/// Handle a list-sessions request.
pub fn handle_list_sessions<S: SessionService>(
service: &S,
) -> anyhow::Result<SessionListResponse> {
let sessions = service.list_all()?;
let total = sessions.len();
Ok(SessionListResponse { sessions, total })
}
/// Handle an archive-session request.
pub fn handle_archive_session<S: SessionService>(
service: &S,
id: &str,
) -> anyhow::Result<()> {
service.archive_session(id)?;
Ok(())
}
/// Handle a start-OAuth-flow request.
pub fn handle_start_oauth<O: OAuthService>(
service: &O,
req: OAuthStartRequest,
) -> anyhow::Result<OAuthStartResponse> {
let auth_url = service.start_flow(&req.config)?;
Ok(OAuthStartResponse { auth_url })
}
/// Handle a complete-OAuth-flow request.
pub fn handle_complete_oauth<O: OAuthService>(
service: &O,
req: OAuthCompleteRequest,
) -> anyhow::Result<OAuthTokenResponse> {
let token = service.complete_flow(&req.config, &req.code)?;
Ok(OAuthTokenResponse { token })
}
/// Handle a get-token request.
pub fn handle_get_token<O: OAuthService>(
service: &O,
) -> anyhow::Result<OAuthTokenResponse> {
let token = service
.get_token()?
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
Ok(OAuthTokenResponse { token })
}
@@ -0,0 +1,2 @@
pub mod dto;
pub mod handlers;
@@ -0,0 +1,2 @@
pub mod http;
pub mod persistence;
@@ -0,0 +1,2 @@
pub mod oauth_repo;
pub mod session_repo;
@@ -0,0 +1,52 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Filesystem-backed `OAuthRepository` implementation.
//!
//! Tokens are stored as a single JSON file. Writes use a write-then-rename
//! + fsync pattern for crash safety.
use std::path::Path;
use crate::domain::oauth::OAuthToken;
use crate::domain::repository::OAuthRepository;
/// Concrete filesystem OAuth token repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemOAuthRepository;
impl FileSystemOAuthRepository {
/// Create a new filesystem OAuth repository.
pub fn new() -> Self {
FileSystemOAuthRepository
}
}
impl OAuthRepository for FileSystemOAuthRepository {
fn save_token(&self, path: &Path, token: &OAuthToken) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(token)?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(())
}
fn load_token(&self, path: &Path) -> anyhow::Result<Option<OAuthToken>> {
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(path)?;
let token: OAuthToken = serde_json::from_str(&data)?;
Ok(Some(token))
}
}
@@ -0,0 +1,92 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Filesystem-backed `SessionRepository` implementation.
//!
//! Each session is stored as `<base_dir>/sessions/<id>/session.json`.
//! Writes use a write-then-rename + fsync pattern for crash safety.
use std::path::Path;
use crate::domain::repository::SessionRepository;
use crate::domain::session::Session;
/// Concrete filesystem session repository.
#[derive(Debug, Clone, Default)]
pub struct FileSystemSessionRepository;
impl FileSystemSessionRepository {
/// Create a new filesystem session repository.
pub fn new() -> Self {
FileSystemSessionRepository
}
}
impl SessionRepository for FileSystemSessionRepository {
fn list_sessions(&self, base_dir: &Path) -> anyhow::Result<Vec<Session>> {
let sessions_dir = base_dir.join("sessions");
let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
return Ok(Vec::new());
};
let mut sessions = Vec::new();
for entry in entries.flatten() {
if !entry.path().is_dir() {
continue;
}
let id = entry.file_name().to_string_lossy().to_string();
if let Ok(session) = self.load_session(base_dir, &id) {
sessions.push(session);
}
}
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"
);
}
let path = base_dir.join("sessions").join(id).join("session.json");
if !path.exists() {
anyhow::bail!("session not found: {id}");
}
let data = std::fs::read_to_string(&path)?;
let session: Session = serde_json::from_str(&data)?;
Ok(session)
}
fn save_session(&self, base_dir: &Path, session: &Session) -> anyhow::Result<()> {
let dir = session.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
let data = serde_json::to_string_pretty(session)?;
let tmp = dir.join("session.json.tmp");
std::fs::write(&tmp, data)?;
// fsync before rename ensures the data is on disk.
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
// fsync the parent directory so the rename survives a crash.
if let Some(parent) = dir.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
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"
);
}
let dir = base_dir.join("sessions").join(id);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
Ok(())
}
}
+17
View File
@@ -0,0 +1,17 @@
#![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:
//!
//! - **domain** — Pure entities and repository/service trait definitions
//! - **application**— Use-case implementations of the service traits
//! - **infrastructure** — Concrete persistence (filesystem) and HTTP adapter layers
pub mod domain;
pub mod application;
pub mod infrastructure;