Refactor session ID handling and improve error management

- Introduced `SessionId` newtype for validated session identifiers, ensuring safety against path traversal attacks.
- Updated session repository methods to accept `SessionId` instead of raw strings, enhancing type safety.
- Removed redundant error handling in repository methods by leveraging the new `Error` type from `zesdex_utils`.
- Simplified atomic JSON write operations by eliminating unnecessary error conversions.
- Enhanced integer casting with a new `CastOr` trait for safer narrowing conversions.
- Removed deprecated error handling code and consolidated error types across the codebase.
- Updated HTTP handlers to utilize the new session ID validation, improving overall robustness.
This commit is contained in:
asepharyana
2026-07-20 06:39:30 +07:00
parent ab1a54b72e
commit e9a8e93c83
39 changed files with 413 additions and 366 deletions
+14 -7
View File
@@ -10,6 +10,8 @@ use std::path::Path;
use serde::Serialize;
use tracing;
use crate::Result;
/// Atomically write serializable `data` to `path`.
///
/// Flow: serialize to pretty JSON -> write to `path.tmp` -> fsync -> rename -> fsync parent.
@@ -18,28 +20,33 @@ use tracing;
/// Edge case: tmp file name uses `with_extension("tmp")` which replaces
/// the existing extension -- correct for `foo.json` -> `foo.tmp`. For paths
/// without an extension (unlikely in this codebase), appends `.tmp`.
pub fn write_json_atomic<T: Serialize>(path: &Path, data: &T, mode: Option<u32>) -> anyhow::Result<()> {
///
/// # Errors
///
/// Returns `Error::Io` on I/O failures, `Error::Serde` on serialisation
/// failures.
pub fn write_json_atomic<T: Serialize>(path: &Path, data: &T, mode: Option<u32>) -> Result<()> {
let tmp = path.with_extension("tmp"); // temporary sibling for atomic rename
let bytes = serde_json::to_vec_pretty(data)?; // pretty-printed JSON
let bytes = serde_json::to_vec_pretty(data)?; // pretty-printed JSON -> Error::Serde
{
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?; // flush kernel buffers to disk
.open(&tmp)?; // -> Error::Io
f.write_all(&bytes)?; // -> Error::Io
f.sync_all()?; // flush kernel buffers to disk -> Error::Io
}
if let Some(m) = mode {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(m))?; // -> Error::Io
}
#[cfg(not(unix))]
{ let _ = m; }
}
std::fs::rename(&tmp, path)?; // atomic move (POSIX guarantees it is atomic within the same fs)
std::fs::rename(&tmp, path)?; // -> Error::Io (atomic move within same fs)
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
+106
View File
@@ -0,0 +1,106 @@
//! Safe integer cast extension trait.
//!
//! Provides a `cast_or(self, default: U)` method on integer types that
//! uses `TryFrom` for a checked narrowing conversion, falling back to a
//! caller-supplied default on overflow. Replaces the `as` casts that
//! used `#![allow(clippy::cast_*)]` across the codebase.
//!
//! # Example
//!
//! ```ignore
//! use zesdex_utils::CastOr;
//!
//! let len: usize = 42;
//! let n: i64 = len.cast_or(-1); // Ok(42)
//! ```
/// Extension trait for checked integer narrowing with a fallback default.
///
/// Implementations are provided for all commonly-used integer conversions
/// via a macro. Each implementation calls `U::try_from(self).unwrap_or(default)`.
pub trait CastOr<U> {
/// Convert `self` to type `U`, returning `default` if the value overflows.
fn cast_or(self, default: U) -> U;
}
macro_rules! impl_cast_or {
($from:ty => $($to:ty),+ $(,)?) => {
$(
impl CastOr<$to> for $from {
#[inline]
fn cast_or(self, default: $to) -> $to {
<$to as TryFrom<$from>>::try_from(self).unwrap_or(default)
}
}
)+
};
}
// usize → narrower types (same-archive-size signed version too)
impl_cast_or!(usize => u64, i64, u32, i32, u16);
// u64 → narrower types
impl_cast_or!(u64 => i64, u32, i32, u16, u8);
// i64 → narrower types
impl_cast_or!(i64 => u64, i32, u16, u8);
// u32 → narrower types
impl_cast_or!(u32 => i32, u16, u8);
// u128 → u64 (common for Duration math)
impl CastOr<u64> for u128 {
#[inline]
fn cast_or(self, default: u64) -> u64 {
u64::try_from(self).unwrap_or(default)
}
}
impl CastOr<i64> for u128 {
#[inline]
fn cast_or(self, default: i64) -> i64 {
i64::try_from(self).unwrap_or(default)
}
}
impl CastOr<u32> for u128 {
#[inline]
fn cast_or(self, default: u32) -> u32 {
u32::try_from(self).unwrap_or(default)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_usize_to_u64() {
let v: usize = 100;
assert_eq!(v.cast_or(0u64), 100u64);
}
#[test]
fn test_usize_to_i64() {
let v: usize = 100;
assert_eq!(v.cast_or(0i64), 100i64);
}
#[test]
fn test_usize_to_u16_overflow() {
let v: usize = 70000; // > u16::MAX
assert_eq!(v.cast_or(42u16), 42u16);
}
#[test]
fn test_u128_to_u64_overflow() {
let v: u128 = u64::MAX as u128 + 1;
assert_eq!(v.cast_or(999u64), 999u64);
}
#[test]
fn test_u64_to_i32_overflow() {
let v: u64 = i32::MAX as u64 + 1;
assert_eq!(v.cast_or(-1i32), -1i32);
}
}
+36 -53
View File
@@ -1,73 +1,47 @@
//! Shared error types for the zesdex codebase.
//!
//! Defines [`Error`], a unified error enum covering I/O, JSON, parse,
//! not-found, and invalid-input cases, plus a [`Result`] type alias.
//! Conversions from `std::io::Error` and `serde_json::Error` are provided
//! via `From` impls.
use std::fmt;
//! not-found, conflict, invalid-id, and invalid-input cases, plus a
//! [`Result`] type alias. Conversions from `std::io::Error` and
//! `serde_json::Error` are provided via `From` impls.
//!
//! This type is used directly by repository traits across crates,
//! replacing per-crate `RepositoryError` duplications.
/// Unified error type for the zesdex codebase.
#[derive(Debug)]
///
/// Serves as the shared `RepositoryError` for all persistence layers.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Wraps an I/O error.
Io(std::io::Error),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// Wraps a JSON serialization/deserialization error.
Serde(serde_json::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
/// A generic parse failure with a message.
#[error("parse error: {0}")]
Parse(String),
/// A resource was not found.
#[error("not found: {0}")]
NotFound(String),
/// A conflict occurred (e.g. duplicate entry).
#[error("conflict: {0}")]
Conflict(String),
/// Invalid input was provided.
#[error("invalid input: {0}")]
InvalidInput(String),
/// The supplied identifier is invalid (e.g. path traversal attempt).
#[error("invalid id: {0}")]
InvalidId(String),
/// The session is locked and cannot be accessed.
#[error("session is locked")]
SessionLocked,
/// An error that could not be cast to a specific variant.
#[error("{0}")]
Other(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Serde(e) => write!(f, "serialization error: {e}"),
Self::Parse(msg) => write!(f, "parse error: {msg}"),
Self::NotFound(resource) => write!(f, "not found: {resource}"),
Self::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
Self::SessionLocked => write!(f, "session is locked"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(e) => Some(e),
Self::Serde(e) => Some(e),
Self::Parse(_) | Self::NotFound(_) | Self::InvalidInput(_) | Self::SessionLocked => {
None
}
}
}
}
// ---------------------------------------------------------------------------
// From conversions
// ---------------------------------------------------------------------------
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self {
Self::Serde(e)
}
}
// Note: anyhow already provides `From<E> for anyhow::Error` for all
// `E: std::error::Error + Send + Sync + 'static`, which our `Error` satisfies.
// ---------------------------------------------------------------------------
// Type alias
// ---------------------------------------------------------------------------
@@ -76,7 +50,7 @@ impl From<serde_json::Error> for Error {
pub type Result<T> = std::result::Result<T, Error>;
// ---------------------------------------------------------------------------
// Additional impls
// Constructors
// ---------------------------------------------------------------------------
impl Error {
@@ -94,4 +68,13 @@ impl Error {
pub fn invalid_input(msg: impl Into<String>) -> Self {
Self::InvalidInput(msg.into())
}
/// Convert an `anyhow::Error` to `zesdex_utils::Error` 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 Error::Io(std::io::Error::new(ioe.kind(), ioe.to_string()));
}
Error::Other(e.to_string())
}
}
+2
View File
@@ -15,6 +15,8 @@
pub mod atomic_write;
pub use atomic_write::write_json_atomic;
pub mod cast;
pub use cast::CastOr;
pub mod clipboard;
pub mod error;
pub mod logger;