lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 1cd2fc9d4321c9878f922948e031fa6de6533660
parent 9087393c01727e730129c65a4f73a81791c24274
Author: triesap <tyson@radroots.org>
Date:   Mon, 22 Dec 2025 16:28:15 +0000

events-indexed: harden validation and add manifest/id range tests

Diffstat:
Mevents-indexed/src/checkpoint.rs | 4++--
Mevents-indexed/src/manifest.rs | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mevents-indexed/src/serde_ext.rs | 25+++++++++++++++++++++++++
Mevents-indexed/src/types.rs | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
4 files changed, 201 insertions(+), 8 deletions(-)

diff --git a/events-indexed/src/checkpoint.rs b/events-indexed/src/checkpoint.rs @@ -4,7 +4,7 @@ use alloc::{string::String, vec::Vec}; use crate::types::RadrootsEventsIndexedShardId; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventsIndexedShardCheckpoint { @@ -18,7 +18,7 @@ pub struct RadrootsEventsIndexedShardCheckpoint { pub cursor: Option<String>, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventsIndexedIndexCheckpoint { diff --git a/events-indexed/src/manifest.rs b/events-indexed/src/manifest.rs @@ -3,7 +3,7 @@ use alloc::{string::String, vec::Vec}; use core::fmt; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventsIndexedShardMetadata { @@ -16,7 +16,7 @@ pub struct RadrootsEventsIndexedShardMetadata { pub sha256: String, } -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventsIndexedManifest { @@ -77,8 +77,117 @@ pub fn validate_manifest( } sum += s.count as u64; } - if sum as u32 != m.total { + if sum > u32::MAX as u64 || sum != m.total as u64 { return Err(RadrootsEventsIndexedManifestError::InconsistentTotals); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + validate_manifest, RadrootsEventsIndexedManifest, RadrootsEventsIndexedManifestError, + RadrootsEventsIndexedShardMetadata, + }; + #[cfg(not(feature = "std"))] + use alloc::{string::String, vec, vec::Vec}; + #[cfg(feature = "std")] + use std::{string::String, vec::Vec}; + + fn shard(file: &str, count: u32, sha256: &str) -> RadrootsEventsIndexedShardMetadata { + RadrootsEventsIndexedShardMetadata { + file: String::from(file), + count, + first_id: String::from("a"), + last_id: String::from("b"), + first_published_at: 0, + last_published_at: 0, + sha256: String::from(sha256), + } + } + + fn base_manifest() -> RadrootsEventsIndexedManifest { + RadrootsEventsIndexedManifest { + country: String::from("us"), + total: 1, + shard_size: 1, + first_published_at: 0, + last_published_at: 0, + shards: vec![shard( + "a.json", + 1, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + )], + } + } + + #[test] + fn validate_manifest_rejects_empty_country() { + let mut m = base_manifest(); + m.country = String::from(" "); + let err = validate_manifest(&m).unwrap_err(); + assert_eq!(err, RadrootsEventsIndexedManifestError::EmptyCountry); + } + + #[test] + fn validate_manifest_rejects_empty_shards() { + let mut m = base_manifest(); + m.shards = Vec::new(); + let err = validate_manifest(&m).unwrap_err(); + assert_eq!(err, RadrootsEventsIndexedManifestError::EmptyShards); + } + + #[test] + fn validate_manifest_rejects_empty_file() { + let mut m = base_manifest(); + m.shards[0].file = String::from(""); + let err = validate_manifest(&m).unwrap_err(); + assert_eq!(err, RadrootsEventsIndexedManifestError::EmptyFile(0)); + } + + #[test] + fn validate_manifest_rejects_invalid_sha256() { + let mut m = base_manifest(); + m.shards[0].sha256 = String::from("zz"); + let err = validate_manifest(&m).unwrap_err(); + assert_eq!(err, RadrootsEventsIndexedManifestError::InvalidSha256(0)); + } + + #[test] + fn validate_manifest_rejects_total_overflow() { + let m = RadrootsEventsIndexedManifest { + country: String::from("us"), + total: 1, + shard_size: 1, + first_published_at: 0, + last_published_at: 0, + shards: vec![ + RadrootsEventsIndexedShardMetadata { + file: String::from("a.json"), + count: u32::MAX, + first_id: String::from("a"), + last_id: String::from("b"), + first_published_at: 0, + last_published_at: 0, + sha256: String::from( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ), + }, + RadrootsEventsIndexedShardMetadata { + file: String::from("b.json"), + count: 1, + first_id: String::from("c"), + last_id: String::from("d"), + first_published_at: 0, + last_published_at: 0, + sha256: String::from( + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + ), + }, + ], + }; + + let err = validate_manifest(&m).unwrap_err(); + assert_eq!(err, RadrootsEventsIndexedManifestError::InconsistentTotals); + } +} diff --git a/events-indexed/src/serde_ext.rs b/events-indexed/src/serde_ext.rs @@ -15,3 +15,28 @@ pub mod epoch_seconds { Ok(v as u32) } } + +#[cfg(all(test, feature = "serde"))] +mod tests { + use super::epoch_seconds; + use serde::de::value::{Error as DeError, U64Deserializer}; + #[cfg(not(feature = "std"))] + use alloc::string::ToString; + #[cfg(feature = "std")] + use std::string::ToString; + + #[test] + fn epoch_seconds_accepts_u32_max() { + let de = U64Deserializer::<DeError>::new(u32::MAX as u64); + let val = epoch_seconds::de(de).unwrap(); + assert_eq!(val, u32::MAX); + } + + #[test] + fn epoch_seconds_rejects_overflow() { + let de = U64Deserializer::<DeError>::new(u32::MAX as u64 + 1); + let err = epoch_seconds::de(de).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("epoch **seconds**")); + } +} diff --git a/events-indexed/src/types.rs b/events-indexed/src/types.rs @@ -1,12 +1,12 @@ #[cfg(not(feature = "std"))] use alloc::string::String; -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct RadrootsEventsIndexedShardId(pub String); -#[typeshare::typeshare] +#[cfg_attr(feature = "typeshare", typeshare::typeshare)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsEventsIndexedIdRange { @@ -16,6 +16,65 @@ pub struct RadrootsEventsIndexedIdRange { impl RadrootsEventsIndexedIdRange { pub fn is_valid(&self) -> bool { - !self.start.is_empty() && !self.end.is_empty() && self.start <= self.end + if self.start.is_empty() || self.end.is_empty() { + return false; + } + if self.start.len() != self.end.len() { + return false; + } + if !self + .start + .chars() + .all(|c| c.is_ascii_hexdigit()) + || !self.end.chars().all(|c| c.is_ascii_hexdigit()) + { + return false; + } + self.start <= self.end + } +} + +#[cfg(test)] +mod tests { + use super::RadrootsEventsIndexedIdRange; + #[cfg(not(feature = "std"))] + use alloc::string::String; + #[cfg(feature = "std")] + use std::string::String; + + #[test] + fn id_range_rejects_non_hex() { + let range = RadrootsEventsIndexedIdRange { + start: String::from("zz"), + end: String::from("ff"), + }; + assert!(!range.is_valid()); + } + + #[test] + fn id_range_rejects_mismatched_length() { + let range = RadrootsEventsIndexedIdRange { + start: String::from("0a"), + end: String::from("0aa"), + }; + assert!(!range.is_valid()); + } + + #[test] + fn id_range_rejects_reverse_order() { + let range = RadrootsEventsIndexedIdRange { + start: String::from("0f"), + end: String::from("0a"), + }; + assert!(!range.is_valid()); + } + + #[test] + fn id_range_accepts_hex_order() { + let range = RadrootsEventsIndexedIdRange { + start: String::from("0a"), + end: String::from("0f"), + }; + assert!(range.is_valid()); } }