Files
frid/crates/federation-net/src/ticket.rs
T
2026-07-10 12:45:47 +03:00

194 lines
6.3 KiB
Rust

//! Peer tickets: self-contained connection invitations.
use std::fmt;
use std::str::FromStr;
use iroh::{EndpointAddr, EndpointId};
use iroh_tickets::{ParseError, Ticket};
use serde::{Deserialize, Serialize};
use crate::protocol::{NetworkId, SchemaId};
/// Current version of the ticket wire format.
pub const TICKET_VERSION: u16 = 1;
/// Maximum size of a decoded ticket in bytes.
const MAX_TICKET_BYTES: usize = 8 * 1024;
/// Maximum length of a ticket string accepted by [`PeerTicket::from_str`].
const MAX_TICKET_STRING_LEN: usize = 16 * 1024;
/// A shareable invitation to connect to a peer.
///
/// The ticket contains everything needed to dial the peer over Iroh, plus the
/// network and schema identifiers so incompatibility is detected before any
/// domain message is exchanged.
///
/// Tickets serialize to a string with the `fnet` prefix via [`fmt::Display`]
/// and parse back via [`FromStr`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerTicket {
/// Version of the ticket format itself.
pub ticket_version: u16,
/// Version of the application-level wire protocol the peer speaks.
pub protocol_version: u16,
/// Network the issuing peer participates in.
pub network_id: NetworkId,
/// Message schema the issuing peer uses.
pub schema_id: SchemaId,
/// Iroh address of the issuing peer.
pub endpoint_addr: EndpointAddr,
}
impl PeerTicket {
/// Returns the endpoint id of the peer this ticket points to.
pub fn endpoint_id(&self) -> EndpointId {
self.endpoint_addr.id
}
}
/// Versioned body of the ticket; everything after the leading version number.
#[derive(Serialize, Deserialize)]
struct TicketBody {
protocol_version: u16,
network_id: NetworkId,
schema_id: SchemaId,
endpoint_addr: EndpointAddr,
}
impl Ticket for PeerTicket {
const KIND: &'static str = "fnet";
fn encode_bytes(&self) -> Vec<u8> {
let body = TicketBody {
protocol_version: self.protocol_version,
network_id: self.network_id,
schema_id: self.schema_id,
endpoint_addr: self.endpoint_addr.clone(),
};
// Serializing plain owned data into a growable Vec is infallible;
// postcard only errors on unsupported types or fixed-size buffers.
postcard::to_stdvec(&(self.ticket_version, body))
.expect("postcard serialization of a ticket into a Vec cannot fail")
}
fn decode_bytes(bytes: &[u8]) -> Result<Self, ParseError> {
if bytes.len() > MAX_TICKET_BYTES {
return Err(ParseError::verification_failed(
"ticket exceeds the maximum allowed size",
));
}
let (ticket_version, rest) = postcard::take_from_bytes::<u16>(bytes)?;
if ticket_version != TICKET_VERSION {
return Err(ParseError::verification_failed(
"unsupported ticket version",
));
}
let body: TicketBody = postcard::from_bytes(rest)?;
Ok(Self {
ticket_version,
protocol_version: body.protocol_version,
network_id: body.network_id,
schema_id: body.schema_id,
endpoint_addr: body.endpoint_addr,
})
}
}
impl fmt::Display for PeerTicket {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&Ticket::encode_string(self))
}
}
impl FromStr for PeerTicket {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > MAX_TICKET_STRING_LEN {
return Err(ParseError::verification_failed(
"ticket string exceeds the maximum allowed length",
));
}
Ticket::decode_string(s)
}
}
#[cfg(test)]
mod tests {
use std::net::{Ipv4Addr, SocketAddr};
use iroh::SecretKey;
use super::*;
fn sample_ticket() -> PeerTicket {
let endpoint_id = SecretKey::generate().public();
let addr = EndpointAddr::new(endpoint_id)
.with_ip_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 4242)));
PeerTicket {
ticket_version: TICKET_VERSION,
protocol_version: 1,
network_id: NetworkId::from_name("test-network"),
schema_id: SchemaId::from_name("test-schema"),
endpoint_addr: addr,
}
}
#[test]
fn ticket_string_round_trip() {
let ticket = sample_ticket();
let encoded = ticket.to_string();
assert!(encoded.starts_with("fnet"));
let decoded: PeerTicket = encoded.parse().expect("parse ticket");
assert_eq!(decoded, ticket);
// The canonical string form must be stable across round-trips.
assert_eq!(decoded.to_string(), encoded);
}
#[test]
fn corrupted_ticket_is_rejected() {
let ticket = sample_ticket();
let mut encoded = ticket.to_string();
// Truncate the payload; the result must not parse.
encoded.truncate(encoded.len() - 10);
assert!(encoded.parse::<PeerTicket>().is_err());
// Corrupt the alphabet: '!' is not valid base32.
let corrupted = format!("fnet!{}", &ticket.to_string()[5..]);
assert!(corrupted.parse::<PeerTicket>().is_err());
}
#[test]
fn wrong_prefix_is_rejected() {
let ticket = sample_ticket();
let encoded = ticket.to_string();
let renamed = format!("blob{}", &encoded[4..]);
assert!(renamed.parse::<PeerTicket>().is_err());
}
#[test]
fn unknown_ticket_version_is_rejected() {
let ticket = sample_ticket();
let body = TicketBody {
protocol_version: ticket.protocol_version,
network_id: ticket.network_id,
schema_id: ticket.schema_id,
endpoint_addr: ticket.endpoint_addr.clone(),
};
let bytes = postcard::to_stdvec(&(99u16, body)).expect("encode");
let mut encoded = String::from("fnet");
data_encoding::BASE32_NOPAD.encode_append(&bytes, &mut encoded);
encoded.make_ascii_lowercase();
assert!(encoded.parse::<PeerTicket>().is_err());
}
#[test]
fn oversized_ticket_is_rejected() {
let bytes = vec![0u8; MAX_TICKET_BYTES + 1];
assert!(PeerTicket::decode_bytes(&bytes).is_err());
let huge = "fnet".to_string() + &"a".repeat(MAX_TICKET_STRING_LEN);
assert!(huge.parse::<PeerTicket>().is_err());
}
}