Files
frid/crates/music-dht/src/dht.rs
T
2026-07-16 17:24:43 +03:00

280 lines
8.4 KiB
Rust

//! DHT record validation and replacement rules.
use federation_net::NetworkId;
use crate::message::StoreRecordRequest;
use crate::normalization::{normalize_name, tokenize};
use crate::record::{
ACTIVE_RECORD_TTL, DhtKey, MAX_ITEM_NAME_BYTES, MAX_TOKENS_PER_ITEM, StoredRecord,
TOMBSTONE_TTL,
};
/// Outcome of comparing an incoming record with the stored one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StoreDecision {
/// No record stored yet, or the incoming one supersedes it: write it.
Write,
/// Same logical record: keep the stored row but extend its expiry.
RefreshExpiry(u64),
/// The incoming record is older or otherwise loses: ignore it.
Ignore,
}
/// Decides what to do with an incoming record given the stored state
/// `(revision, deleted, expires_at_ms)` for the same
/// `(key, artist_id, owner)`.
///
/// Rules:
/// * a higher revision always wins;
/// * on equal revisions a tombstone beats an active record;
/// * on equal revisions and equal deletion state the record is the same —
/// only the expiry is refreshed (this is how republish extends TTL);
/// * an older revision never replaces a newer one, in particular an old
/// active record never resurrects a tombstone.
pub(crate) fn decide_store(
existing: Option<(u64, bool, u64)>,
incoming: &StoredRecord,
) -> StoreDecision {
let Some((revision, deleted, expires_at_ms)) = existing else {
return StoreDecision::Write;
};
let item = &incoming.item;
if item.revision > revision {
return StoreDecision::Write;
}
if item.revision < revision {
return StoreDecision::Ignore;
}
// Equal revisions.
match (deleted, item.deleted) {
(false, true) => StoreDecision::Write,
(true, false) => StoreDecision::Ignore,
_ => {
if incoming.expires_at_ms > expires_at_ms {
StoreDecision::RefreshExpiry(incoming.expires_at_ms)
} else {
StoreDecision::Ignore
}
}
}
}
/// Validates an incoming `StoreRecord` request.
///
/// Checks the size limits, that the record is internally consistent, that the
/// key actually corresponds to the record's name or one of its tokens, and
/// clamps the expiry to the maximum TTL allowed for the record type. Returns
/// the record with a possibly clamped `expires_at_ms`.
pub(crate) fn validate_store(
request: StoreRecordRequest,
network_id: &NetworkId,
now_ms: u64,
) -> Result<StoredRecord, String> {
let mut record = request.record;
let item = &record.item;
if item.name.len() > MAX_ITEM_NAME_BYTES {
return Err("item name too long".into());
}
if item.normalized_name != normalize_name(&item.name) {
return Err("normalized name does not match the item name".into());
}
if item.normalized_name.is_empty() {
return Err("item name normalizes to nothing".into());
}
let tokens = tokenize(&item.normalized_name);
if tokens.len() > MAX_TOKENS_PER_ITEM {
return Err("too many tokens".into());
}
let key_matches = request.key == DhtKey::exact(network_id, &item.normalized_name)
|| tokens
.iter()
.any(|token| request.key == DhtKey::token(network_id, token));
if !key_matches {
return Err("key does not correspond to the record".into());
}
if record.expires_at_ms <= now_ms {
return Err("record is already expired".into());
}
let max_ttl_ms = if item.deleted {
TOMBSTONE_TTL.as_millis() as u64
} else {
ACTIVE_RECORD_TTL.as_millis() as u64
};
record.expires_at_ms = record.expires_at_ms.min(now_ms + max_ttl_ms);
Ok(record)
}
#[cfg(test)]
mod tests {
use federation_net::EndpointId;
use super::*;
use crate::record::{ItemId, LibraryItem};
fn test_peer(seed: u8) -> EndpointId {
iroh::SecretKey::from_bytes(&[seed; 32]).public()
}
fn record(revision: u64, deleted: bool, expires_at_ms: u64) -> StoredRecord {
let owner = test_peer(1);
StoredRecord {
item: LibraryItem {
id: ItemId::from_bytes([9u8; 32]),
owner,
kind: crate::record::ItemKind::Artist,
name: "Massive Attack".into(),
normalized_name: "massive attack".into(),
artist_names: Vec::new(),
year: None,
release_type: None,
duration_seconds: None,
revision,
deleted,
updated_at_ms: 0,
},
publisher: owner,
expires_at_ms,
}
}
#[test]
fn newer_revision_replaces_older() {
let incoming = record(2, false, 1000);
assert_eq!(
decide_store(Some((1, false, 500)), &incoming),
StoreDecision::Write
);
}
#[test]
fn older_revision_is_ignored() {
let incoming = record(1, false, 1000);
assert_eq!(
decide_store(Some((2, false, 500)), &incoming),
StoreDecision::Ignore
);
}
#[test]
fn tombstone_beats_active_record_of_same_revision() {
let incoming = record(1, true, 1000);
assert_eq!(
decide_store(Some((1, false, 500)), &incoming),
StoreDecision::Write
);
}
#[test]
fn active_record_does_not_resurrect_tombstone() {
let incoming = record(1, false, 1000);
assert_eq!(
decide_store(Some((1, true, 500)), &incoming),
StoreDecision::Ignore
);
// Even an older active record loses to a newer tombstone.
let incoming = record(1, false, 1000);
assert_eq!(
decide_store(Some((2, true, 500)), &incoming),
StoreDecision::Ignore
);
}
#[test]
fn republish_refreshes_expiry() {
let incoming = record(1, false, 2000);
assert_eq!(
decide_store(Some((1, false, 500)), &incoming),
StoreDecision::RefreshExpiry(2000)
);
let stale = record(1, false, 100);
assert_eq!(
decide_store(Some((1, false, 500)), &stale),
StoreDecision::Ignore
);
}
#[test]
fn missing_record_is_written() {
let incoming = record(1, false, 1000);
assert_eq!(decide_store(None, &incoming), StoreDecision::Write);
}
#[test]
fn validate_checks_key_and_clamps_ttl() {
let net = NetworkId::from_name("test");
let now = 1_000_000;
let rec = record(1, false, now + ACTIVE_RECORD_TTL.as_millis() as u64 * 10);
// Correct exact key: accepted, expiry clamped to the maximum TTL.
let ok = validate_store(
StoreRecordRequest {
key: DhtKey::exact(&net, "massive attack"),
record: rec.clone(),
},
&net,
now,
)
.expect("valid");
assert_eq!(ok.expires_at_ms, now + ACTIVE_RECORD_TTL.as_millis() as u64);
// Correct token key: accepted.
assert!(
validate_store(
StoreRecordRequest {
key: DhtKey::token(&net, "massive"),
record: rec.clone(),
},
&net,
now,
)
.is_ok()
);
// Unrelated key: rejected.
assert!(
validate_store(
StoreRecordRequest {
key: DhtKey::exact(&net, "portishead"),
record: rec.clone(),
},
&net,
now,
)
.is_err()
);
// Expired record: rejected.
let expired = record(1, false, now - 1);
assert!(
validate_store(
StoreRecordRequest {
key: DhtKey::exact(&net, "massive attack"),
record: expired,
},
&net,
now,
)
.is_err()
);
// Inconsistent normalization: rejected.
let mut bad = rec;
bad.item.normalized_name = "something else".into();
assert!(
validate_store(
StoreRecordRequest {
key: DhtKey::exact(&net, "something else"),
record: bad,
},
&net,
now,
)
.is_err()
);
}
}