mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 05:40:31 +00:00
* fix: remove unwrap() from production code, improve error handling - Replace unwrap() with proper error handling in npm, mirror, validation - Add input validation to cargo registry (crate name + version) - Improve expect() messages with descriptive context in metrics, rate_limit - Remove unnecessary clone() in error.rs, docker.rs, npm.rs, dashboard_metrics - Add #![deny(clippy::unwrap_used)] to prevent future unwrap in prod code - Add let-else pattern for safer null checks in validation.rs * docs: update SECURITY.md — add 0.3.x to supported versions * security: forbid unsafe code at crate level Add #![forbid(unsafe_code)] to both lib.rs and main.rs. NORA has zero unsafe blocks — this prevents future additions without removing the forbid attribute (stronger than deny). * build: add rust-toolchain.toml, Dockerfile HEALTHCHECK - Pin toolchain to stable with clippy + rustfmt components - Add Docker HEALTHCHECK for standalone deployments (wget /health) * test: add Go proxy and Raw registry integration tests Go proxy tests: list, .info, .mod, @latest, path traversal, 404 Raw registry tests: upload/download, HEAD, 404, path traversal, overwrite, delete, binary data (10KB)
201 lines
6.0 KiB
Rust
201 lines
6.0 KiB
Rust
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//! Application error handling with HTTP response conversion
|
|
//!
|
|
//! Provides a unified error type that can be converted to HTTP responses
|
|
//! with appropriate status codes and JSON error bodies.
|
|
|
|
use axum::{
|
|
http::StatusCode,
|
|
response::{IntoResponse, Response},
|
|
Json,
|
|
};
|
|
use serde::Serialize;
|
|
use thiserror::Error;
|
|
|
|
use crate::storage::StorageError;
|
|
use crate::validation::ValidationError;
|
|
|
|
#[allow(dead_code)] // Wiring into handlers planned for v0.3
|
|
/// Application-level errors with HTTP response conversion
|
|
#[derive(Debug, Error)]
|
|
pub enum AppError {
|
|
#[error("Not found: {0}")]
|
|
NotFound(String),
|
|
|
|
#[error("Bad request: {0}")]
|
|
BadRequest(String),
|
|
|
|
#[error("Unauthorized: {0}")]
|
|
Unauthorized(String),
|
|
|
|
#[error("Internal error: {0}")]
|
|
Internal(String),
|
|
|
|
#[error("Storage error: {0}")]
|
|
Storage(#[from] StorageError),
|
|
|
|
#[error("Validation error: {0}")]
|
|
Validation(#[from] ValidationError),
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
/// JSON error response body
|
|
#[derive(Serialize)]
|
|
struct ErrorResponse {
|
|
error: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
request_id: Option<String>,
|
|
}
|
|
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let (status, message) = match self {
|
|
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
|
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
|
|
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
|
|
AppError::Storage(e) => match e {
|
|
StorageError::NotFound => (StatusCode::NOT_FOUND, "Resource not found".to_string()),
|
|
StorageError::Validation(v) => (StatusCode::BAD_REQUEST, v.to_string()),
|
|
_ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
|
|
},
|
|
AppError::Validation(e) => (StatusCode::BAD_REQUEST, e.to_string()),
|
|
};
|
|
|
|
(
|
|
status,
|
|
Json(ErrorResponse {
|
|
error: message,
|
|
request_id: None,
|
|
}),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
impl AppError {
|
|
/// Create a not found error
|
|
pub fn not_found(msg: impl Into<String>) -> Self {
|
|
Self::NotFound(msg.into())
|
|
}
|
|
|
|
/// Create a bad request error
|
|
pub fn bad_request(msg: impl Into<String>) -> Self {
|
|
Self::BadRequest(msg.into())
|
|
}
|
|
|
|
/// Create an unauthorized error
|
|
pub fn unauthorized(msg: impl Into<String>) -> Self {
|
|
Self::Unauthorized(msg.into())
|
|
}
|
|
|
|
/// Create an internal error
|
|
pub fn internal(msg: impl Into<String>) -> Self {
|
|
Self::Internal(msg.into())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_app_error_from_storage_error() {
|
|
let storage_err = StorageError::NotFound;
|
|
let app_err: AppError = storage_err.into();
|
|
assert!(matches!(app_err, AppError::Storage(StorageError::NotFound)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_app_error_from_validation_error() {
|
|
let val_err = ValidationError::EmptyInput;
|
|
let app_err: AppError = val_err.into();
|
|
assert!(matches!(
|
|
app_err,
|
|
AppError::Validation(ValidationError::EmptyInput)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_display() {
|
|
let err = AppError::NotFound("image not found".to_string());
|
|
assert_eq!(err.to_string(), "Not found: image not found");
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_constructors() {
|
|
let err = AppError::not_found("missing");
|
|
assert!(matches!(err, AppError::NotFound(_)));
|
|
assert_eq!(err.to_string(), "Not found: missing");
|
|
|
|
let err = AppError::bad_request("invalid input");
|
|
assert!(matches!(err, AppError::BadRequest(_)));
|
|
assert_eq!(err.to_string(), "Bad request: invalid input");
|
|
|
|
let err = AppError::unauthorized("no token");
|
|
assert!(matches!(err, AppError::Unauthorized(_)));
|
|
assert_eq!(err.to_string(), "Unauthorized: no token");
|
|
|
|
let err = AppError::internal("db crashed");
|
|
assert!(matches!(err, AppError::Internal(_)));
|
|
assert_eq!(err.to_string(), "Internal error: db crashed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_display_storage() {
|
|
let err = AppError::Storage(StorageError::NotFound);
|
|
assert!(err.to_string().contains("Storage error"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_display_validation() {
|
|
let err = AppError::Validation(ValidationError::PathTraversal);
|
|
assert!(err.to_string().contains("Validation error"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_into_response_not_found() {
|
|
let err = AppError::NotFound("gone".to_string());
|
|
let response = err.into_response();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_into_response_bad_request() {
|
|
let err = AppError::BadRequest("bad".to_string());
|
|
let response = err.into_response();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_into_response_unauthorized() {
|
|
let err = AppError::Unauthorized("nope".to_string());
|
|
let response = err.into_response();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_into_response_internal() {
|
|
let err = AppError::Internal("boom".to_string());
|
|
let response = err.into_response();
|
|
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_into_response_storage_not_found() {
|
|
let err = AppError::Storage(StorageError::NotFound);
|
|
let response = err.into_response();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_into_response_validation() {
|
|
let err = AppError::Validation(ValidationError::EmptyInput);
|
|
let response = err.into_response();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
}
|