fix(store): peer estimates outside a plausible wall range don't count

Spec §9 decision J: an estimate outside the plausibility bounds doesn't
count. A max-based peer_time_estimate always returned a forged
observed_at = u64::MAX, which the host then rejected, so it never saw
the honest estimates and stayed quarantined.

LogStoreBuilder::plausible_wall_ms(floor..=ceiling) sets the range
(default unbounded, so behaviour is unchanged when unset), and
peer_time_estimate filters candidates against it before taking the
max. The WallClockStore signature is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 08:44:09 -07:00
co-authored by Claude Opus 5.5
parent 01db6c0b71
commit 15bbdbd4a0
2 changed files with 66 additions and 4 deletions
+31 -4
View File
@@ -112,6 +112,7 @@
use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::Write; use std::io::Write;
use std::ops::RangeInclusive;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -298,6 +299,8 @@ pub struct LogStore {
anchor: Tick, anchor: Tick,
/// The `unknown_downtime_age_ms` broadcast floor configured on open. /// The `unknown_downtime_age_ms` broadcast floor configured on open.
unknown_downtime_age_ms: u64, unknown_downtime_age_ms: u64,
/// Wall times a peer estimate must fall in to count (decision J).
plausible_wall_ms: RangeInclusive<u64>,
/// Wall-clock value captured by [`Self::set_wall_clock`]; `Some` once the /// Wall-clock value captured by [`Self::set_wall_clock`]; `Some` once the
/// wall clock has been trusted. /// wall clock has been trusted.
wall_at_set: Option<u64>, wall_at_set: Option<u64>,
@@ -336,6 +339,7 @@ pub struct FoldReport {
pub struct LogStoreBuilder { pub struct LogStoreBuilder {
path: PathBuf, path: PathBuf,
unknown_downtime_age_ms: u64, unknown_downtime_age_ms: u64,
plausible_wall_ms: RangeInclusive<u64>,
} }
impl LogStoreBuilder { impl LogStoreBuilder {
@@ -350,10 +354,25 @@ impl LogStoreBuilder {
} }
} }
/// Open the store, creating it if necessary, with the configured broadcast /// Set the range of wall times (ms since the Unix epoch) a peer estimate
/// floor. See [`LogStore::open`]. /// must fall in to count (spec §9, decision J). [`LogStore::peer_time_estimate`]
/// ignores any candidate whose estimate is outside it, so a forged
/// `observed_at` far ahead (or behind) can't hide the honest estimates.
///
/// The default is unbounded (`0..=u64::MAX`).
pub fn plausible_wall_ms(self, plausible_wall_ms: RangeInclusive<u64>) -> Self {
Self {
plausible_wall_ms,
..self
}
}
/// Open the store, creating it if necessary, with the configured options.
/// See [`LogStore::open`].
pub fn open(self, now: Tick) -> Result<LogStore, StoreError> { pub fn open(self, now: Tick) -> Result<LogStore, StoreError> {
LogStore::open_with(self.path.as_ref(), now, self.unknown_downtime_age_ms) let mut store = LogStore::open_with(self.path.as_ref(), now, self.unknown_downtime_age_ms)?;
store.plausible_wall_ms = self.plausible_wall_ms;
Ok(store)
} }
} }
@@ -392,6 +411,7 @@ impl LogStore {
LogStoreBuilder { LogStoreBuilder {
path: path.to_path_buf(), path: path.to_path_buf(),
unknown_downtime_age_ms: LogStore::DEFAULT_UNKNOWN_DOWNTIME_MS, unknown_downtime_age_ms: LogStore::DEFAULT_UNKNOWN_DOWNTIME_MS,
plausible_wall_ms: 0..=u64::MAX,
} }
} }
/// Open (creating if necessary) the log at `path` and rebuild the index. /// Open (creating if necessary) the log at `path` and rebuild the index.
@@ -602,6 +622,7 @@ impl LogStore {
open_tick: now, open_tick: now,
anchor: now, anchor: now,
unknown_downtime_age_ms, unknown_downtime_age_ms,
plausible_wall_ms: 0..=u64::MAX,
wall_at_set: None, wall_at_set: None,
tick_at_set: None, tick_at_set: None,
fold_report: None, fold_report: None,
@@ -895,7 +916,12 @@ impl LogStore {
/// estimate and collects estimates for a short window before adopting /// estimate and collects estimates for a short window before adopting
/// the latest one with [`Self::set_wall_clock`] (spec §9). /// the latest one with [`Self::set_wall_clock`] (spec §9).
/// ///
/// Returns `None` when no candidate carries an `observed_at` yet. /// Estimates outside the range set with
/// [`LogStoreBuilder::plausible_wall_ms`] don't count (unbounded by
/// default), so one forged `observed_at = u64::MAX` can't win the max and
/// hide the honest estimates.
///
/// Returns `None` when no candidate carries an `observed_at` in range yet.
pub fn peer_time_estimate(&self, now: Tick) -> Option<u64> { pub fn peer_time_estimate(&self, now: Tick) -> Option<u64> {
self.index self.index
.values() .values()
@@ -907,6 +933,7 @@ impl LogStore {
.saturating_add(now.saturating_sub(entry.stored_at)) .saturating_add(now.saturating_sub(entry.stored_at))
}) })
}) })
.filter(|estimate| self.plausible_wall_ms.contains(estimate))
.max() .max()
} }
+35
View File
@@ -1298,6 +1298,41 @@ mod snapshots_and_flush {
assert_eq!(s.peer_time_estimate(200), Some(u64::MAX)); assert_eq!(s.peer_time_estimate(200), Some(u64::MAX));
} }
/// Decision J / B3: an estimate outside the plausible range doesn't count,
/// so a forged `observed_at = u64::MAX` can't hide the honest estimates.
#[test]
fn implausible_peer_time_does_not_count() {
const FLOOR: u64 = 1_767_225_600_000; // 2026-01-01Z
const CEILING: u64 = FLOOR + 36_525 * 86_400_000;
let path = store_path("j_plausible");
let mut s = LogStore::builder(&path)
.plausible_wall_ms(FLOOR..=CEILING)
.open(0)
.unwrap();
s.insert(obs([1; 16], 1, 0, Some(u64::MAX)), 0).unwrap();
assert_eq!(
s.peer_time_estimate(0),
None,
"the forgery alone counts for nothing"
);
s.insert(obs([2; 16], 1, 0, Some(FLOOR - 1)), 0).unwrap();
assert_eq!(
s.peer_time_estimate(0),
None,
"below the floor doesn't count"
);
s.insert(obs([3; 16], 1, 100, Some(FLOOR + 5_000)), 0)
.unwrap();
assert_eq!(s.peer_time_estimate(50), Some(FLOOR + 5_150));
drop(s);
// Unset, the range is unbounded: the forgery wins, saturated.
let path = store_path("j_unbounded");
let mut s = LogStore::builder(&path).open(0).unwrap();
s.insert(obs([1; 16], 1, 0, Some(u64::MAX)), 0).unwrap();
assert_eq!(s.peer_time_estimate(0), Some(u64::MAX));
}
/// Decision L: quarantined entries are exempt from every trim rule, not /// Decision L: quarantined entries are exempt from every trim rule, not
/// just the age trim. /// just the age trim.
#[test] #[test]