commit b57c36486e44f6b4ed9ad6e754e6fca0a9ceea11
parent 5169fade97d4ed9cdb5ce690b85d4344e8082a82
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 00:38:29 +0000
service-host: add clock and entropy contracts
- separate wall and monotonic clock capabilities
- model checked monotonic deadline comparisons
- provide instance-local production clock adapters
- inject typed operating-system entropy with failure tests
Diffstat:
6 files changed, 339 insertions(+), 3 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -3878,6 +3878,9 @@ dependencies = [
[[package]]
name = "radroots_service_host"
version = "0.1.0-alpha"
+dependencies = [
+ "getrandom 0.2.17",
+]
[[package]]
name = "radroots_service_sqlite"
diff --git a/crates/service_host/Cargo.toml b/crates/service_host/Cargo.toml
@@ -12,6 +12,7 @@ homepage.workspace = true
readme = "README.md"
[dependencies]
+getrandom = { workspace = true }
[lints]
workspace = true
diff --git a/crates/service_host/src/entropy.rs b/crates/service_host/src/entropy.rs
@@ -0,0 +1,67 @@
+//! Injected entropy contract and production operating-system adapter.
+
+use core::fmt;
+use std::error::Error;
+
+/// Failure to obtain cryptographically secure host entropy.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EntropyError {
+ Unavailable,
+}
+
+impl fmt::Display for EntropyError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("host entropy is unavailable")
+ }
+}
+
+impl Error for EntropyError {}
+
+/// Injected source of cryptographically secure bytes.
+pub trait EntropySource: Send + Sync {
+ /// Fills the complete destination or returns an error without partial success.
+ fn fill_bytes(&self, destination: &mut [u8]) -> Result<(), EntropyError>;
+}
+
+/// Production entropy source backed by the operating system.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct SystemEntropy;
+
+impl EntropySource for SystemEntropy {
+ fn fill_bytes(&self, destination: &mut [u8]) -> Result<(), EntropyError> {
+ getrandom::getrandom(destination).map_err(|_| EntropyError::Unavailable)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ struct FailingEntropy;
+
+ impl EntropySource for FailingEntropy {
+ fn fill_bytes(&self, _destination: &mut [u8]) -> Result<(), EntropyError> {
+ Err(EntropyError::Unavailable)
+ }
+ }
+
+ #[test]
+ fn injected_entropy_errors_are_typed_and_safe() {
+ let mut destination = [0_u8; 16];
+ let error = FailingEntropy
+ .fill_bytes(&mut destination)
+ .expect_err("entropy failure");
+
+ assert_eq!(error, EntropyError::Unavailable);
+ assert_eq!(error.to_string(), "host entropy is unavailable");
+ assert_eq!(destination, [0; 16]);
+ }
+
+ #[test]
+ fn production_entropy_adapter_smoke_test() {
+ let mut destination = [0_u8; 32];
+ SystemEntropy
+ .fill_bytes(&mut destination)
+ .expect("operating-system entropy");
+ }
+}
diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs
@@ -2,6 +2,13 @@
//! Reusable, service-neutral host mechanics for Radroots services.
+pub mod entropy;
pub mod error;
+pub mod time;
+pub use entropy::{EntropyError, EntropySource, SystemEntropy};
pub use error::{HostError, HostErrorCode, HostErrorKind, SafeHostError};
+pub use time::{
+ MonotonicClock, MonotonicClockError, MonotonicDeadline, MonotonicTime, SystemMonotonicClock,
+ SystemWallClock, UnixTimeSeconds, WallClock, WallClockError,
+};
diff --git a/crates/service_host/src/time.rs b/crates/service_host/src/time.rs
@@ -0,0 +1,255 @@
+//! Injected wall and monotonic clock contracts.
+
+use core::fmt;
+use std::{error::Error, time::Duration};
+
+/// A nonnegative whole-second UTC timestamp relative to the Unix epoch.
+#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub struct UnixTimeSeconds(u64);
+
+impl UnixTimeSeconds {
+ /// Creates an already validated Unix timestamp.
+ #[must_use]
+ pub const fn new(seconds: u64) -> Self {
+ Self(seconds)
+ }
+
+ /// Returns whole seconds since the Unix epoch.
+ #[must_use]
+ pub const fn get(self) -> u64 {
+ self.0
+ }
+}
+
+/// A process-local monotonic observation relative to one clock origin.
+#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub struct MonotonicTime(Duration);
+
+impl MonotonicTime {
+ /// Creates a monotonic observation from a clock-relative duration.
+ #[must_use]
+ pub const fn from_duration_since_origin(elapsed: Duration) -> Self {
+ Self(elapsed)
+ }
+
+ /// Returns the clock-relative duration.
+ #[must_use]
+ pub const fn duration_since_origin(self) -> Duration {
+ self.0
+ }
+
+ /// Computes a deadline without wrapping on duration overflow.
+ pub fn checked_deadline_after(
+ self,
+ duration: Duration,
+ ) -> Result<MonotonicDeadline, MonotonicClockError> {
+ self.0
+ .checked_add(duration)
+ .map(|elapsed| MonotonicDeadline(Self(elapsed)))
+ .ok_or(MonotonicClockError::DeadlineOverflow)
+ }
+}
+
+/// A deadline in the same process-local clock domain as `MonotonicTime`.
+#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
+pub struct MonotonicDeadline(MonotonicTime);
+
+impl MonotonicDeadline {
+ /// Returns true when the supplied observation reaches or passes the deadline.
+ #[must_use]
+ pub fn is_reached_at(self, now: MonotonicTime) -> bool {
+ now.0 >= self.0.0
+ }
+
+ /// Returns the deadline as a clock-relative observation.
+ #[must_use]
+ pub const fn time(self) -> MonotonicTime {
+ self.0
+ }
+}
+
+/// Wall-clock adapter failure.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum WallClockError {
+ BeforeUnixEpoch,
+}
+
+impl fmt::Display for WallClockError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::BeforeUnixEpoch => formatter.write_str("wall clock is before the Unix epoch"),
+ }
+ }
+}
+
+impl Error for WallClockError {}
+
+/// Monotonic clock arithmetic failure.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MonotonicClockError {
+ DeadlineOverflow,
+}
+
+impl fmt::Display for MonotonicClockError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::DeadlineOverflow => formatter.write_str("monotonic deadline overflows"),
+ }
+ }
+}
+
+impl Error for MonotonicClockError {}
+
+/// Injected source of restart-stable wall UTC observations.
+pub trait WallClock: Send + Sync {
+ /// Returns the current whole-second UTC timestamp.
+ fn now_utc(&self) -> Result<UnixTimeSeconds, WallClockError>;
+}
+
+/// Injected source of process-local monotonic observations.
+pub trait MonotonicClock: Send + Sync {
+ /// Returns the current observation in this clock's domain.
+ fn now_monotonic(&self) -> MonotonicTime;
+
+ /// Computes a deadline relative to the current observation.
+ fn deadline_after(&self, duration: Duration) -> Result<MonotonicDeadline, MonotonicClockError> {
+ self.now_monotonic().checked_deadline_after(duration)
+ }
+}
+
+/// Production wall clock backed by `SystemTime`.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct SystemWallClock;
+
+impl WallClock for SystemWallClock {
+ fn now_utc(&self) -> Result<UnixTimeSeconds, WallClockError> {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| UnixTimeSeconds::new(duration.as_secs()))
+ .map_err(|_| WallClockError::BeforeUnixEpoch)
+ }
+}
+
+/// Production monotonic clock with an instance-local origin.
+#[derive(Clone, Copy, Debug)]
+pub struct SystemMonotonicClock {
+ origin: std::time::Instant,
+}
+
+impl SystemMonotonicClock {
+ /// Captures a new private monotonic origin.
+ #[must_use]
+ pub fn new() -> Self {
+ Self {
+ origin: std::time::Instant::now(),
+ }
+ }
+}
+
+impl Default for SystemMonotonicClock {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl MonotonicClock for SystemMonotonicClock {
+ fn now_monotonic(&self) -> MonotonicTime {
+ MonotonicTime::from_duration_since_origin(self.origin.elapsed())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use core::sync::atomic::{AtomicU64, Ordering};
+
+ use super::*;
+
+ struct FakeClock {
+ wall_seconds: AtomicU64,
+ monotonic_millis: AtomicU64,
+ }
+
+ impl FakeClock {
+ fn new(wall_seconds: u64, monotonic_millis: u64) -> Self {
+ Self {
+ wall_seconds: AtomicU64::new(wall_seconds),
+ monotonic_millis: AtomicU64::new(monotonic_millis),
+ }
+ }
+
+ fn advance(&self, duration: Duration) {
+ self.wall_seconds
+ .fetch_add(duration.as_secs(), Ordering::Relaxed);
+ let millis = u64::try_from(duration.as_millis()).expect("test duration fits u64");
+ self.monotonic_millis.fetch_add(millis, Ordering::Relaxed);
+ }
+ }
+
+ impl WallClock for FakeClock {
+ fn now_utc(&self) -> Result<UnixTimeSeconds, WallClockError> {
+ Ok(UnixTimeSeconds::new(
+ self.wall_seconds.load(Ordering::Relaxed),
+ ))
+ }
+ }
+
+ impl MonotonicClock for FakeClock {
+ fn now_monotonic(&self) -> MonotonicTime {
+ MonotonicTime::from_duration_since_origin(Duration::from_millis(
+ self.monotonic_millis.load(Ordering::Relaxed),
+ ))
+ }
+ }
+
+ #[test]
+ fn fake_clocks_advance_without_hidden_system_reads() {
+ let clock = FakeClock::new(1_000, 40);
+ let deadline = clock
+ .deadline_after(Duration::from_millis(25))
+ .expect("deadline");
+
+ assert_eq!(clock.now_utc().expect("wall time").get(), 1_000);
+ assert!(!deadline.is_reached_at(clock.now_monotonic()));
+ clock.advance(Duration::from_millis(25));
+ assert!(deadline.is_reached_at(clock.now_monotonic()));
+ assert_eq!(clock.now_utc().expect("wall time").get(), 1_000);
+ clock.advance(Duration::from_secs(2));
+ assert_eq!(clock.now_utc().expect("wall time").get(), 1_002);
+ }
+
+ #[test]
+ fn deadline_comparison_is_inclusive_and_overflow_is_rejected() {
+ let now = MonotonicTime::from_duration_since_origin(Duration::from_secs(5));
+ let deadline = now
+ .checked_deadline_after(Duration::from_secs(2))
+ .expect("deadline");
+ assert!(!deadline.is_reached_at(now));
+ assert!(
+ deadline.is_reached_at(MonotonicTime::from_duration_since_origin(
+ Duration::from_secs(7)
+ ))
+ );
+ assert!(
+ deadline.is_reached_at(MonotonicTime::from_duration_since_origin(
+ Duration::from_secs(8)
+ ))
+ );
+
+ assert_eq!(
+ MonotonicTime::from_duration_since_origin(Duration::MAX)
+ .checked_deadline_after(Duration::from_nanos(1)),
+ Err(MonotonicClockError::DeadlineOverflow)
+ );
+ }
+
+ #[test]
+ fn production_clock_adapters_smoke_test() {
+ assert!(SystemWallClock.now_utc().expect("system wall time").get() > 0);
+
+ let monotonic = SystemMonotonicClock::new();
+ let first = monotonic.now_monotonic();
+ let second = monotonic.now_monotonic();
+ assert!(second >= first);
+ assert!(monotonic.deadline_after(Duration::from_secs(1)).is_ok());
+ }
+}
diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs
@@ -4,7 +4,7 @@ const MANIFEST: &str = include_str!("../Cargo.toml");
const ROOT: &str = include_str!("../src/lib.rs");
#[test]
-fn service_host_is_unpublished_lint_governed_and_dependency_free() {
+fn service_host_is_unpublished_lint_governed_and_dependency_bounded() {
for required in [
"name = \"radroots_service_host\"",
"publish = false",
@@ -17,8 +17,11 @@ fn service_host_is_unpublished_lint_governed_and_dependency_free() {
);
}
- assert_eq!(dependency_keys(MANIFEST), BTreeSet::new());
- assert_eq!(public_modules(ROOT), BTreeSet::from(["error"]));
+ assert_eq!(dependency_keys(MANIFEST), BTreeSet::from(["getrandom"]));
+ assert_eq!(
+ public_modules(ROOT),
+ BTreeSet::from(["entropy", "error", "time"])
+ );
}
fn public_modules(root: &str) -> BTreeSet<&str> {