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:
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());
}
}