From a7a6bf27775bf15b12e3145685989419aa86d05c Mon Sep 17 00:00:00 2001 From: Prz-droid Date: Sat, 25 Apr 2026 01:22:46 +0100 Subject: [PATCH] Add comprehensive input validation with strict constraints across all modules --- INPUT_VALIDATION_IMPLEMENTATION.md | 0 .../VALIDATION_IMPLEMENTATION.md | 0 dongle-smartcontract/src/constants.rs | 8 + dongle-smartcontract/src/errors.rs | 28 + dongle-smartcontract/src/lib.rs | 1 + dongle-smartcontract/src/project_registry.rs | 142 ++---- dongle-smartcontract/src/review_registry.rs | 19 +- dongle-smartcontract/src/tests/mod.rs | 1 + .../src/tests/validation_tests.rs | 396 ++++++++++++++ dongle-smartcontract/src/validation.rs | 481 ++++++++++++++++++ .../src/verification_registry.rs | 12 +- 11 files changed, 963 insertions(+), 125 deletions(-) create mode 100644 INPUT_VALIDATION_IMPLEMENTATION.md create mode 100644 dongle-smartcontract/VALIDATION_IMPLEMENTATION.md create mode 100644 dongle-smartcontract/src/tests/validation_tests.rs create mode 100644 dongle-smartcontract/src/validation.rs diff --git a/INPUT_VALIDATION_IMPLEMENTATION.md b/INPUT_VALIDATION_IMPLEMENTATION.md new file mode 100644 index 0000000..e69de29 diff --git a/dongle-smartcontract/VALIDATION_IMPLEMENTATION.md b/dongle-smartcontract/VALIDATION_IMPLEMENTATION.md new file mode 100644 index 0000000..e69de29 diff --git a/dongle-smartcontract/src/constants.rs b/dongle-smartcontract/src/constants.rs index 0abc2a8..6234473 100644 --- a/dongle-smartcontract/src/constants.rs +++ b/dongle-smartcontract/src/constants.rs @@ -28,8 +28,16 @@ pub const MAX_WEBSITE_LEN: usize = 256; #[allow(dead_code)] pub const MAX_CID_LEN: usize = 128; +/// Minimum length for CID validation. +#[allow(dead_code)] +pub const MIN_CID_LEN: usize = 10; + /// Valid rating range (inclusive). Reviews must be in [RATING_MIN, RATING_MAX]. u32 for Soroban Val. #[allow(dead_code)] pub const RATING_MIN: u32 = 1; #[allow(dead_code)] pub const RATING_MAX: u32 = 5; + +/// Maximum number of items that can be returned in a single pagination request. +#[allow(dead_code)] +pub const MAX_PAGINATION_LIMIT: u32 = 100; diff --git a/dongle-smartcontract/src/errors.rs b/dongle-smartcontract/src/errors.rs index b40b0eb..f483ced 100644 --- a/dongle-smartcontract/src/errors.rs +++ b/dongle-smartcontract/src/errors.rs @@ -41,6 +41,34 @@ pub enum ContractError { CannotRemoveLastAdmin = 17, /// Admin not found AdminNotFound = 18, + /// Invalid project name + InvalidProjectName = 19, + /// Invalid description + InvalidDescription = 20, + /// Description too long + DescriptionTooLong = 21, + /// Invalid category + InvalidCategory = 22, + /// Category too long + CategoryTooLong = 23, + /// Invalid category format + InvalidCategoryFormat = 24, + /// Invalid website URL + InvalidWebsiteUrl = 25, + /// Website URL too long + WebsiteUrlTooLong = 26, + /// Invalid website URL format + InvalidWebsiteUrlFormat = 27, + /// Invalid CID + InvalidCid = 28, + /// CID invalid length + CidInvalidLength = 29, + /// Invalid CID format + InvalidCidFormat = 30, + /// Invalid pagination limit + InvalidPaginationLimit = 31, + /// Pagination limit too large + PaginationLimitTooLarge = 32, } // Legacy alias to avoid breaking any code that uses `Error` directly diff --git a/dongle-smartcontract/src/lib.rs b/dongle-smartcontract/src/lib.rs index 87a9f48..8afff60 100644 --- a/dongle-smartcontract/src/lib.rs +++ b/dongle-smartcontract/src/lib.rs @@ -10,6 +10,7 @@ pub mod rating_calculator; pub mod review_registry; pub mod storage_keys; pub mod types; +pub mod validation; mod verification_registry; #[cfg(test)] diff --git a/dongle-smartcontract/src/project_registry.rs b/dongle-smartcontract/src/project_registry.rs index e4ce174..d772862 100644 --- a/dongle-smartcontract/src/project_registry.rs +++ b/dongle-smartcontract/src/project_registry.rs @@ -1,6 +1,7 @@ use crate::errors::ContractError; use crate::storage_keys::StorageKey; use crate::types::{Project, ProjectRegistrationParams, ProjectUpdateParams, VerificationStatus}; +use crate::validation; use soroban_sdk::{Address, Env, Vec}; pub struct ProjectRegistry; @@ -13,15 +14,13 @@ impl ProjectRegistry { ) -> Result { params.owner.require_auth(); - if params.name.is_empty() { - panic!("InvalidProjectName"); - } - if params.description.is_empty() { - panic!("InvalidProjectDescription"); - } - if params.category.is_empty() { - panic!("InvalidProjectCategory"); - } + // Validate all inputs + validation::validate_project_name(¶ms.name)?; + validation::validate_description(¶ms.description)?; + validation::validate_category(¶ms.category)?; + validation::validate_website(¶ms.website)?; + validation::validate_cid(¶ms.logo_cid)?; + validation::validate_cid(¶ms.metadata_cid)?; // Check if project name already exists if env @@ -85,23 +84,30 @@ impl ProjectRegistry { return None; } - if let Some(value) = params.name { - project.name = value; + // Validate updated fields + if let Some(ref value) = params.name { + validation::validate_project_name(value).ok()?; + project.name = value.clone(); } - if let Some(value) = params.description { - project.description = value; + if let Some(ref value) = params.description { + validation::validate_description(value).ok()?; + project.description = value.clone(); } - if let Some(value) = params.category { - project.category = value; + if let Some(ref value) = params.category { + validation::validate_category(value).ok()?; + project.category = value.clone(); } - if let Some(value) = params.website { - project.website = value; + if let Some(ref value) = params.website { + validation::validate_website(value).ok()?; + project.website = value.clone(); } - if let Some(value) = params.logo_cid { - project.logo_cid = value; + if let Some(ref value) = params.logo_cid { + validation::validate_cid(value).ok()?; + project.logo_cid = value.clone(); } - if let Some(value) = params.metadata_cid { - project.metadata_cid = value; + if let Some(ref value) = params.metadata_cid { + validation::validate_cid(value).ok()?; + project.metadata_cid = value.clone(); } project.updated_at = env.ledger().timestamp(); @@ -151,6 +157,11 @@ impl ProjectRegistry { } pub fn list_projects(env: &Env, start_id: u64, limit: u32) -> Vec { + // Validate pagination parameters + if validation::validate_pagination(limit).is_err() { + return Vec::new(env); + } + let count: u64 = env .storage() .persistent() @@ -178,92 +189,5 @@ impl ProjectRegistry { #[cfg(test)] mod tests { - use crate::errors::ContractError; - use crate::project_registry::ProjectRegistry; - use soroban_sdk::{Address, Env, String}; - - // Validation function only used in tests - fn validate_project_data( - name: &String, - _description: &String, - _category: &String, - ) -> Result<(), ContractError> { - extern crate alloc; - use alloc::string::ToString; - - let name_str = name.to_string(); - - // 1. Validate Non-empty and not only whitespace - if name_str.trim().is_empty() { - return Err(ContractError::InvalidProjectData); - } - - // 2. Validate max length using the CONSTANT - let max_len = crate::constants::MAX_NAME_LEN; - if name_str.len() > max_len { - return Err(ContractError::ProjectNameTooLong); - } - - // 3. Validate alphanumeric, underscore, hyphen - for c in name_str.chars() { - if !c.is_ascii_alphanumeric() && c != '_' && c != '-' { - return Err(ContractError::InvalidProjectNameFormat); - } - } - - Ok(()) - } - - #[test] - fn test_valid_project_name() { - let env = Env::default(); - let name = String::from_str(&env, "Valid-Project_Name123"); - - let result = validate_project_data( - &name, - &String::from_str(&env, "Desc"), - &String::from_str(&env, "Cat"), - ); - assert!(result.is_ok()); - } - - #[test] - fn test_empty_or_whitespace_name() { - let env = Env::default(); - let name = String::from_str(&env, " "); - - let result = validate_project_data( - &name, - &String::from_str(&env, "Desc"), - &String::from_str(&env, "Cat"), - ); - assert_eq!(result, Err(ContractError::InvalidProjectData)); - } - - #[test] - fn test_invalid_characters_in_name() { - let env = Env::default(); - let name = String::from_str(&env, "My Project *"); - - let result = validate_project_data( - &name, - &String::from_str(&env, "Desc"), - &String::from_str(&env, "Cat"), - ); - assert_eq!(result, Err(ContractError::InvalidProjectNameFormat)); - } - - #[test] - fn test_name_too_long() { - let env = Env::default(); - // 51 characters - let name = String::from_str(&env, "ThisProjectNameIsWayTooLongAndExceedsTheFiftyCharL1"); - - let result = validate_project_data( - &name, - &String::from_str(&env, "Desc"), - &String::from_str(&env, "Cat"), - ); - assert_eq!(result, Err(ContractError::ProjectNameTooLong)); - } + // Tests moved to validation module } diff --git a/dongle-smartcontract/src/review_registry.rs b/dongle-smartcontract/src/review_registry.rs index 0822ab3..59fd6c4 100644 --- a/dongle-smartcontract/src/review_registry.rs +++ b/dongle-smartcontract/src/review_registry.rs @@ -1,11 +1,11 @@ //! Review registry: create/update/delete reviews and maintain aggregates and indexes. -use crate::constants::{RATING_MAX, RATING_MIN}; use crate::errors::ContractError; use crate::events::publish_review_event; use crate::rating_calculator::RatingCalculator; use crate::storage_keys::StorageKey; use crate::types::{ProjectStats, Review, ReviewAction}; +use crate::validation; use soroban_sdk::{Address, Env, String, Vec}; pub struct ReviewRegistry; @@ -20,9 +20,9 @@ impl ReviewRegistry { ) -> Result<(), ContractError> { reviewer.require_auth(); - if !(RATING_MIN..=RATING_MAX).contains(&rating) { - return Err(ContractError::InvalidRating); - } + // Validate inputs + validation::validate_rating(rating)?; + validation::validate_cid(&comment_cid)?; let review_key = StorageKey::Review(project_id, reviewer.clone()); if env.storage().persistent().has(&review_key) { @@ -107,9 +107,9 @@ impl ReviewRegistry { ) -> Result<(), ContractError> { reviewer.require_auth(); - if !(RATING_MIN..=RATING_MAX).contains(&rating) { - return Err(ContractError::InvalidRating); - } + // Validate inputs + validation::validate_rating(rating)?; + validation::validate_cid(&comment_cid)?; let review_key = StorageKey::Review(project_id, reviewer.clone()); let mut review: Review = env @@ -266,6 +266,11 @@ impl ReviewRegistry { } pub fn list_reviews(env: &Env, project_id: u64, start_id: u32, limit: u32) -> Vec { + // Validate pagination + if validation::validate_pagination(limit).is_err() { + return Vec::new(env); + } + let reviewers: Vec
= env .storage() .persistent() diff --git a/dongle-smartcontract/src/tests/mod.rs b/dongle-smartcontract/src/tests/mod.rs index d069e02..6ac9344 100644 --- a/dongle-smartcontract/src/tests/mod.rs +++ b/dongle-smartcontract/src/tests/mod.rs @@ -3,6 +3,7 @@ // Existing test modules mod admin; mod registration; +mod validation_tests; mod verification; // Test infrastructure diff --git a/dongle-smartcontract/src/tests/validation_tests.rs b/dongle-smartcontract/src/tests/validation_tests.rs new file mode 100644 index 0000000..0acfdf3 --- /dev/null +++ b/dongle-smartcontract/src/tests/validation_tests.rs @@ -0,0 +1,396 @@ +//! Integration tests for input validation across all modules + +use crate::errors::ContractError; +use crate::tests::fixtures::{create_test_env, register_test_project}; +use crate::types::ProjectRegistrationParams; +use crate::DongleContract; +use soroban_sdk::{testutils::Address as _, Address, Env, String}; + +// ── Project Registration Validation Tests ── + +#[test] +fn test_register_project_with_valid_inputs() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Valid Project"), + description: String::from_str(&env, "A valid project description"), + category: String::from_str(&env, "DeFi"), + website: Some(String::from_str(&env, "https://example.com")), + logo_cid: Some(String::from_str( + &env, + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + )), + metadata_cid: Some(String::from_str( + &env, + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + )), + }; + + let result = DongleContract::register_project(env.clone(), params); + assert!(result.is_ok()); +} + +#[test] +#[should_panic(expected = "InvalidProjectName")] +fn test_register_project_empty_name() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, ""), + description: String::from_str(&env, "Description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "InvalidProjectName")] +fn test_register_project_whitespace_only_name() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, " "), + description: String::from_str(&env, "Description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "ProjectNameTooLong")] +fn test_register_project_name_too_long() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + // 51 characters + let long_name = "ThisProjectNameIsWayTooLongAndExceedsTheFiftyCharL1"; + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, long_name), + description: String::from_str(&env, "Description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "InvalidProjectNameFormat")] +fn test_register_project_name_invalid_characters() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "Project@Name!"), + description: String::from_str(&env, "Description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "InvalidDescription")] +fn test_register_project_empty_description() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "ValidName"), + description: String::from_str(&env, ""), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "InvalidCategory")] +fn test_register_project_empty_category() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "ValidName"), + description: String::from_str(&env, "Valid description"), + category: String::from_str(&env, ""), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "InvalidWebsiteUrlFormat")] +fn test_register_project_invalid_website_protocol() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "ValidName"), + description: String::from_str(&env, "Valid description"), + category: String::from_str(&env, "DeFi"), + website: Some(String::from_str(&env, "ftp://example.com")), + logo_cid: None, + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "CidInvalidLength")] +fn test_register_project_invalid_cid_too_short() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "ValidName"), + description: String::from_str(&env, "Valid description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: Some(String::from_str(&env, "short")), + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +#[test] +#[should_panic(expected = "InvalidCidFormat")] +fn test_register_project_invalid_cid_format() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, "ValidName"), + description: String::from_str(&env, "Valid description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: Some(String::from_str(&env, "Qm@#$%^&*()_+invalid")), + metadata_cid: None, + }; + + let _ = DongleContract::register_project(env.clone(), params); +} + +// ── Review Validation Tests ── + +#[test] +fn test_add_review_with_valid_rating() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let reviewer = Address::generate(&env); + let result = DongleContract::add_review( + env.clone(), + project_id, + reviewer.clone(), + 5, + Some(String::from_str( + &env, + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + )), + ); + + assert!(result.is_ok()); +} + +#[test] +fn test_add_review_rating_too_low() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let reviewer = Address::generate(&env); + let result = DongleContract::add_review(env.clone(), project_id, reviewer.clone(), 0, None); + + assert_eq!(result, Err(ContractError::InvalidRating)); +} + +#[test] +fn test_add_review_rating_too_high() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let reviewer = Address::generate(&env); + let result = DongleContract::add_review(env.clone(), project_id, reviewer.clone(), 6, None); + + assert_eq!(result, Err(ContractError::InvalidRating)); +} + +#[test] +fn test_add_review_invalid_comment_cid() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let reviewer = Address::generate(&env); + let result = DongleContract::add_review( + env.clone(), + project_id, + reviewer.clone(), + 5, + Some(String::from_str(&env, "invalid@cid")), + ); + + assert_eq!(result, Err(ContractError::InvalidCidFormat)); +} + +// ── Pagination Validation Tests ── + +#[test] +fn test_list_projects_with_valid_limit() { + let env = Env::default(); + env.mock_all_auths(); + + let projects = DongleContract::list_projects(env.clone(), 1, 10); + // Should not panic, returns empty vec if no projects + assert_eq!(projects.len(), 0); +} + +#[test] +fn test_list_projects_with_zero_limit() { + let env = Env::default(); + env.mock_all_auths(); + + // Should return empty vec due to validation failure + let projects = DongleContract::list_projects(env.clone(), 1, 0); + assert_eq!(projects.len(), 0); +} + +#[test] +fn test_list_projects_with_excessive_limit() { + let env = Env::default(); + env.mock_all_auths(); + + // Should return empty vec due to validation failure (limit > 100) + let projects = DongleContract::list_projects(env.clone(), 1, 101); + assert_eq!(projects.len(), 0); +} + +#[test] +fn test_list_reviews_with_valid_limit() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let reviews = DongleContract::list_reviews(env.clone(), project_id, 0, 10); + assert_eq!(reviews.len(), 0); +} + +#[test] +fn test_list_reviews_with_zero_limit() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let reviews = DongleContract::list_reviews(env.clone(), project_id, 0, 0); + assert_eq!(reviews.len(), 0); +} + +// ── Verification Evidence CID Tests ── + +#[test] +#[should_panic(expected = "InvalidCid")] +fn test_request_verification_empty_evidence_cid() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let _ = DongleContract::request_verification( + env.clone(), + project_id, + owner.clone(), + String::from_str(&env, ""), + ); +} + +#[test] +#[should_panic(expected = "CidInvalidLength")] +fn test_request_verification_evidence_cid_too_short() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + let _ = DongleContract::request_verification( + env.clone(), + project_id, + owner.clone(), + String::from_str(&env, "short"), + ); +} + +// ── Edge Case Tests ── + +#[test] +fn test_project_name_at_max_length() { + let env = Env::default(); + env.mock_all_auths(); + + let owner = Address::generate(&env); + // Exactly 50 characters + let name = "12345678901234567890123456789012345678901234567890"; + let params = ProjectRegistrationParams { + owner: owner.clone(), + name: String::from_str(&env, name), + description: String::from_str(&env, "Description"), + category: String::from_str(&env, "DeFi"), + website: None, + logo_cid: None, + metadata_cid: None, + }; + + let result = DongleContract::register_project(env.clone(), params); + assert!(result.is_ok()); +} + +#[test] +fn test_all_valid_ratings() { + let (env, admin, owner) = create_test_env(); + let project_id = register_test_project(&env, &owner); + + // Test all valid ratings (1-5) + for rating in 1..=5 { + let reviewer = Address::generate(&env); + let result = + DongleContract::add_review(env.clone(), project_id, reviewer.clone(), rating, None); + assert!(result.is_ok(), "Rating {} should be valid", rating); + } +} diff --git a/dongle-smartcontract/src/validation.rs b/dongle-smartcontract/src/validation.rs new file mode 100644 index 0000000..29e4278 --- /dev/null +++ b/dongle-smartcontract/src/validation.rs @@ -0,0 +1,481 @@ +//! Input validation module for all user-provided data. +//! Ensures data integrity and prevents abuse through strict validation rules. + +use crate::constants::*; +use crate::errors::ContractError; +use soroban_sdk::String; + +/// Validates project name according to defined rules: +/// - Non-empty after trimming whitespace +/// - Length between MIN_STRING_LEN and MAX_NAME_LEN +/// - Contains only alphanumeric characters, underscores, and hyphens +pub fn validate_project_name(name: &String) -> Result<(), ContractError> { + extern crate alloc; + use alloc::string::ToString; + + let name_str = name.to_string(); + let trimmed = name_str.trim(); + + // Check non-empty + if trimmed.is_empty() { + return Err(ContractError::InvalidProjectName); + } + + // Check length + if trimmed.len() < MIN_STRING_LEN || trimmed.len() > MAX_NAME_LEN { + return Err(ContractError::ProjectNameTooLong); + } + + // Check format: alphanumeric, underscore, hyphen, space + for c in trimmed.chars() { + if !c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != ' ' { + return Err(ContractError::InvalidProjectNameFormat); + } + } + + Ok(()) +} + +/// Validates project description: +/// - Non-empty after trimming +/// - Length between MIN_STRING_LEN and MAX_DESCRIPTION_LEN +pub fn validate_description(description: &String) -> Result<(), ContractError> { + extern crate alloc; + use alloc::string::ToString; + + let desc_str = description.to_string(); + let trimmed = desc_str.trim(); + + if trimmed.is_empty() { + return Err(ContractError::InvalidDescription); + } + + if trimmed.len() < MIN_STRING_LEN || trimmed.len() > MAX_DESCRIPTION_LEN { + return Err(ContractError::DescriptionTooLong); + } + + Ok(()) +} + +/// Validates project category: +/// - Non-empty after trimming +/// - Length between MIN_STRING_LEN and MAX_CATEGORY_LEN +/// - Contains only alphanumeric characters, underscores, hyphens, and spaces +pub fn validate_category(category: &String) -> Result<(), ContractError> { + extern crate alloc; + use alloc::string::ToString; + + let cat_str = category.to_string(); + let trimmed = cat_str.trim(); + + if trimmed.is_empty() { + return Err(ContractError::InvalidCategory); + } + + if trimmed.len() < MIN_STRING_LEN || trimmed.len() > MAX_CATEGORY_LEN { + return Err(ContractError::CategoryTooLong); + } + + // Allow alphanumeric, underscore, hyphen, space + for c in trimmed.chars() { + if !c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != ' ' { + return Err(ContractError::InvalidCategoryFormat); + } + } + + Ok(()) +} + +/// Validates website URL: +/// - If provided, must be non-empty +/// - Length must not exceed MAX_WEBSITE_LEN +/// - Must start with http:// or https:// +pub fn validate_website(website: &Option) -> Result<(), ContractError> { + extern crate alloc; + use alloc::string::ToString; + + if let Some(url) = website { + let url_str = url.to_string(); + let trimmed = url_str.trim(); + + if trimmed.is_empty() { + return Err(ContractError::InvalidWebsiteUrl); + } + + if trimmed.len() > MAX_WEBSITE_LEN { + return Err(ContractError::WebsiteUrlTooLong); + } + + // Basic URL format check + if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") { + return Err(ContractError::InvalidWebsiteUrlFormat); + } + } + + Ok(()) +} + +/// Validates CID (Content Identifier) for IPFS/similar systems: +/// - If provided, must be non-empty +/// - Length must be between MIN_CID_LEN and MAX_CID_LEN +/// - Contains only alphanumeric characters (base58/base32 encoding) +pub fn validate_cid(cid: &Option) -> Result<(), ContractError> { + extern crate alloc; + use alloc::string::ToString; + + if let Some(cid_val) = cid { + let cid_str = cid_val.to_string(); + let trimmed = cid_str.trim(); + + if trimmed.is_empty() { + return Err(ContractError::InvalidCid); + } + + if trimmed.len() < MIN_CID_LEN || trimmed.len() > MAX_CID_LEN { + return Err(ContractError::CidInvalidLength); + } + + // CIDs are typically base58 or base32 encoded (alphanumeric) + for c in trimmed.chars() { + if !c.is_ascii_alphanumeric() { + return Err(ContractError::InvalidCidFormat); + } + } + } + + Ok(()) +} + +/// Validates rating value: +/// - Must be between RATING_MIN and RATING_MAX (inclusive) +pub fn validate_rating(rating: u32) -> Result<(), ContractError> { + if !(RATING_MIN..=RATING_MAX).contains(&rating) { + return Err(ContractError::InvalidRating); + } + Ok(()) +} + +/// Validates pagination parameters: +/// - Limit must be greater than 0 +/// - Limit must not exceed MAX_PAGINATION_LIMIT +pub fn validate_pagination(limit: u32) -> Result<(), ContractError> { + if limit == 0 { + return Err(ContractError::InvalidPaginationLimit); + } + + if limit > MAX_PAGINATION_LIMIT { + return Err(ContractError::PaginationLimitTooLarge); + } + + Ok(()) +} + +/// Validates evidence CID for verification requests: +/// - Must be non-empty +/// - Length must be between MIN_CID_LEN and MAX_CID_LEN +/// - Contains only alphanumeric characters +pub fn validate_evidence_cid(evidence_cid: &String) -> Result<(), ContractError> { + extern crate alloc; + use alloc::string::ToString; + + let cid_str = evidence_cid.to_string(); + let trimmed = cid_str.trim(); + + if trimmed.is_empty() { + return Err(ContractError::InvalidCid); + } + + if trimmed.len() < MIN_CID_LEN || trimmed.len() > MAX_CID_LEN { + return Err(ContractError::CidInvalidLength); + } + + for c in trimmed.chars() { + if !c.is_ascii_alphanumeric() { + return Err(ContractError::InvalidCidFormat); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{Env, String}; + + // ── Project Name Tests ── + + #[test] + fn test_valid_project_name() { + let env = Env::default(); + let name = String::from_str(&env, "Valid-Project_Name123"); + assert!(validate_project_name(&name).is_ok()); + } + + #[test] + fn test_project_name_with_spaces() { + let env = Env::default(); + let name = String::from_str(&env, "My Project Name"); + assert!(validate_project_name(&name).is_ok()); + } + + #[test] + fn test_empty_project_name() { + let env = Env::default(); + let name = String::from_str(&env, ""); + assert_eq!( + validate_project_name(&name), + Err(ContractError::InvalidProjectName) + ); + } + + #[test] + fn test_whitespace_only_project_name() { + let env = Env::default(); + let name = String::from_str(&env, " "); + assert_eq!( + validate_project_name(&name), + Err(ContractError::InvalidProjectName) + ); + } + + #[test] + fn test_project_name_too_long() { + let env = Env::default(); + // 51 characters + let name = String::from_str(&env, "ThisProjectNameIsWayTooLongAndExceedsTheFiftyCharL1"); + assert_eq!( + validate_project_name(&name), + Err(ContractError::ProjectNameTooLong) + ); + } + + #[test] + fn test_project_name_invalid_characters() { + let env = Env::default(); + let name = String::from_str(&env, "Project@Name!"); + assert_eq!( + validate_project_name(&name), + Err(ContractError::InvalidProjectNameFormat) + ); + } + + // ── Description Tests ── + + #[test] + fn test_valid_description() { + let env = Env::default(); + let desc = String::from_str(&env, "This is a valid project description."); + assert!(validate_description(&desc).is_ok()); + } + + #[test] + fn test_empty_description() { + let env = Env::default(); + let desc = String::from_str(&env, ""); + assert_eq!( + validate_description(&desc), + Err(ContractError::InvalidDescription) + ); + } + + #[test] + fn test_description_too_long() { + let env = Env::default(); + let long_desc = "a".repeat(MAX_DESCRIPTION_LEN + 1); + let desc = String::from_str(&env, &long_desc); + assert_eq!( + validate_description(&desc), + Err(ContractError::DescriptionTooLong) + ); + } + + // ── Category Tests ── + + #[test] + fn test_valid_category() { + let env = Env::default(); + let cat = String::from_str(&env, "DeFi"); + assert!(validate_category(&cat).is_ok()); + } + + #[test] + fn test_category_with_spaces() { + let env = Env::default(); + let cat = String::from_str(&env, "Decentralized Finance"); + assert!(validate_category(&cat).is_ok()); + } + + #[test] + fn test_empty_category() { + let env = Env::default(); + let cat = String::from_str(&env, ""); + assert_eq!( + validate_category(&cat), + Err(ContractError::InvalidCategory) + ); + } + + #[test] + fn test_category_too_long() { + let env = Env::default(); + let long_cat = "a".repeat(MAX_CATEGORY_LEN + 1); + let cat = String::from_str(&env, &long_cat); + assert_eq!( + validate_category(&cat), + Err(ContractError::CategoryTooLong) + ); + } + + // ── Website Tests ── + + #[test] + fn test_valid_website() { + let env = Env::default(); + let url = Some(String::from_str(&env, "https://example.com")); + assert!(validate_website(&url).is_ok()); + } + + #[test] + fn test_website_http() { + let env = Env::default(); + let url = Some(String::from_str(&env, "http://example.com")); + assert!(validate_website(&url).is_ok()); + } + + #[test] + fn test_website_none() { + assert!(validate_website(&None).is_ok()); + } + + #[test] + fn test_website_invalid_protocol() { + let env = Env::default(); + let url = Some(String::from_str(&env, "ftp://example.com")); + assert_eq!( + validate_website(&url), + Err(ContractError::InvalidWebsiteUrlFormat) + ); + } + + #[test] + fn test_website_too_long() { + let env = Env::default(); + let long_url = format!("https://{}.com", "a".repeat(MAX_WEBSITE_LEN)); + let url = Some(String::from_str(&env, &long_url)); + assert_eq!( + validate_website(&url), + Err(ContractError::WebsiteUrlTooLong) + ); + } + + // ── CID Tests ── + + #[test] + fn test_valid_cid() { + let env = Env::default(); + let cid = Some(String::from_str( + &env, + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", + )); + assert!(validate_cid(&cid).is_ok()); + } + + #[test] + fn test_cid_none() { + assert!(validate_cid(&None).is_ok()); + } + + #[test] + fn test_cid_too_short() { + let env = Env::default(); + let cid = Some(String::from_str(&env, "short")); + assert_eq!(validate_cid(&cid), Err(ContractError::CidInvalidLength)); + } + + #[test] + fn test_cid_too_long() { + let env = Env::default(); + let long_cid = "a".repeat(MAX_CID_LEN + 1); + let cid = Some(String::from_str(&env, &long_cid)); + assert_eq!(validate_cid(&cid), Err(ContractError::CidInvalidLength)); + } + + #[test] + fn test_cid_invalid_characters() { + let env = Env::default(); + let cid = Some(String::from_str( + &env, + "Qm@#$%^&*()_+{}|:<>?~`-=[]\\;',./", + )); + assert_eq!(validate_cid(&cid), Err(ContractError::InvalidCidFormat)); + } + + // ── Rating Tests ── + + #[test] + fn test_valid_ratings() { + for rating in RATING_MIN..=RATING_MAX { + assert!(validate_rating(rating).is_ok()); + } + } + + #[test] + fn test_rating_too_low() { + assert_eq!( + validate_rating(RATING_MIN - 1), + Err(ContractError::InvalidRating) + ); + } + + #[test] + fn test_rating_too_high() { + assert_eq!( + validate_rating(RATING_MAX + 1), + Err(ContractError::InvalidRating) + ); + } + + // ── Pagination Tests ── + + #[test] + fn test_valid_pagination() { + assert!(validate_pagination(10).is_ok()); + assert!(validate_pagination(MAX_PAGINATION_LIMIT).is_ok()); + } + + #[test] + fn test_pagination_zero() { + assert_eq!( + validate_pagination(0), + Err(ContractError::InvalidPaginationLimit) + ); + } + + #[test] + fn test_pagination_too_large() { + assert_eq!( + validate_pagination(MAX_PAGINATION_LIMIT + 1), + Err(ContractError::PaginationLimitTooLarge) + ); + } + + // ── Evidence CID Tests ── + + #[test] + fn test_valid_evidence_cid() { + let env = Env::default(); + let cid = String::from_str(&env, "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG"); + assert!(validate_evidence_cid(&cid).is_ok()); + } + + #[test] + fn test_evidence_cid_empty() { + let env = Env::default(); + let cid = String::from_str(&env, ""); + assert_eq!( + validate_evidence_cid(&cid), + Err(ContractError::InvalidCid) + ); + } +} diff --git a/dongle-smartcontract/src/verification_registry.rs b/dongle-smartcontract/src/verification_registry.rs index a41e08f..950b72c 100644 --- a/dongle-smartcontract/src/verification_registry.rs +++ b/dongle-smartcontract/src/verification_registry.rs @@ -10,6 +10,7 @@ use crate::fee_manager::FeeManager; use crate::project_registry::ProjectRegistry; use crate::storage_keys::StorageKey; use crate::types::{VerificationRecord, VerificationStatus}; +use crate::validation; use soroban_sdk::{Address, Env, String}; pub struct VerificationRegistry; @@ -41,8 +42,8 @@ impl VerificationRegistry { // 3. Consume fee payment FeeManager::consume_fee_payment(env, project_id)?; - // 4. Validate evidence - Self::validate_evidence_cid(&evidence_cid)?; + // 4. Validate evidence CID + validation::validate_evidence_cid(&evidence_cid)?; // 5. Create record let config = FeeManager::get_fee_config(env)?; @@ -157,13 +158,6 @@ impl VerificationRegistry { .ok_or(ContractError::VerificationNotFound) } - pub fn validate_evidence_cid(evidence_cid: &String) -> Result<(), ContractError> { - if evidence_cid.is_empty() { - return Err(ContractError::InvalidProjectData); - } - Ok(()) - } - #[allow(dead_code)] pub fn verification_exists(env: &Env, project_id: u64) -> bool { env.storage()