Skip to main content

macula_rust/
dht.rs

1//! The subset of Macula's signed DHT records that direct-dial resolution
2//! needs: `procedure_advertisement` and `station_endpoint` construction,
3//! signing, verification, and storage-key derivation, plus thin wrappers
4//! around the mesh's `_dht.*` RPC procedures.
5//!
6//! Ported from `macula-io/macula`'s `src/record/macula_record.erl` and
7//! `src/macula.erl` (the `put_record`/`find_record`/`find_records` facade),
8//! cross-checked against `macula-go`'s own port of the same reference
9//! (`dht/record.go`, `dht/client.go`) — see those files' doc comments for
10//! the fuller reasoning behind each field. Only the two record types
11//! direct-dial needs are ported; add more constructors here as other
12//! direct-dial consumers (streaming, content) are built.
13//!
14//! **This is a thin RPC client, not a DHT participant.** Every function
15//! here just issues an ordinary signed CALL (`_dht.put_record` etc.) to
16//! whichever station the given [`Session`] is
17//! already connected to — real Kademlia routing, replication, and k-bucket
18//! maintenance stay entirely on the relay side (`macula-station`). Nothing
19//! in this module talks DHT protocol directly.
20
21use std::time::{Duration, SystemTime, UNIX_EPOCH};
22
23use crate::cbor::Value;
24use crate::connection::{CallError, Session};
25use crate::frame::CallResponse;
26use crate::identity::KeyPair;
27
28/// Record type tags — `macula_record.erl`'s `?TYPE_*` constants.
29pub const TYPE_PROCEDURE_ADVERTISEMENT: u8 = 0x06;
30pub const TYPE_STATION_ENDPOINT: u8 = 0x12;
31pub const TYPE_CONTENT_ANNOUNCEMENT: u8 = 0x11;
32
33/// Matches `macula_record`'s `?DEFAULT_TTL_MS` (48h) — the TTL a
34/// `procedure_advertisement` gets when the caller doesn't specify one.
35pub const DEFAULT_TTL: Duration = Duration::from_secs(48 * 60 * 60);
36
37/// The Ed25519 signature domain separator — `macula_record`'s
38/// `?SIG_DOMAIN`. 17 bytes: "macula-v2-record" (16 ASCII) plus a trailing
39/// NUL.
40const SIG_DOMAIN: &[u8] = b"macula-v2-record\0";
41
42/// Mirrors `macula_record.erl`'s envelope map (type/key/version/
43/// created_at/expires_at/payload/signature). `subject_id` is not carried —
44/// neither record type this module builds uses it.
45#[derive(Debug, Clone)]
46pub struct Record {
47    pub record_type: u8,
48    /// 32B: envelope signer's Ed25519 pubkey.
49    pub key: [u8; 32],
50    /// 16B: UUIDv7.
51    pub version: [u8; 16],
52    /// ms since epoch.
53    pub created_at: i128,
54    /// ms since epoch.
55    pub expires_at: i128,
56    pub payload: Value,
57    /// 64B once [`sign`] has been called; empty beforehand.
58    pub signature: Vec<u8>,
59}
60
61fn now_ms() -> i128 {
62    SystemTime::now()
63        .duration_since(UNIX_EPOCH)
64        .expect("system clock before 1970")
65        .as_millis() as i128
66}
67
68fn new_envelope(record_type: u8, key: [u8; 32], payload: Value, ttl: Duration) -> Record {
69    let created_at = now_ms();
70    Record {
71        record_type,
72        key,
73        version: *uuid::Uuid::now_v7().as_bytes(),
74        created_at,
75        expires_at: created_at + ttl.as_millis() as i128,
76        payload,
77        signature: Vec::new(),
78    }
79}
80
81/// Builds an UNSIGNED `procedure_advertisement` record naming
82/// `serving_station` as `procedure_uri`'s current handler. `procedure_uri`
83/// should be the realm-qualified discovery URI (see [`discovery_uri`]),
84/// matching `macula_direct_dial`'s own convention — the advertiser and the
85/// resolver must derive the identical URI or the DHT storage key
86/// ([`procedure_key`]) will not agree. Sign before [`put_record`].
87///
88/// Mirrors `macula_record:procedure_advertisement/3,4`. See
89/// [`new_procedure_advertisement_with_cert_chain`] for the `cert_chain`
90/// variant.
91pub fn new_procedure_advertisement(
92    advertiser_node: [u8; 32],
93    procedure_uri: impl Into<String>,
94    serving_station: [u8; 32],
95    ttl: Duration,
96) -> Record {
97    let ttl = if ttl.is_zero() { DEFAULT_TTL } else { ttl };
98    let payload = Value::Map(vec![
99        (Value::text("procedure_uri"), Value::text(procedure_uri)),
100        (
101            Value::text("advertiser_node"),
102            Value::Bytes(advertiser_node.to_vec()),
103        ),
104        (
105            Value::text("serving_station"),
106            Value::Bytes(serving_station.to_vec()),
107        ),
108    ]);
109    new_envelope(TYPE_PROCEDURE_ADVERTISEMENT, advertiser_node, payload, ttl)
110}
111
112/// [`new_procedure_advertisement`] plus an embedded X.509 service-cert
113/// chain (leaf-first PEM: leaf ++ org CA), for Slice 7c Direction B
114/// managed-realm authorization — see
115/// [`cert_chain::verify_advertisement_cert_chain`](crate::cert_chain::verify_advertisement_cert_chain)
116/// for the corresponding check. Opt-in: plain [`new_procedure_advertisement`]
117/// is unaffected and remains the right choice for unmanaged realms.
118pub fn new_procedure_advertisement_with_cert_chain(
119    advertiser_node: [u8; 32],
120    procedure_uri: impl Into<String>,
121    serving_station: [u8; 32],
122    ttl: Duration,
123    cert_chain_pem: Vec<u8>,
124) -> Record {
125    let mut rec = new_procedure_advertisement(advertiser_node, procedure_uri, serving_station, ttl);
126    let Value::Map(mut entries) = rec.payload else {
127        unreachable!("new_procedure_advertisement always returns a Map payload");
128    };
129    entries.push((Value::text("cert_chain"), Value::Bytes(cert_chain_pem)));
130    rec.payload = Value::Map(entries);
131    rec
132}
133
134/// Builds an UNSIGNED `content_announcement` record naming
135/// `announcer_node` as reachable at `endpoint` for `mcid`. Sign before
136/// [`put_record`]. Mirrors `macula_record:content_announcement/3,4` — see
137/// [`ContentAnnouncement`] for which optional metadata fields are not
138/// ported.
139pub fn new_content_announcement(
140    announcer_node: [u8; 32],
141    mcid: crate::manifest::Mcid,
142    endpoint: impl Into<String>,
143    ttl: Duration,
144) -> Record {
145    let payload = Value::Map(vec![
146        (
147            Value::text("announcer_node"),
148            Value::Bytes(announcer_node.to_vec()),
149        ),
150        (Value::text("mcid"), Value::Bytes(mcid.to_vec())),
151        (Value::text("endpoint"), Value::text(endpoint)),
152    ]);
153    new_envelope(TYPE_CONTENT_ANNOUNCEMENT, announcer_node, payload, ttl)
154}
155
156/// Extracts a `content_announcement` record's typed fields, or an error if
157/// `r` isn't one or is malformed. Mirrors
158/// `macula_record:read_content_announcement/1`.
159pub fn read_content_announcement(r: &Record) -> Result<ContentAnnouncement, ReadRecordError> {
160    if r.record_type != TYPE_CONTENT_ANNOUNCEMENT {
161        return Err(ReadRecordError::WrongRecordType);
162    }
163    let announcer_node = bytes32_field(&r.payload, "announcer_node")?;
164    let mcid: crate::manifest::Mcid = match r.payload.get("mcid") {
165        Some(Value::Bytes(b)) => b
166            .as_slice()
167            .try_into()
168            .map_err(|_| ReadRecordError::WrongFieldType("mcid"))?,
169        Some(_) => return Err(ReadRecordError::WrongFieldType("mcid")),
170        None => return Err(ReadRecordError::MissingField("mcid")),
171    };
172    let endpoint = match r.payload.get("endpoint") {
173        Some(Value::Text(t)) => t.clone(),
174        Some(_) => return Err(ReadRecordError::WrongFieldType("endpoint")),
175        None => return Err(ReadRecordError::MissingField("endpoint")),
176    };
177    Ok(ContentAnnouncement {
178        announcer_node,
179        mcid,
180        endpoint,
181    })
182}
183
184/// The exact bytes `macula_record:canonical_unsigned/1` signs and
185/// verifies: deterministic CBOR of the envelope map using the COMPACT
186/// single-letter keys (t/k/v/c/x/p), signature excluded. This is a
187/// DIFFERENT representation from the full-field-name map [`to_rpc_value`]
188/// sends as RPC args — the compact form exists only to be signed/verified,
189/// never sent on the wire as such.
190fn canonical_unsigned(r: &Record) -> Vec<u8> {
191    let entries = Value::Map(vec![
192        (Value::text("t"), Value::Int(r.record_type as i128)),
193        (Value::text("k"), Value::Bytes(r.key.to_vec())),
194        (Value::text("v"), Value::Bytes(r.version.to_vec())),
195        (Value::text("c"), Value::Int(r.created_at)),
196        (Value::text("x"), Value::Int(r.expires_at)),
197        (Value::text("p"), r.payload.clone()),
198    ]);
199    // Signing bytes are protocol-internal and always within the
200    // deterministic encoder's supported range — an encode failure here
201    // would mean a payload this module itself built is malformed, which
202    // is a bug in this module, not a runtime condition to recover from.
203    crate::cbor::encode(&entries).expect("dht record payload must be encodable")
204}
205
206/// Sets `r.signature` to the Ed25519 signature over
207/// `SIG_DOMAIN || canonical_unsigned(r)`, matching `macula_record:sign/2`.
208pub fn sign(mut r: Record, id: &KeyPair) -> Record {
209    let mut msg = SIG_DOMAIN.to_vec();
210    msg.extend_from_slice(&canonical_unsigned(&r));
211    r.signature = id.sign(&msg).to_vec();
212    r
213}
214
215#[derive(Debug, PartialEq, Eq)]
216pub enum VerifyError {
217    InvalidSignature,
218    Expired,
219}
220
221impl std::fmt::Display for VerifyError {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        match self {
224            VerifyError::InvalidSignature => write!(f, "dht: signature invalid"),
225            VerifyError::Expired => write!(f, "dht: record expired"),
226        }
227    }
228}
229
230impl std::error::Error for VerifyError {}
231
232/// Checks `r`'s Ed25519 signature against its own `key`, then its expiry.
233/// Matches `macula_record:verify/1`. Distinguishes [`VerifyError::Expired`]
234/// from [`VerifyError::InvalidSignature`] because a caller resolving a
235/// record (e.g. `direct_dial`'s retry loop) should retry past a
236/// stale-but-once-valid replica, never past a forged one — see
237/// `macula_direct_dial.erl`'s `on_endpoint_verified/3` doing exactly this
238/// branch.
239pub fn verify(r: &Record) -> Result<(), VerifyError> {
240    let sig: [u8; 64] = r
241        .signature
242        .as_slice()
243        .try_into()
244        .map_err(|_| VerifyError::InvalidSignature)?;
245    let mut msg = SIG_DOMAIN.to_vec();
246    msg.extend_from_slice(&canonical_unsigned(r));
247    if !crate::identity::verify(&msg, &sig, &r.key) {
248        return Err(VerifyError::InvalidSignature);
249    }
250    if r.expires_at > 0 && now_ms() >= r.expires_at {
251        return Err(VerifyError::Expired);
252    }
253    Ok(())
254}
255
256/// Namespaces `station_endpoint` storage keys so they don't collide with
257/// `node_record`, which keys on the same pubkey — `macula_record`'s
258/// `?STORAGE_DOMAIN_STATION_ENDPOINT`.
259const STORAGE_DOMAIN_STATION_ENDPOINT: &[u8] = b"station_endpoint";
260
261/// The DHT storage key for a `procedure_advertisement` by its (already
262/// realm-qualified — see [`discovery_uri`]) URI: `SHA-256(uri)`. Matches
263/// `macula_record:procedure_key/1`.
264pub fn procedure_key(procedure_uri: &str) -> [u8; 32] {
265    use sha2::{Digest, Sha256};
266    Sha256::digest(procedure_uri.as_bytes()).into()
267}
268
269/// The DHT storage key for a station's own `station_endpoint` record:
270/// `SHA-256("station_endpoint" || pubkey)`. Matches
271/// `macula_record:station_endpoint_key/1`.
272pub fn station_endpoint_key(station_pubkey: [u8; 32]) -> [u8; 32] {
273    use sha2::{Digest, Sha256};
274    let mut hasher = Sha256::new();
275    hasher.update(STORAGE_DOMAIN_STATION_ENDPOINT);
276    hasher.update(station_pubkey);
277    hasher.finalize().into()
278}
279
280/// The DHT storage key for every `content_announcement` naming `mcid`:
281/// `SHA-256(mcid)`. Matches `macula_record:content_key/1`. Consumers use
282/// this with [`find_records`] (there may be more than one announcer)
283/// before holding any record.
284pub fn content_key(mcid: crate::manifest::Mcid) -> [u8; 32] {
285    use sha2::{Digest, Sha256};
286    Sha256::digest(mcid).into()
287}
288
289/// Matches `macula_direct_dial`'s `discovery_uri/2`: the DHT
290/// lookup/advertisement key input is `hex(realm) + "/" + procedure`, so the
291/// same procedure name under different realms doesn't collide in the DHT.
292/// The advertiser and every resolver must derive this identically.
293pub fn discovery_uri(realm: [u8; 32], procedure: &str) -> String {
294    let mut hex_realm = String::with_capacity(64);
295    for b in realm {
296        hex_realm.push_str(&format!("{b:02X}"));
297    }
298    format!("{hex_realm}/{procedure}")
299}
300
301/// A `procedure_advertisement` record's fields, read out of its payload —
302/// mirrors `macula_record:read_procedure_advertisement/1`. `cert_chain` is
303/// `None` when the advertisement carries no `cert_chain` field (the common,
304/// unmanaged-realm case); see
305/// [`cert_chain::verify_advertisement_cert_chain`](crate::cert_chain::verify_advertisement_cert_chain).
306#[derive(Debug, Clone)]
307pub struct ProcedureAdvertisement {
308    pub procedure_uri: String,
309    pub advertiser_node: [u8; 32],
310    pub serving_station: [u8; 32],
311    /// Optional: leaf-first PEM bundle, leaf ++ org CA.
312    pub cert_chain: Option<Vec<u8>>,
313}
314
315/// A `station_endpoint` record's fields, read out of its payload — mirrors
316/// `macula_record:read_station_endpoint/1`.
317#[derive(Debug, Clone)]
318pub struct StationEndpoint {
319    pub quic_port: u16,
320    pub host_advertised: Vec<String>,
321}
322
323/// A `content_announcement` record's fields, read out of its payload —
324/// mirrors `macula_record:read_content_announcement/1`. The optional
325/// `name`/`size`/`chunk_count` metadata fields
326/// (`content_announcement_opts()`) are not ported — direct-dial content
327/// fetch doesn't need them to resolve and dial; add them if a future
328/// caller needs to prioritize candidates without fetching the manifest.
329#[derive(Debug, Clone)]
330pub struct ContentAnnouncement {
331    pub announcer_node: [u8; 32],
332    pub mcid: crate::manifest::Mcid,
333    /// A dialable seed URL, e.g. `"https://host:4433"` — matches
334    /// `macula_client:seed()`'s own format, NOT a `station_endpoint`'s
335    /// split host/port.
336    pub endpoint: String,
337}
338
339#[derive(Debug, PartialEq, Eq)]
340pub enum ReadRecordError {
341    WrongRecordType,
342    MissingField(&'static str),
343    WrongFieldType(&'static str),
344}
345
346impl std::fmt::Display for ReadRecordError {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        match self {
349            ReadRecordError::WrongRecordType => write!(f, "dht: unexpected record type"),
350            ReadRecordError::MissingField(name) => write!(f, "dht: missing field {name:?}"),
351            ReadRecordError::WrongFieldType(name) => {
352                write!(f, "dht: field {name:?} has the wrong type")
353            }
354        }
355    }
356}
357
358impl std::error::Error for ReadRecordError {}
359
360/// Extracts a `procedure_advertisement` record's typed fields, or an error
361/// if `r` isn't one or is malformed.
362pub fn read_procedure_advertisement(r: &Record) -> Result<ProcedureAdvertisement, ReadRecordError> {
363    if r.record_type != TYPE_PROCEDURE_ADVERTISEMENT {
364        return Err(ReadRecordError::WrongRecordType);
365    }
366    let procedure_uri = match r.payload.get("procedure_uri") {
367        Some(Value::Text(t)) => t.clone(),
368        Some(_) => return Err(ReadRecordError::WrongFieldType("procedure_uri")),
369        None => return Err(ReadRecordError::MissingField("procedure_uri")),
370    };
371    let advertiser_node = bytes32_field(&r.payload, "advertiser_node")?;
372    let serving_station = bytes32_field(&r.payload, "serving_station")?;
373    // Absent is valid, not an error — the common, unmanaged-realm case.
374    let cert_chain = match r.payload.get("cert_chain") {
375        Some(Value::Bytes(b)) => Some(b.clone()),
376        _ => None,
377    };
378    Ok(ProcedureAdvertisement {
379        procedure_uri,
380        advertiser_node,
381        serving_station,
382        cert_chain,
383    })
384}
385
386/// Extracts a `station_endpoint` record's typed fields, or an error if `r`
387/// isn't one or is malformed.
388pub fn read_station_endpoint(r: &Record) -> Result<StationEndpoint, ReadRecordError> {
389    if r.record_type != TYPE_STATION_ENDPOINT {
390        return Err(ReadRecordError::WrongRecordType);
391    }
392    let quic_port = match r.payload.get("quic_port") {
393        Some(Value::Int(n)) if (1..=65535).contains(n) => *n as u16,
394        Some(_) => return Err(ReadRecordError::WrongFieldType("quic_port")),
395        None => return Err(ReadRecordError::MissingField("quic_port")),
396    };
397    // `macula_record.erl`'s `with_host_list/2` puts each host in as a bare
398    // Erlang binary, unlike every other string field in this record (which
399    // wraps with `{text, Bin}`) — so on the wire these are CBOR BYTE
400    // strings (major type 2), not text strings, confirmed against a real
401    // station's own published record while building `macula-go`'s
402    // equivalent. Try bytes first, text as a fallback in case a future
403    // publisher wraps these properly.
404    let host_advertised = match r.payload.get("host_advertised") {
405        Some(Value::List(items)) => items
406            .iter()
407            .filter_map(|item| match item {
408                Value::Bytes(b) => String::from_utf8(b.clone()).ok(),
409                Value::Text(t) => Some(t.clone()),
410                _ => None,
411            })
412            .collect(),
413        _ => Vec::new(),
414    };
415    Ok(StationEndpoint {
416        quic_port,
417        host_advertised,
418    })
419}
420
421fn bytes32_field(v: &Value, name: &'static str) -> Result<[u8; 32], ReadRecordError> {
422    match v.get(name) {
423        Some(Value::Bytes(b)) => b
424            .as_slice()
425            .try_into()
426            .map_err(|_| ReadRecordError::WrongFieldType(name)),
427        Some(_) => Err(ReadRecordError::WrongFieldType(name)),
428        None => Err(ReadRecordError::MissingField(name)),
429    }
430}
431
432// ---------------------------------------------------------------------
433// Thin RPC wrappers over the mesh's `_dht.*` procedures.
434// ---------------------------------------------------------------------
435
436/// The all-zero 32-byte realm DHT traffic travels under, protocol-internal
437/// infrastructure — matches `macula.erl`'s `?DHT_REALM`.
438const DHT_REALM: [u8; 32] = [0u8; 32];
439
440/// Matches `macula.erl`'s `?DHT_RECORD_TIMEOUT_MS`.
441const DHT_TIMEOUT: Duration = Duration::from_secs(5);
442
443const PUT_RECORD_PROC: &str = "_dht.put_record";
444const FIND_RECORD_PROC: &str = "_dht.find_record";
445const FIND_RECORDS_PROC: &str = "_dht.find_records";
446const FIND_RECORDS_BY_TYPE_PROC: &str = "_dht.find_records_by_type";
447
448/// The FULL-field-name map `macula.erl`'s `put_record/2` sends as a CALL's
449/// args (and `find_record`/`find_records` return as a RESULT) — distinct
450/// from [`canonical_unsigned`]'s compact single-letter envelope, which
451/// exists only to be signed/verified, never sent as such.
452fn to_rpc_value(r: &Record) -> Value {
453    let mut entries = vec![
454        (Value::text("type"), Value::Int(r.record_type as i128)),
455        (Value::text("key"), Value::Bytes(r.key.to_vec())),
456        (Value::text("version"), Value::Bytes(r.version.to_vec())),
457        (Value::text("created_at"), Value::Int(r.created_at)),
458        (Value::text("expires_at"), Value::Int(r.expires_at)),
459        (Value::text("payload"), r.payload.clone()),
460    ];
461    if r.signature.len() == 64 {
462        entries.push((Value::text("signature"), Value::Bytes(r.signature.clone())));
463    }
464    Value::Map(entries)
465}
466
467#[derive(Debug, PartialEq, Eq)]
468pub enum RecordFromRpcError {
469    MissingField(&'static str),
470    WrongFieldType(&'static str),
471}
472
473impl std::fmt::Display for RecordFromRpcError {
474    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475        match self {
476            RecordFromRpcError::MissingField(name) => write!(f, "dht: missing field {name:?}"),
477            RecordFromRpcError::WrongFieldType(name) => {
478                write!(f, "dht: field {name:?} has the wrong type")
479            }
480        }
481    }
482}
483
484impl std::error::Error for RecordFromRpcError {}
485
486fn record_from_rpc_value(v: &Value) -> Result<Record, RecordFromRpcError> {
487    let record_type = match v.get("type") {
488        Some(Value::Int(n)) if (0..=255).contains(n) => *n as u8,
489        Some(_) => return Err(RecordFromRpcError::WrongFieldType("type")),
490        None => return Err(RecordFromRpcError::MissingField("type")),
491    };
492    let key = match v.get("key") {
493        Some(Value::Bytes(b)) => b
494            .as_slice()
495            .try_into()
496            .map_err(|_| RecordFromRpcError::WrongFieldType("key"))?,
497        Some(_) => return Err(RecordFromRpcError::WrongFieldType("key")),
498        None => return Err(RecordFromRpcError::MissingField("key")),
499    };
500    let version = match v.get("version") {
501        Some(Value::Bytes(b)) => b
502            .as_slice()
503            .try_into()
504            .map_err(|_| RecordFromRpcError::WrongFieldType("version"))?,
505        Some(_) => return Err(RecordFromRpcError::WrongFieldType("version")),
506        None => return Err(RecordFromRpcError::MissingField("version")),
507    };
508    let created_at = match v.get("created_at") {
509        Some(Value::Int(n)) => *n,
510        Some(_) => return Err(RecordFromRpcError::WrongFieldType("created_at")),
511        None => return Err(RecordFromRpcError::MissingField("created_at")),
512    };
513    let expires_at = match v.get("expires_at") {
514        Some(Value::Int(n)) => *n,
515        Some(_) => return Err(RecordFromRpcError::WrongFieldType("expires_at")),
516        None => return Err(RecordFromRpcError::MissingField("expires_at")),
517    };
518    let payload = v
519        .get("payload")
520        .cloned()
521        .ok_or(RecordFromRpcError::MissingField("payload"))?;
522    let signature = match v.get("signature") {
523        Some(Value::Bytes(b)) => b.clone(),
524        _ => Vec::new(),
525    };
526    Ok(Record {
527        record_type,
528        key,
529        version,
530        created_at,
531        expires_at,
532        payload,
533        signature,
534    })
535}
536
537#[derive(Debug)]
538pub enum DhtError {
539    Call(CallError),
540    /// The station answered with an ERROR frame — carries its `name`.
541    Remote(String),
542    NotFound,
543    Malformed(RecordFromRpcError),
544    /// The RESULT payload wasn't the list shape `find_records`/
545    /// `find_records_by_type` are expected to return.
546    ExpectedList,
547}
548
549impl std::fmt::Display for DhtError {
550    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551        match self {
552            DhtError::Call(e) => write!(f, "dht: {e}"),
553            DhtError::Remote(name) => write!(f, "dht: station reported {name}"),
554            DhtError::NotFound => write!(f, "dht: record not found"),
555            DhtError::Malformed(e) => write!(f, "dht: {e}"),
556            DhtError::ExpectedList => write!(f, "dht: expected a list reply"),
557        }
558    }
559}
560
561impl std::error::Error for DhtError {}
562
563fn deadline_ms(timeout: Duration) -> i128 {
564    now_ms() + timeout.as_millis() as i128
565}
566
567/// Stores a signed record in the mesh DHT. Mirrors `macula:put_record/2` —
568/// the relay validates the signature on receipt.
569pub async fn put_record(session: &mut Session, id: &KeyPair, rec: &Record) -> Result<(), DhtError> {
570    let resp = session
571        .call(
572            PUT_RECORD_PROC,
573            DHT_REALM,
574            to_rpc_value(rec),
575            deadline_ms(DHT_TIMEOUT),
576            id,
577            DHT_TIMEOUT,
578        )
579        .await
580        .map_err(DhtError::Call)?;
581    match resp {
582        CallResponse::Result { .. } => Ok(()),
583        CallResponse::Error { name, .. } => Err(DhtError::Remote(name)),
584    }
585}
586
587/// Fetches one record by its storage key (see [`procedure_key`] /
588/// [`station_endpoint_key`]). Returns [`DhtError::NotFound`] if none
589/// exists — the caller's signature should still be checked via [`verify`]
590/// before the payload is trusted; this function does not verify on the
591/// caller's behalf.
592pub async fn find_record(
593    session: &mut Session,
594    id: &KeyPair,
595    key: [u8; 32],
596) -> Result<Record, DhtError> {
597    let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]);
598    let resp = session
599        .call(
600            FIND_RECORD_PROC,
601            DHT_REALM,
602            args,
603            deadline_ms(DHT_TIMEOUT),
604            id,
605            DHT_TIMEOUT,
606        )
607        .await
608        .map_err(DhtError::Call)?;
609    match resp {
610        CallResponse::Result { payload, .. } => {
611            if matches!(&payload, Value::Text(t) if t == "not_found") {
612                return Err(DhtError::NotFound);
613            }
614            record_from_rpc_value(&payload).map_err(DhtError::Malformed)
615        }
616        CallResponse::Error { name, .. } => Err(DhtError::Remote(name)),
617    }
618}
619
620/// Fetches every record stored at `key` — the full signer-deduped multiset
621/// (e.g. every `procedure_advertisement` for one procedure). Each record's
622/// signature should be verified via [`verify`] before its payload is
623/// trusted; this function does not verify on the caller's behalf.
624pub async fn find_records(
625    session: &mut Session,
626    id: &KeyPair,
627    key: [u8; 32],
628) -> Result<Vec<Record>, DhtError> {
629    let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]);
630    let resp = session
631        .call(
632            FIND_RECORDS_PROC,
633            DHT_REALM,
634            args,
635            deadline_ms(DHT_TIMEOUT),
636            id,
637            DHT_TIMEOUT,
638        )
639        .await
640        .map_err(DhtError::Call)?;
641    records_list_from_response(resp)
642}
643
644/// Returns every record of `typ` currently visible from the station this
645/// session is connected to. Coverage depends on that station's own view of
646/// the DHT. Mirrors `macula:find_records_by_type/2`.
647pub async fn find_records_by_type(
648    session: &mut Session,
649    id: &KeyPair,
650    typ: u8,
651) -> Result<Vec<Record>, DhtError> {
652    let args = Value::Map(vec![(Value::text("type"), Value::Int(typ as i128))]);
653    let resp = session
654        .call(
655            FIND_RECORDS_BY_TYPE_PROC,
656            DHT_REALM,
657            args,
658            deadline_ms(DHT_TIMEOUT),
659            id,
660            DHT_TIMEOUT,
661        )
662        .await
663        .map_err(DhtError::Call)?;
664    records_list_from_response(resp)
665}
666
667fn records_list_from_response(resp: CallResponse) -> Result<Vec<Record>, DhtError> {
668    match resp {
669        CallResponse::Result { payload, .. } => match payload {
670            Value::List(items) => Ok(items
671                .iter()
672                .filter_map(|item| record_from_rpc_value(item).ok())
673                .collect()),
674            _ => Err(DhtError::ExpectedList),
675        },
676        CallResponse::Error { name, .. } => Err(DhtError::Remote(name)),
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    fn sample_advertisement(id: &KeyPair) -> Record {
685        let station: [u8; 32] = [7u8; 32];
686        let uri = discovery_uri([0u8; 32], "test.procedure");
687        let rec = new_procedure_advertisement(id.node_id(), uri, station, DEFAULT_TTL);
688        sign(rec, id)
689    }
690
691    #[test]
692    fn sign_then_verify_round_trips() {
693        let id = KeyPair::generate();
694        let rec = sample_advertisement(&id);
695        assert_eq!(rec.signature.len(), 64);
696        assert!(verify(&rec).is_ok());
697    }
698
699    #[test]
700    fn verify_rejects_a_tampered_payload() {
701        let id = KeyPair::generate();
702        let mut rec = sample_advertisement(&id);
703        // Flip the record's advertised type after signing -- the signature
704        // covers record_type, so this must invalidate it.
705        rec.record_type = TYPE_STATION_ENDPOINT;
706        assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature));
707    }
708
709    #[test]
710    fn verify_rejects_a_signature_from_the_wrong_signer() {
711        let signer = KeyPair::generate();
712        let mut rec = sample_advertisement(&signer);
713        // The envelope's own `key` field claims a DIFFERENT signer than
714        // the one that actually produced `signature` -- verify checks the
715        // signature against `key`, so this must fail.
716        rec.key = KeyPair::generate().public_bytes();
717        assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature));
718    }
719
720    #[test]
721    fn verify_rejects_an_expired_record() {
722        let id = KeyPair::generate();
723        let station: [u8; 32] = [7u8; 32];
724        let uri = discovery_uri([0u8; 32], "test.procedure");
725        // A TTL that has already elapsed by the time verify() runs.
726        let rec = new_procedure_advertisement(id.node_id(), uri, station, Duration::from_millis(1));
727        std::thread::sleep(Duration::from_millis(20));
728        let rec = sign(rec, &id);
729        assert_eq!(verify(&rec), Err(VerifyError::Expired));
730    }
731
732    #[test]
733    fn canonical_unsigned_is_deterministic() {
734        let id = KeyPair::generate();
735        let rec = sample_advertisement(&id);
736        // Re-deriving the same bytes from the same (already-built) record
737        // must always agree -- this is exactly what a verifier on the
738        // other end of the wire independently recomputes.
739        assert_eq!(canonical_unsigned(&rec), canonical_unsigned(&rec));
740    }
741
742    #[test]
743    fn procedure_key_differs_by_realm() {
744        let a = procedure_key(&discovery_uri([0u8; 32], "same.name"));
745        let b = procedure_key(&discovery_uri([1u8; 32], "same.name"));
746        assert_ne!(
747            a, b,
748            "the same bare procedure name under different realms must not collide"
749        );
750    }
751
752    #[test]
753    fn discovery_uri_matches_expected_hex_format() {
754        let uri = discovery_uri([0u8; 32], "hecate_mail.initiate_mailbox");
755        assert_eq!(
756            uri,
757            format!("{}/hecate_mail.initiate_mailbox", "00".repeat(32))
758        );
759    }
760
761    #[test]
762    fn read_procedure_advertisement_round_trips_the_payload() {
763        let id = KeyPair::generate();
764        let station: [u8; 32] = [9u8; 32];
765        let uri = "0".repeat(64) + "/some.procedure";
766        let rec = new_procedure_advertisement(id.node_id(), uri.clone(), station, DEFAULT_TTL);
767        let read = read_procedure_advertisement(&rec).expect("should read back cleanly");
768        assert_eq!(read.procedure_uri, uri);
769        assert_eq!(read.advertiser_node, id.node_id());
770        assert_eq!(read.serving_station, station);
771    }
772
773    #[test]
774    fn read_procedure_advertisement_rejects_the_wrong_record_type() {
775        let id = KeyPair::generate();
776        let station: [u8; 32] = [9u8; 32];
777        let mut rec = new_procedure_advertisement(id.node_id(), "x/y", station, DEFAULT_TTL);
778        rec.record_type = TYPE_STATION_ENDPOINT;
779        assert!(matches!(
780            read_procedure_advertisement(&rec),
781            Err(ReadRecordError::WrongRecordType)
782        ));
783    }
784
785    #[test]
786    fn station_endpoint_host_advertised_reads_byte_string_entries() {
787        // macula_record.erl's with_host_list/2 puts each host in as a bare
788        // Erlang binary -- on the wire these decode as CBOR byte strings
789        // (major type 2), not text, confirmed against a real station's own
790        // published record while building macula-go's equivalent. This
791        // guards that this crate reads that shape too, not just a
792        // hypothetical text-wrapped one.
793        let rec = Record {
794            record_type: TYPE_STATION_ENDPOINT,
795            key: [1u8; 32],
796            version: [0u8; 16],
797            created_at: 0,
798            expires_at: 0,
799            payload: Value::Map(vec![
800                (Value::text("quic_port"), Value::Int(4433)),
801                (
802                    Value::text("host_advertised"),
803                    Value::List(vec![Value::Bytes(b"203.0.113.5".to_vec())]),
804                ),
805            ]),
806            signature: Vec::new(),
807        };
808        let ep = read_station_endpoint(&rec).expect("should read the byte-string host");
809        assert_eq!(ep.quic_port, 4433);
810        assert_eq!(ep.host_advertised, vec!["203.0.113.5".to_string()]);
811    }
812
813    #[test]
814    fn to_rpc_value_and_record_from_rpc_value_round_trip() {
815        let id = KeyPair::generate();
816        let rec = sample_advertisement(&id);
817        let rpc_value = to_rpc_value(&rec);
818        let back = record_from_rpc_value(&rpc_value).expect("should decode cleanly");
819        assert_eq!(back.record_type, rec.record_type);
820        assert_eq!(back.key, rec.key);
821        assert_eq!(back.version, rec.version);
822        assert_eq!(back.created_at, rec.created_at);
823        assert_eq!(back.expires_at, rec.expires_at);
824        assert_eq!(back.signature, rec.signature);
825        // The payload survives the RPC round trip byte-for-byte-equivalent
826        // even though it isn't compared via canonical_unsigned here.
827        assert!(verify(&back).is_ok());
828    }
829}