Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
# Web framework
axum = "0.7"
http-body-util = "0.1"
tokio = { version = "1.35", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors"] }
Expand Down Expand Up @@ -48,4 +49,4 @@ async-trait = "0.1"

[dev-dependencies]
httpmock = "0.7"
axum-test = "16.4.1"
axum-test = "16.4.1"
27 changes: 15 additions & 12 deletions contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use tracing::{info, warn};
use cache::CacheBackend;
use hash_validator::{HashValidator, ValidationError as HashValidationError};
use metrics::MetricsRegistry;
use module::middleware::hash_normalization::normalize;
use module::webhook::VerificationWebhookNotifier;
use stellar::{StellarClient, TransactionRecord};

Expand All @@ -39,7 +40,7 @@ pub struct AppState {
}

// Request/Response types
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct VerifyRequest {
pub document_hash: String,
pub transaction_id: Option<String>,
Expand All @@ -54,7 +55,7 @@ pub struct VerifyResponse {
}

/// Request type for submitting a document hash to Stellar blockchain
#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct SubmitRequest {
pub document_hash: String,
pub document_id: String,
Expand All @@ -70,7 +71,7 @@ pub struct SubmitResponse {
pub error: Option<String>,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct RevokeRequest {
pub document_hash: String,
pub reason: String,
Expand Down Expand Up @@ -104,7 +105,7 @@ pub struct ValidationErrorResponse {
pub error: String,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchVerifyRequest {
pub hashes: Vec<String>,
}
Expand All @@ -126,7 +127,7 @@ pub struct BatchVerifyItem {
pub error: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TransferRequest {
pub document_hash: String,
pub from_owner: String,
Expand Down Expand Up @@ -176,6 +177,8 @@ fn map_validation_error(err: HashValidationError) -> (StatusCode, ValidationErro
}

pub fn app(state: AppState) -> Router {
use crate::module::middleware::hash_normalization::hash_normalization_middleware;

Router::new()
.route("/health", get(health_check))
.route("/metrics", get(metrics_handler))
Expand All @@ -186,6 +189,7 @@ pub fn app(state: AppState) -> Router {
.route("/submit", post(submit_document))
.route("/revoke", post(revoke_document))
.route("/transfer", post(record_transfer))
.layer(axum::middleware::from_fn(hash_normalization_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
Expand Down Expand Up @@ -337,7 +341,7 @@ pub async fn verify_document(
State(state): State<AppState>,
Json(req): Json<VerifyRequest>,
) -> Response {
let normalized_hash = HashValidator::normalize(&req.document_hash);
let normalized_hash = normalize(&req.document_hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
let (status, body) = map_validation_error(err);
return (status, Json(body)).into_response();
Expand Down Expand Up @@ -397,7 +401,7 @@ pub async fn verify_document_history(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> Response {
let normalized_hash = HashValidator::normalize(&hash);
let normalized_hash = normalize(&hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
let (status, body) = map_validation_error(err);
return (status, Json(body)).into_response();
Expand Down Expand Up @@ -483,8 +487,7 @@ pub async fn batch_verify_documents(

// Helper function to verify a single hash
async fn verify_single_hash(state: &AppState, hash: String) -> BatchVerifyItem {
let normalized_hash = HashValidator::normalize(&hash);

let normalized_hash = normalize(&hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
let error_msg = match err {
HashValidationError::EmptyHash => "hash must not be empty".to_string(),
Expand Down Expand Up @@ -572,7 +575,7 @@ pub async fn submit_document(
State(state): State<AppState>,
Json(req): Json<SubmitRequest>,
) -> impl IntoResponse {
let normalized_hash = HashValidator::normalize(&req.document_hash);
let normalized_hash = normalize(&req.document_hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
let (status, body) = map_validation_error(err);
return (status, Json(body)).into_response();
Expand Down Expand Up @@ -639,7 +642,7 @@ pub async fn revoke_document(
State(state): State<AppState>,
Json(req): Json<RevokeRequest>,
) -> impl IntoResponse {
let normalized_hash = HashValidator::normalize(&req.document_hash);
let normalized_hash = normalize(&req.document_hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
let (status, body) = map_validation_error(err);
return (status, Json(body)).into_response();
Expand Down Expand Up @@ -691,7 +694,7 @@ pub async fn revoke_document(
}

pub async fn transfer_document(Json(req): Json<TransferRequest>) -> impl IntoResponse {
let normalized_hash = HashValidator::normalize(&req.document_hash);
let normalized_hash = normalize(&req.document_hash);
if let Err(err) = HashValidator::validate_sha256(&normalized_hash) {
let (status, body) = map_validation_error(err);
return (status, Json(body));
Expand Down
229 changes: 229 additions & 0 deletions contract/src/module/middleware/hash_normalization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
use crate::hash_validator::{HashValidator, ValidationError};
use crate::ValidationErrorResponse;
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Json, Response},
};
use http_body_util::BodyExt;
use serde::Deserialize;

/// Normalizes a hash string by trimming whitespace and converting to lowercase
pub fn normalize(hash: &str) -> String {
hash.trim().to_lowercase()
}

/// Helper function to normalize hash fields in request bodies
pub fn normalize_request_body<T>(body: &mut T)
where
T: HasDocumentHash,
{
if let Some(document_hash) = body.document_hash_mut() {
*document_hash = normalize(document_hash);
}
}

/// Trait to extract and mutate the document_hash field from request types
pub trait HasDocumentHash {
fn document_hash(&self) -> Option<&str>;
fn document_hash_mut(&mut self) -> Option<&mut String>;
}

// Implement HasDocumentHash for all request types that have a document_hash field
#[derive(Debug, Deserialize)]
struct AnyRequest;

impl HasDocumentHash for crate::VerifyRequest {
fn document_hash(&self) -> Option<&str> {
Some(&self.document_hash)
}
fn document_hash_mut(&mut self) -> Option<&mut String> {
Some(&mut self.document_hash)
}
}

impl HasDocumentHash for crate::SubmitRequest {
fn document_hash(&self) -> Option<&str> {
Some(&self.document_hash)
}
fn document_hash_mut(&mut self) -> Option<&mut String> {
Some(&mut self.document_hash)
}
}

impl HasDocumentHash for crate::RevokeRequest {
fn document_hash(&self) -> Option<&str> {
Some(&self.document_hash)
}
fn document_hash_mut(&mut self) -> Option<&mut String> {
Some(&mut self.document_hash)
}
}

impl HasDocumentHash for crate::TransferRequest {
fn document_hash(&self) -> Option<&str> {
Some(&self.document_hash)
}
fn document_hash_mut(&mut self) -> Option<&mut String> {
Some(&mut self.document_hash)
}
}

/// Axum middleware that normalizes hash strings in request bodies and path parameters
pub async fn hash_normalization_middleware(
mut req: Request,
next: Next,
) -> Result<Response, impl IntoResponse> {
// First, handle path parameters that might contain hashes (like /verify/:hash)
let uri = req.uri().clone();
let path = uri.path();

// Check if this is an endpoint that has a hash in the path
if let Some(captures) = path
.strip_prefix("/verify/")
.and_then(|p| p.strip_suffix("/history").or_else(|| Some(p)))
{
if !captures.is_empty() && !captures.starts_with("batch") {
// This is /verify/:hash or /verify/:hash/history
// We'll handle the normalization in the handler itself since path params are extracted directly
// Middleware can't easily modify path params, so the existing normalize call in handlers is still used
// But we ensure it's using our shared normalize function
}
}

// For request bodies, we can deserialize, normalize, then re-serialize
// This works for all JSON requests that have a document_hash field
let body = match req.body_mut().collect().await {
Ok(b) => b.to_bytes(),
Err(_) => {
return Err((
StatusCode::BAD_REQUEST,
Json(ValidationErrorResponse {
error: "Failed to read request body".to_string(),
}),
))
}
};

// Try to deserialize and normalize if it's a request with document_hash
if let Ok(mut verify_req) = serde_json::from_slice::<crate::VerifyRequest>(&body) {
normalize_request_body(&mut verify_req);
// Validate after normalization
if let Err(err) = HashValidator::validate_sha256(&verify_req.document_hash) {
let (status, body) = map_validation_error(err);
return Err((status, Json(body)));
}
// Re-attach the normalized body
*req.body_mut() = serde_json::to_vec(&verify_req).unwrap().into();
} else if let Ok(mut submit_req) = serde_json::from_slice::<crate::SubmitRequest>(&body) {
normalize_request_body(&mut submit_req);
if let Err(err) = HashValidator::validate_sha256(&submit_req.document_hash) {
let (status, body) = map_validation_error(err);
return Err((status, Json(body)));
}
*req.body_mut() = serde_json::to_vec(&submit_req).unwrap().into();
} else if let Ok(mut revoke_req) = serde_json::from_slice::<crate::RevokeRequest>(&body) {
normalize_request_body(&mut revoke_req);
if let Err(err) = HashValidator::validate_sha256(&revoke_req.document_hash) {
let (status, body) = map_validation_error(err);
return Err((status, Json(body)));
}
*req.body_mut() = serde_json::to_vec(&revoke_req).unwrap().into();
} else if let Ok(mut transfer_req) = serde_json::from_slice::<crate::TransferRequest>(&body) {
normalize_request_body(&mut transfer_req);
if let Err(err) = HashValidator::validate_sha256(&transfer_req.document_hash) {
let (status, body) = map_validation_error(err);
return Err((status, Json(body)));
}
*req.body_mut() = serde_json::to_vec(&transfer_req).unwrap().into();
}

// Continue processing the request
Ok(next.run(req).await)
}

fn map_validation_error(err: ValidationError) -> (StatusCode, ValidationErrorResponse) {
let message = match err {
ValidationError::EmptyHash => "hash must not be empty".to_string(),
ValidationError::WrongLength { expected, actual } => format!(
"hash has wrong length: expected {} characters, got {}",
expected, actual
),
ValidationError::InvalidCharacter {
position,
character,
} => format!(
"hash contains invalid character '{}' at position {}",
character, position
),
};

(
StatusCode::BAD_REQUEST,
ValidationErrorResponse { error: message },
)
}

#[cfg(test)]
mod tests {
use super::*;

fn sample_sha256_uppercase() -> &'static str {
"E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
}

fn sample_sha256_mixed() -> &'static str {
"e3b0c44298FC1c149AFbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

fn sample_sha256_lowercase() -> &'static str {
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}

#[test]
fn lowercase_input_unchanged() {
let result = normalize(sample_sha256_lowercase());
assert_eq!(result, sample_sha256_lowercase());
assert_eq!(result.len(), 64);
}

#[test]
fn uppercase_input_lowercased() {
let result = normalize(sample_sha256_uppercase());
assert_eq!(result, sample_sha256_lowercase());
assert_eq!(result.len(), 64);
}

#[test]
fn mixed_case_input_lowercased() {
let result = normalize(sample_sha256_mixed());
assert_eq!(result, sample_sha256_lowercase());
assert_eq!(result.len(), 64);
}

#[test]
fn leading_trailing_spaces_stripped() {
let input = format!(" {} ", sample_sha256_lowercase());
let result = normalize(&input);
assert_eq!(result, sample_sha256_lowercase());
assert_eq!(result.len(), 64);
}

#[test]
fn combined_whitespace_and_uppercase() {
let input = format!(" {} ", sample_sha256_uppercase());
let result = normalize(&input);
assert_eq!(result, sample_sha256_lowercase());
assert_eq!(result.len(), 64);
}

#[test]
fn sixtyfour_char_uppercase_normalizes_to_sixtyfour_lowercase() {
let input = sample_sha256_uppercase();
assert_eq!(input.len(), 64);
let result = normalize(input);
assert_eq!(result.len(), 64);
assert_eq!(result, sample_sha256_lowercase());
}
}
1 change: 1 addition & 0 deletions contract/src/module/middleware/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod hash_normalization;
1 change: 1 addition & 0 deletions contract/src/module/mod.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod cache_warmup;
pub mod middleware;
pub mod webhook;
Loading