use std::io::Write as _;
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::PeerId;
use crate::address::MultiAddr;
pub(crate) const ROUTING_SNAPSHOT_FILENAME: &str = "routing_snapshot.json";
const SCHEMA_VERSION: u32 = 1;
const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
const MAX_FUTURE_TIMESTAMP_SKEW: Duration = Duration::from_secs(5 * 60);
const MAX_SNAPSHOT_BYTES: u64 = 512 * 1024;
const MAX_SNAPSHOT_PEERS: usize = 1024;
const MAX_ADDRESSES_PER_PEER: usize = 8;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct SnapshotPeer {
pub peer_id: PeerId,
pub addresses: Vec<MultiAddr>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub(crate) enum SnapshotRejection {
#[error("unknown schema version {found} (this build writes {expected})")]
UnknownSchemaVersion {
found: u32,
expected: u32,
},
#[error("written by a different node")]
ForeignOwner,
#[error("stale or implausibly future-dated")]
Stale,
#[error("unusable snapshot file: {0}")]
Unreadable(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RoutingSnapshot {
pub schema_version: u32,
pub owner: PeerId,
pub saved_at_epoch_secs: u64,
pub peers: Vec<SnapshotPeer>,
}
impl RoutingSnapshot {
pub fn new(owner: PeerId, saved_at_epoch_secs: u64, mut peers: Vec<SnapshotPeer>) -> Self {
peers.truncate(MAX_SNAPSHOT_PEERS);
for peer in &mut peers {
peer.addresses.truncate(MAX_ADDRESSES_PER_PEER);
}
Self {
schema_version: SCHEMA_VERSION,
owner,
saved_at_epoch_secs,
peers,
}
}
pub fn peers_for(
&self,
expected_owner: &PeerId,
now_epoch_secs: u64,
) -> Result<&[SnapshotPeer], SnapshotRejection> {
if self.schema_version != SCHEMA_VERSION {
return Err(SnapshotRejection::UnknownSchemaVersion {
found: self.schema_version,
expected: SCHEMA_VERSION,
});
}
if self.owner != *expected_owner {
return Err(SnapshotRejection::ForeignOwner);
}
if self.is_stale(now_epoch_secs) {
return Err(SnapshotRejection::Stale);
}
Ok(&self.peers)
}
fn is_stale(&self, now_epoch_secs: u64) -> bool {
let future_skew = self.saved_at_epoch_secs.saturating_sub(now_epoch_secs);
if future_skew > MAX_FUTURE_TIMESTAMP_SKEW.as_secs() {
return true;
}
now_epoch_secs.saturating_sub(self.saved_at_epoch_secs) > MAX_AGE.as_secs()
}
pub async fn save_to_dir(&self, dir: &Path) -> anyhow::Result<()> {
tokio::fs::create_dir_all(dir).await.map_err(|e| {
anyhow::anyhow!(
"failed to create routing snapshot directory {}: {e}",
dir.display()
)
})?;
let path = dir.join(ROUTING_SNAPSHOT_FILENAME);
let json = serde_json::to_vec(self)
.map_err(|e| anyhow::anyhow!("failed to serialize routing snapshot: {e}"))?;
let dir_owned = dir.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut tmp = tempfile::NamedTempFile::new_in(&dir_owned).map_err(|e| {
anyhow::anyhow!("failed to create temp file in {}: {e}", dir_owned.display())
})?;
tmp.write_all(&json)
.map_err(|e| anyhow::anyhow!("failed to write routing snapshot: {e}"))?;
tmp.persist(&path).map_err(|e| {
anyhow::anyhow!(
"failed to persist routing snapshot to {}: {e}",
path.display()
)
})?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("routing snapshot save task panicked: {e}"))?
}
pub async fn load_from_dir(dir: &Path) -> Result<Option<Self>, SnapshotRejection> {
let path = dir.join(ROUTING_SNAPSHOT_FILENAME);
let file = match open_snapshot_file(&path).await {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(SnapshotRejection::Unreadable(e.to_string())),
};
let metadata = file
.metadata()
.await
.map_err(|e| SnapshotRejection::Unreadable(e.to_string()))?;
if !metadata.is_file() {
return Err(SnapshotRejection::Unreadable("not a regular file".into()));
}
if metadata.len() > MAX_SNAPSHOT_BYTES {
return Err(SnapshotRejection::Unreadable(format!(
"{} bytes exceeds the {MAX_SNAPSHOT_BYTES} byte limit",
metadata.len()
)));
}
let mut bytes = Vec::new();
let mut bounded = tokio::io::AsyncReadExt::take(file, MAX_SNAPSHOT_BYTES + 1);
tokio::io::AsyncReadExt::read_to_end(&mut bounded, &mut bytes)
.await
.map_err(|e| SnapshotRejection::Unreadable(e.to_string()))?;
if bytes.len() as u64 > MAX_SNAPSHOT_BYTES {
return Err(SnapshotRejection::Unreadable(format!(
"exceeds the {MAX_SNAPSHOT_BYTES} byte limit while reading"
)));
}
let mut snapshot: Self = serde_json::from_slice(&bytes)
.map_err(|e| SnapshotRejection::Unreadable(e.to_string()))?;
snapshot.peers.truncate(MAX_SNAPSHOT_PEERS);
for peer in &mut snapshot.peers {
peer.addresses.truncate(MAX_ADDRESSES_PER_PEER);
}
Ok(Some(snapshot))
}
}
#[cfg(unix)]
async fn open_snapshot_file(path: &Path) -> std::io::Result<tokio::fs::File> {
use std::os::unix::fs::OpenOptionsExt as _;
let path = path.to_path_buf();
let file = tokio::task::spawn_blocking(move || {
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
.open(path)
})
.await
.map_err(|e| std::io::Error::other(format!("snapshot open task panicked: {e}")))??;
Ok(tokio::fs::File::from_std(file))
}
#[cfg(not(unix))]
async fn open_snapshot_file(path: &Path) -> std::io::Result<tokio::fs::File> {
tokio::fs::File::open(path).await
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn peer() -> SnapshotPeer {
SnapshotPeer {
peer_id: PeerId::random(),
addresses: vec!["/ip4/10.0.1.1/udp/9000/quic".parse().unwrap()],
}
}
fn snapshot(owner: PeerId, saved_at: u64, count: usize) -> RoutingSnapshot {
RoutingSnapshot::new(owner, saved_at, (0..count).map(|_| peer()).collect())
}
#[tokio::test]
async fn round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let owner = PeerId::random();
let original = snapshot(owner, 1_000, 130);
original.save_to_dir(dir.path()).await.unwrap();
let loaded = RoutingSnapshot::load_from_dir(dir.path())
.await
.unwrap()
.expect("snapshot present");
assert_eq!(loaded.peers, original.peers);
assert_eq!(loaded.peers_for(&owner, 1_000).unwrap().len(), 130);
}
#[tokio::test]
async fn a_missing_snapshot_is_absence_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(
RoutingSnapshot::load_from_dir(dir.path())
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn a_truncated_file_is_reported_not_silently_ignored() {
let dir = tempfile::tempdir().unwrap();
let owner = PeerId::random();
snapshot(owner, 1_000, 4)
.save_to_dir(dir.path())
.await
.unwrap();
let path = dir.path().join(ROUTING_SNAPSHOT_FILENAME);
let json = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, &json[..json.len() / 2]).unwrap();
assert!(matches!(
RoutingSnapshot::load_from_dir(dir.path()).await,
Err(SnapshotRejection::Unreadable(_))
));
}
#[tokio::test]
async fn an_oversized_file_is_refused_without_reading_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(ROUTING_SNAPSHOT_FILENAME);
std::fs::write(&path, vec![b'x'; (MAX_SNAPSHOT_BYTES + 1) as usize]).unwrap();
assert!(matches!(
RoutingSnapshot::load_from_dir(dir.path()).await,
Err(SnapshotRejection::Unreadable(_))
));
}
#[tokio::test]
async fn a_file_with_too_many_peers_is_capped_on_load() {
let dir = tempfile::tempdir().unwrap();
let owner = PeerId::random();
let oversized = RoutingSnapshot {
schema_version: SCHEMA_VERSION,
owner,
saved_at_epoch_secs: 1_000,
peers: (0..MAX_SNAPSHOT_PEERS + 50).map(|_| peer()).collect(),
};
let path = dir.path().join(ROUTING_SNAPSHOT_FILENAME);
std::fs::write(&path, serde_json::to_vec(&oversized).unwrap()).unwrap();
let loaded = RoutingSnapshot::load_from_dir(dir.path())
.await
.unwrap()
.expect("snapshot present");
assert_eq!(loaded.peers.len(), MAX_SNAPSHOT_PEERS);
}
#[cfg(unix)]
#[tokio::test]
async fn a_fifo_at_the_snapshot_path_is_refused_without_blocking() {
use std::os::unix::ffi::OsStrExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(ROUTING_SNAPSHOT_FILENAME);
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }, 0);
let result = tokio::time::timeout(
Duration::from_secs(10),
RoutingSnapshot::load_from_dir(dir.path()),
)
.await
.expect("loading must not block on a FIFO");
assert!(matches!(result, Err(SnapshotRejection::Unreadable(_))));
}
#[cfg(unix)]
#[tokio::test]
async fn a_symlink_at_the_snapshot_path_is_refused() {
let dir = tempfile::tempdir().unwrap();
let owner = PeerId::random();
let target = dir.path().join("elsewhere.json");
std::fs::write(
&target,
serde_json::to_vec(&snapshot(owner, 1_000, 4)).unwrap(),
)
.unwrap();
let path = dir.path().join(ROUTING_SNAPSHOT_FILENAME);
std::os::unix::fs::symlink(&target, &path).unwrap();
assert!(matches!(
RoutingSnapshot::load_from_dir(dir.path()).await,
Err(SnapshotRejection::Unreadable(_))
));
}
#[test]
fn another_nodes_snapshot_is_refused() {
let owner = PeerId::random();
let someone_else = PeerId::random();
assert_eq!(
snapshot(owner, 1_000, 4).peers_for(&someone_else, 1_000),
Err(SnapshotRejection::ForeignOwner)
);
}
#[test]
fn an_unknown_schema_version_is_refused_rather_than_guessed_at() {
let owner = PeerId::random();
let mut snap = snapshot(owner, 1_000, 4);
snap.schema_version = SCHEMA_VERSION + 1;
assert!(matches!(
snap.peers_for(&owner, 1_000),
Err(SnapshotRejection::UnknownSchemaVersion { .. })
));
}
#[test]
fn staleness_is_bounded_on_both_sides_of_now() {
let owner = PeerId::random();
let saved_at = 1_000_000;
let snap = snapshot(owner, saved_at, 4);
assert!(snap.peers_for(&owner, saved_at).is_ok());
assert!(snap.peers_for(&owner, saved_at + 6 * 60 * 60).is_ok());
assert_eq!(
snap.peers_for(&owner, saved_at + MAX_AGE.as_secs() + 1),
Err(SnapshotRejection::Stale)
);
assert_eq!(
snap.peers_for(&owner, saved_at - MAX_FUTURE_TIMESTAMP_SKEW.as_secs() - 1),
Err(SnapshotRejection::Stale)
);
}
}