diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ab7ba..c09b509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Added +- **Get-or-closer request** (ant-node ADR-0020): Kademlia's `FIND_VALUE` + for chunks. `ChunkMessageBody::{GetOrCloserRequest, GetOrCloserResponse}` + carry `ChunkGetOrCloserRequest` and `ChunkGetOrCloserResponse`. A node that + holds the chunk answers `Found` with it; one that does not answers `Closer` + with its closest known peers. Those peers are an opaque payload that + saorsa-core encodes and checks as a `FIND_NODE` answer. The variants are + appended after every existing one, so older discriminants keep their wire + values. +- `GET_OR_CLOSER_AGENT_TOKEN` and `advertises_get_or_closer`: a node built + before the request drops it without a reply, so a client sends it only to + a peer whose user agent carries this token. + + - **Pointers** (`pointer`): a paid, mutable, owner-signed reference. One 5,303-byte record with the ML-DSA-65 owner key inlined, addressed at `BLAKE3::derive_key("autonomi.pointer.address.v1", owner)` — so a pointer is diff --git a/src/chunk.rs b/src/chunk.rs index b432a7e..8e1e1a4 100644 --- a/src/chunk.rs +++ b/src/chunk.rs @@ -28,6 +28,14 @@ pub const MAX_WIRE_MESSAGE_SIZE: usize = 5 * 1024 * 1024; /// Data type identifier for chunks. pub const DATA_TYPE_CHUNK: u32 = 0; +/// User-agent token a node announces when it answers +/// [`ChunkMessageBody::GetOrCloserRequest`] (ant-node ADR-0020). +/// +/// Native QUIC peers exchange no capabilities, and a node built before that +/// request existed drops it without a reply. A client sends it only to a peer +/// whose user agent carries this token; see [`advertises_get_or_closer`]. +pub const GET_OR_CLOSER_AGENT_TOKEN: &str = "get-or-closer/1"; + /// Settlement rules this build pays and verifies under. /// /// Separate from [`PROTOCOL_VERSION`] on purpose. That one tracks the *wire*: @@ -183,6 +191,25 @@ pub enum ChunkMessageBody { PointerGetRequest(PointerGetRequest), /// Response to a pointer GET. PointerGetResponse(PointerGetResponse), + /// Request a chunk, or the responder's closest peers to it when it does + /// not hold the chunk: Kademlia's `FIND_VALUE` (ant-node ADR-0020). + /// + /// Appended after every existing variant for the reason given on + /// [`Self::QuoteRequestV2`]. A node built before this variant drops it + /// without a reply, so a client sends it only to a peer whose user agent + /// carries [`GET_OR_CLOSER_AGENT_TOKEN`]. + GetOrCloserRequest(ChunkGetOrCloserRequest), + /// Response to a get-or-closer request. + GetOrCloserResponse(ChunkGetOrCloserResponse), +} + +/// Whether a peer's user agent says it answers +/// [`ChunkMessageBody::GetOrCloserRequest`]. +#[must_use] +pub fn advertises_get_or_closer(user_agent: &str) -> bool { + user_agent + .split_whitespace() + .any(|token| token == GET_OR_CLOSER_AGENT_TOKEN) } // ============================================================================= @@ -449,6 +476,53 @@ pub enum ChunkGetResponse { Error(ProtocolError), } +// ============================================================================= +// Get-or-closer Request/Response +// ============================================================================= + +/// Request a chunk, or the responder's closest peers to it when it does not +/// hold the chunk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChunkGetOrCloserRequest { + /// The content-addressed identifier to retrieve. + pub address: XorName, +} + +impl ChunkGetOrCloserRequest { + /// Create a new get-or-closer request. + #[must_use] + pub const fn new(address: XorName) -> Self { + Self { address } + } +} + +/// Response to a get-or-closer request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ChunkGetOrCloserResponse { + /// The responder holds the chunk. + Found { + /// The chunk address. + address: XorName, + /// The chunk data. + content: Bytes, + }, + /// The responder does not hold the chunk. + Closer { + /// The requested address. + address: XorName, + /// The responder's closest known peers to `address`, as its `FIND_NODE` + /// answer to the requester would hold them. + /// + /// Encoded by saorsa-core's `DhtNetworkManager::encode_closer_peers` + /// and checked by `decode_closer_peers`. Opaque here, so the peer + /// format is versioned in one place, as it is for `FIND_NODE`. + peers: Bytes, + }, + /// An error occurred. + Error(ProtocolError), +} + // ============================================================================= // Quote Request/Response // ============================================================================= @@ -1375,4 +1449,111 @@ mod tests { // The whole point is that refusing to quote costs the user nothing. assert!(rendered.contains("nothing was charged"), "{rendered}"); } + + #[test] + fn test_get_or_closer_request_encode_decode() { + let address = [0x61; 32]; + let msg = ChunkMessage { + request_id: 600, + body: ChunkMessageBody::GetOrCloserRequest(ChunkGetOrCloserRequest::new(address)), + }; + + let encoded = msg.encode().expect("encode should succeed"); + let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); + + assert_eq!(decoded.request_id, 600); + let ChunkMessageBody::GetOrCloserRequest(request) = decoded.body else { + panic!("expected GetOrCloserRequest"); + }; + assert_eq!(request.address, address); + } + + #[test] + fn test_get_or_closer_responses_encode_decode() { + let address = [0x62; 32]; + let found = ChunkMessage { + request_id: 601, + body: ChunkMessageBody::GetOrCloserResponse(ChunkGetOrCloserResponse::Found { + address, + content: Bytes::from_static(b"chunk"), + }), + }; + let decoded = ChunkMessage::decode(&found.encode().expect("encode")).expect("decode"); + let ChunkMessageBody::GetOrCloserResponse(ChunkGetOrCloserResponse::Found { + address: found_address, + content, + }) = decoded.body + else { + panic!("expected Found"); + }; + assert_eq!(found_address, address); + assert_eq!(content.as_ref(), b"chunk"); + + let closer = ChunkMessage { + request_id: 602, + body: ChunkMessageBody::GetOrCloserResponse(ChunkGetOrCloserResponse::Closer { + address, + peers: Bytes::from_static(&[1, 2, 3]), + }), + }; + let decoded = ChunkMessage::decode(&closer.encode().expect("encode")).expect("decode"); + let ChunkMessageBody::GetOrCloserResponse(ChunkGetOrCloserResponse::Closer { + address: closer_address, + peers, + }) = decoded.body + else { + panic!("expected Closer"); + }; + assert_eq!(closer_address, address); + assert_eq!(peers.as_ref(), &[1, 2, 3]); + } + + /// Variants are only ever appended, so a peer built before a variant + /// existed still reads every older one at its old wire value. + #[test] + fn test_message_discriminants_keep_their_wire_values() { + let address = [0x63; 32]; + let discriminant = |body: ChunkMessageBody| { + let encoded = ChunkMessage { + request_id: 0, + body, + } + .encode() + .expect("encode should succeed"); + // A zero request id is one varint byte; the variant index follows. + encoded[1] + }; + assert_eq!( + discriminant(ChunkMessageBody::GetRequest(ChunkGetRequest::new(address))), + 2 + ); + assert_eq!( + discriminant(ChunkMessageBody::PointerGetRequest(PointerGetRequest::new( + address + ))), + 12 + ); + assert_eq!( + discriminant(ChunkMessageBody::GetOrCloserRequest( + ChunkGetOrCloserRequest::new(address) + )), + 14 + ); + assert_eq!( + discriminant(ChunkMessageBody::GetOrCloserResponse( + ChunkGetOrCloserResponse::Error(ProtocolError::Internal("x".to_string())) + )), + 15 + ); + } + + #[test] + fn test_advertises_get_or_closer() { + assert!(advertises_get_or_closer( + "node/0.22.0 migration/files get-or-closer/1" + )); + assert!(!advertises_get_or_closer("node/0.21.0 migration/files")); + assert!(!advertises_get_or_closer("node/0.22.0 get-or-closer/10")); + assert!(!advertises_get_or_closer("")); + } } diff --git a/src/lib.rs b/src/lib.rs index cf774bc..9f54e49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,15 +56,16 @@ pub mod pointer; // ============================================================================= pub use chunk::{ - client_update_required_message, settlement_compatibility, ChunkGetRequest, ChunkGetResponse, + advertises_get_or_closer, client_update_required_message, settlement_compatibility, + ChunkGetOrCloserRequest, ChunkGetOrCloserResponse, ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteRequestV2, ChunkQuoteResponse, MerkleCandidateQuoteRequest, MerkleCandidateQuoteRequestV2, MerkleCandidateQuoteResponse, PointerGetRequest, PointerGetResponse, PointerPutRequest, PointerPutResponse, ProtocolError, SettlementCompatibility, XorName, CHUNK_PROTOCOL_ID, CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE, - CURRENT_SETTLEMENT_VERSION, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, - MIN_SUPPORTED_SETTLEMENT_VERSION, PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, - XORNAME_LEN, + CURRENT_SETTLEMENT_VERSION, DATA_TYPE_CHUNK, GET_OR_CLOSER_AGENT_TOKEN, MAX_CHUNK_SIZE, + MAX_WIRE_MESSAGE_SIZE, MIN_SUPPORTED_SETTLEMENT_VERSION, PROOF_TAG_MERKLE, + PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, XORNAME_LEN, }; #[cfg(feature = "native")] pub use chunk_protocol::{