280 lines
8.4 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|