Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/sync/
ticket.rs

1//! URL-based shareable database link.
2//!
3//! A `DatabaseTicket` encodes a database ID and optional peer address hints
4//! into a compact URL that can be shared between peers.
5//!
6//! # URL Format
7//!
8//! Magnet-style URI with the database ID and peer addresses as query parameters:
9//!
10//! ```text
11//! eidetica:?db=<database_id>&pr=<peer_address>&pr=<peer_address>
12//! ```
13//!
14//! - **`db`** (required): The database ID, passed through as-is (e.g., `sha256:hex`)
15//! - **`pr`** (optional, repeatable): A peer address prefixed by its transport
16//!   type (e.g., `http:host:port`, `iroh:endpointABC...`)
17//!
18//! # Examples
19//!
20//! ```
21//! # use eidetica::sync::{DatabaseTicket, Address};
22//! # use eidetica::entry::ID;
23//! let id = ID::from_bytes(b"example database id");
24//! let ticket = DatabaseTicket::new(id);
25//! let url = ticket.to_string();
26//! assert!(url.starts_with("eidetica:?db="));
27//!
28//! let parsed: DatabaseTicket = url.parse().unwrap();
29//! assert_eq!(parsed.database_id(), ticket.database_id());
30//! ```
31
32use std::borrow::Cow;
33use std::fmt;
34use std::str::FromStr;
35
36use serde::{Deserialize, Serialize};
37
38use crate::Result;
39use crate::entry::ID;
40use crate::sync::SyncError;
41use crate::sync::peer_types::Address;
42
43/// Ticket URI scheme and query separator (`eidetica:?`).
44const SCHEME: &str = "eidetica:?";
45
46/// Query parameter key for the database ID.
47const DB_PARAM: &str = "db";
48
49/// Query parameter key for peer address hints.
50const PR_PARAM: &str = "pr";
51
52/// A shareable link containing a database ID and optional transport address hints.
53///
54/// `DatabaseTicket` can be serialized to and parsed from a magnet-style URI:
55///
56/// ```text
57/// eidetica:?db=sha256:abc...&pr=http:192.168.1.1:8080&pr=iroh:endpointABC...
58/// ```
59///
60/// The database ID is passed through as an opaque string. If `db` appears
61/// more than once, the last value wins. Peer addresses use the transport's
62/// native encoding, prefixed by the transport name and a colon if the
63/// encoding doesn't already include it.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(into = "String", try_from = "String")]
66pub struct DatabaseTicket {
67    database_id: ID,
68    addresses: Vec<Address>,
69}
70
71impl DatabaseTicket {
72    /// Create a ticket with a database ID and no address hints.
73    pub fn new(database_id: ID) -> Self {
74        Self {
75            database_id,
76            addresses: Vec::new(),
77        }
78    }
79
80    /// Create a ticket with a database ID and address hints.
81    pub fn with_addresses(database_id: ID, addresses: Vec<Address>) -> Self {
82        Self {
83            database_id,
84            addresses,
85        }
86    }
87
88    /// Get the database ID.
89    pub fn database_id(&self) -> &ID {
90        &self.database_id
91    }
92
93    /// Get the transport address hints.
94    pub fn addresses(&self) -> &[Address] {
95        &self.addresses
96    }
97
98    /// Add a transport address hint.
99    pub fn add_address(&mut self, address: Address) {
100        self.addresses.push(address);
101    }
102}
103
104/// Minimally encode a query-parameter value.
105///
106/// Only the characters that are structurally significant inside a query string
107/// (`&`, `=`, `#`, `+`) and the escape character (`%`) are percent-encoded.
108/// Everything else — including `:` — passes through verbatim, keeping tickets
109/// human-readable.
110///
111/// Spaces are not encoded because database IDs and transport addresses do not
112/// contain them. The `url::form_urlencoded::parse` decoder used in `FromStr`
113/// treats `+` as a space (per the `application/x-www-form-urlencoded` spec),
114/// so we encode literal `+` as `%2B` above to avoid that ambiguity. Tickets
115/// produced by other implementations that percent-encode more aggressively
116/// (e.g., encoding `:` or `/`) are accepted because the parser uses
117/// `form_urlencoded::parse` which handles all standard percent-encoding.
118fn encode_query_value(s: &str) -> Cow<'_, str> {
119    if !s.contains(['%', '&', '=', '#', '+']) {
120        return Cow::Borrowed(s);
121    }
122    let mut out = String::with_capacity(s.len());
123    for ch in s.chars() {
124        match ch {
125            '%' => out.push_str("%25"),
126            '&' => out.push_str("%26"),
127            '=' => out.push_str("%3D"),
128            '#' => out.push_str("%23"),
129            '+' => out.push_str("%2B"),
130            _ => out.push(ch),
131        }
132    }
133    Cow::Owned(out)
134}
135
136/// Format a transport [`Address`] as a `type:value` string.
137///
138/// The transport name is prefixed before the transport's native encoding,
139/// separated by a single colon: `http:192.168.1.1:8080`,
140/// `iroh:endpointABC...`, etc.
141fn encode_address(addr: &Address) -> String {
142    format!("{}:{}", addr.transport_type, addr.address)
143}
144
145/// Parse a `type:value` string back into a transport [`Address`].
146///
147/// Splits on the first `:` to recover the transport name and the
148/// transport-specific address. Returns `None` for values without a `:`
149/// separator.
150fn decode_address(value: &str) -> Option<Address> {
151    let (transport_type, address) = value.split_once(':')?;
152    Some(Address::new(transport_type, address))
153}
154
155impl fmt::Display for DatabaseTicket {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        write!(
158            f,
159            "{SCHEME}{DB_PARAM}={}",
160            encode_query_value(&self.database_id.to_string())
161        )?;
162
163        for addr in &self.addresses {
164            let encoded = encode_address(addr);
165            write!(f, "&{PR_PARAM}={}", encode_query_value(&encoded))?;
166        }
167
168        Ok(())
169    }
170}
171
172impl FromStr for DatabaseTicket {
173    type Err = crate::Error;
174
175    fn from_str(s: &str) -> Result<Self> {
176        let query = s.strip_prefix(SCHEME).ok_or_else(|| {
177            let preview: String = s.chars().take(20).collect();
178            SyncError::InvalidAddress(format!("Expected '{SCHEME}' prefix, got: {preview}"))
179        })?;
180
181        let mut database_id = None;
182        let mut addresses = Vec::new();
183
184        for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
185            match key.as_ref() {
186                DB_PARAM => {
187                    // If `db` appears more than once, the last value wins.
188                    database_id = Some(ID::parse(&value)?);
189                }
190                PR_PARAM => {
191                    if let Some(addr) = decode_address(&value) {
192                        addresses.push(addr);
193                    }
194                    // Silently skip malformed pr values for forward compat
195                }
196                _ => {} // Unknown params ignored for forward compat
197            }
198        }
199
200        let database_id = database_id.ok_or_else(|| {
201            SyncError::InvalidAddress(format!("Ticket URL missing '{DB_PARAM}' parameter"))
202        })?;
203
204        Ok(Self {
205            database_id,
206            addresses,
207        })
208    }
209}
210
211impl From<DatabaseTicket> for String {
212    fn from(ticket: DatabaseTicket) -> Self {
213        ticket.to_string()
214    }
215}
216
217impl TryFrom<String> for DatabaseTicket {
218    type Error = crate::Error;
219
220    fn try_from(s: String) -> Result<Self> {
221        s.parse()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    const SHA256_HEX: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
230    const BLAKE3_HEX: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
231
232    /// Create an iroh Address in EndpointTicket format.
233    fn test_iroh_address() -> Address {
234        use iroh::{EndpointAddr, SecretKey, TransportAddr};
235        use iroh_tickets::{Ticket, endpoint::EndpointTicket};
236
237        let secret_key = SecretKey::from_bytes(&[1u8; 32]);
238        let endpoint_addr = EndpointAddr::from_parts(
239            secret_key.public(),
240            vec![TransportAddr::Ip("127.0.0.1:1234".parse().unwrap())],
241        );
242        Address::iroh(Ticket::serialize(&EndpointTicket::new(endpoint_addr)))
243    }
244
245    fn sha256_id() -> ID {
246        ID::from_bytes(SHA256_HEX.as_bytes())
247    }
248
249    fn blake3_id() -> ID {
250        use multihash_codetable::Code;
251        ID::from_bytes_with(BLAKE3_HEX.as_bytes(), Code::Blake3_256)
252    }
253
254    #[test]
255    fn round_trip_no_hints() {
256        let ticket = DatabaseTicket::new(sha256_id());
257        let url = ticket.to_string();
258        let parsed: DatabaseTicket = url.parse().unwrap();
259        assert_eq!(parsed.database_id(), ticket.database_id());
260        assert!(parsed.addresses().is_empty());
261    }
262
263    #[test]
264    fn round_trip_one_hint() {
265        let ticket =
266            DatabaseTicket::with_addresses(sha256_id(), vec![Address::http("192.168.1.1:8080")]);
267        let url = ticket.to_string();
268        let parsed: DatabaseTicket = url.parse().unwrap();
269        assert_eq!(parsed.database_id(), ticket.database_id());
270        assert_eq!(parsed.addresses().len(), 1);
271        assert_eq!(parsed.addresses()[0].transport_type, "http");
272        assert_eq!(parsed.addresses()[0].address, "192.168.1.1:8080");
273    }
274
275    #[test]
276    fn round_trip_multiple_hints() {
277        let iroh_addr = test_iroh_address();
278        let ticket = DatabaseTicket::with_addresses(
279            sha256_id(),
280            vec![iroh_addr.clone(), Address::http("192.168.1.1:8080")],
281        );
282        let url = ticket.to_string();
283        // Iroh address should be in EndpointTicket format in the URL
284        assert!(url.contains("pr=iroh:endpoint"));
285        let parsed: DatabaseTicket = url.parse().unwrap();
286        assert_eq!(parsed.database_id(), ticket.database_id());
287        assert_eq!(parsed.addresses().len(), 2);
288        assert_eq!(parsed.addresses()[0].transport_type, "iroh");
289        // Round-trip through EndpointTicket preserves the internal address
290        assert_eq!(parsed.addresses()[0].address, iroh_addr.address);
291        assert_eq!(parsed.addresses()[1].transport_type, "http");
292        assert_eq!(parsed.addresses()[1].address, "192.168.1.1:8080");
293    }
294
295    #[test]
296    fn database_id_passed_through_opaquely() {
297        // sha256 ID round-trips without transformation
298        let ticket = DatabaseTicket::new(sha256_id());
299        let url = ticket.to_string();
300        let parsed: DatabaseTicket = url.parse().unwrap();
301        assert_eq!(parsed.database_id(), ticket.database_id());
302
303        // blake3 ID round-trips without transformation
304        let ticket = DatabaseTicket::new(blake3_id());
305        let url = ticket.to_string();
306        let parsed: DatabaseTicket = url.parse().unwrap();
307        assert_eq!(parsed.database_id(), ticket.database_id());
308    }
309
310    #[test]
311    fn wrong_scheme_error() {
312        let result = "https:?db=sha256:abc123".parse::<DatabaseTicket>();
313        assert!(result.is_err());
314    }
315
316    #[test]
317    fn missing_db_param_error() {
318        let result = "eidetica:?pr=http:localhost:8080".parse::<DatabaseTicket>();
319        assert!(result.is_err());
320    }
321
322    #[test]
323    fn unknown_hash_algorithm_round_trips() {
324        // CID-based IDs round-trip through ticket URLs
325        let id = ID::from_bytes(b"future_hash_test_data");
326        let ticket = DatabaseTicket::new(id.clone());
327        let url = ticket.to_string();
328        let parsed: DatabaseTicket = url.parse().unwrap();
329        assert_eq!(*parsed.database_id(), id);
330    }
331
332    #[test]
333    fn unknown_query_params_ignored() {
334        let db_id = sha256_id();
335        let db_id_str = db_id.to_string();
336        let url = format!("eidetica:?db={db_id_str}&future_param=value&pr=http:localhost:8080");
337        let parsed: DatabaseTicket = url.parse().unwrap();
338        // Unknown params are silently ignored (forward compat)
339        assert_eq!(parsed.addresses().len(), 1);
340        assert_eq!(parsed.addresses()[0].transport_type, "http");
341        assert_eq!(parsed.addresses()[0].address, "localhost:8080");
342    }
343
344    #[test]
345    fn url_encoding_special_characters() {
346        let ticket = DatabaseTicket::with_addresses(
347            sha256_id(),
348            vec![Address::new("http", "host:8080/path?q=1&r=2")],
349        );
350        let url = ticket.to_string();
351        // The & and = in the address value should be encoded
352        let parsed: DatabaseTicket = url.parse().unwrap();
353        assert_eq!(parsed.addresses()[0].address, "host:8080/path?q=1&r=2");
354    }
355
356    #[test]
357    fn multiple_values_same_transport() {
358        let ticket = DatabaseTicket::with_addresses(
359            sha256_id(),
360            vec![
361                Address::http("192.168.1.1:8080"),
362                Address::http("10.0.0.1:8080"),
363            ],
364        );
365        let url = ticket.to_string();
366        let parsed: DatabaseTicket = url.parse().unwrap();
367        assert_eq!(parsed.addresses().len(), 2);
368        assert_eq!(parsed.addresses()[0].address, "192.168.1.1:8080");
369        assert_eq!(parsed.addresses()[1].address, "10.0.0.1:8080");
370    }
371
372    #[test]
373    fn add_address_method() {
374        let mut ticket = DatabaseTicket::new(sha256_id());
375        assert!(ticket.addresses().is_empty());
376        ticket.add_address(Address::http("localhost:8080"));
377        assert_eq!(ticket.addresses().len(), 1);
378    }
379
380    #[test]
381    fn display_format_no_hints() {
382        let ticket = DatabaseTicket::new(sha256_id());
383        let url = ticket.to_string();
384        assert!(url.starts_with("eidetica:?db="));
385        // Verify round-trip
386        let parsed: DatabaseTicket = url.parse().unwrap();
387        assert_eq!(parsed.database_id(), ticket.database_id());
388    }
389
390    #[test]
391    fn display_format_with_hints() {
392        let ticket =
393            DatabaseTicket::with_addresses(sha256_id(), vec![Address::http("localhost:8080")]);
394        let url = ticket.to_string();
395        assert!(url.starts_with("eidetica:?db="));
396        assert!(url.ends_with("&pr=http:localhost:8080"));
397    }
398
399    #[test]
400    fn malformed_pr_value_skipped() {
401        // A pr value without : is silently skipped
402        let db_id_str = sha256_id().to_string();
403        let url = format!("eidetica:?db={db_id_str}&pr=no_colon_here");
404        let parsed: DatabaseTicket = url.parse().unwrap();
405        assert!(parsed.addresses().is_empty());
406    }
407
408    #[test]
409    fn display_format_multiple_transports() {
410        let iroh_addr = test_iroh_address();
411        let ticket = DatabaseTicket::with_addresses(
412            sha256_id(),
413            vec![iroh_addr, Address::http("192.168.1.1:8080")],
414        );
415        let url = ticket.to_string();
416        // Iroh addresses appear as EndpointTicket format in ticket URLs
417        assert!(url.contains("&pr=iroh:endpoint"));
418        assert!(url.ends_with("&pr=http:192.168.1.1:8080"));
419    }
420
421    #[test]
422    fn serde_round_trip() {
423        let ticket =
424            DatabaseTicket::with_addresses(sha256_id(), vec![Address::http("192.168.1.1:8080")]);
425        let json = serde_json::to_string(&ticket).unwrap();
426        // Serializes as a plain URL string
427        assert!(json.starts_with('"'));
428        assert!(json.contains("eidetica:?db="));
429
430        let deserialized: DatabaseTicket = serde_json::from_str(&json).unwrap();
431        assert_eq!(deserialized, ticket);
432    }
433}