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:
+31
-4
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user