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

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::new("sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
24//! let ticket = DatabaseTicket::new(id);
25//! let url = ticket.to_string();
26//! assert!(url.starts_with("eidetica:?db=sha256:"));
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. Peer addresses
61/// use the transport's native encoding, prefixed by the transport name and
62/// a colon if the encoding doesn't already include it.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(into = "String", try_from = "String")]
65pub struct DatabaseTicket {
66    database_id: ID,
67    addresses: Vec<Address>,
68}
69
70impl DatabaseTicket {
71    /// Create a ticket with a database ID and no address hints.
72    pub fn new(database_id: ID) -> Self {
73        Self {
74            database_id,
75            addresses: Vec::new(),
76        }
77    }
78
79    /// Create a ticket with a database ID and address hints.
80    pub fn with_addresses(database_id: ID, addresses: Vec<Address>) -> Self {
81        Self {
82            database_id,
83            addresses,
84        }
85    }
86
87    /// Get the database ID.
88    pub fn database_id(&self) -> &ID {
89        &self.database_id
90    }
91
92    /// Get the transport address hints.
93    pub fn addresses(&self) -> &[Address] {
94        &self.addresses
95    }
96
97    /// Add a transport address hint.
98    pub fn add_address(&mut self, address: Address) {
99        self.addresses.push(address);
100    }
101}
102
103/// Minimally encode a query-parameter value.
104///
105/// Only the characters that are structurally significant inside a query string
106/// (`&`, `=`, `#`, `+`) and the escape character (`%`) are percent-encoded.
107/// Everything else — including `:` — passes through verbatim, keeping tickets
108/// human-readable.
109///
110/// Spaces are not encoded because database IDs and transport addresses do not
111/// contain them. The `url::form_urlencoded::parse` decoder used in `FromStr`
112/// treats `+` as a space (per the `application/x-www-form-urlencoded` spec),
113/// so we encode literal `+` as `%2B` above to avoid that ambiguity. Tickets
114/// produced by other implementations that percent-encode more aggressively
115/// (e.g., encoding `:` or `/`) are accepted because the parser uses
116/// `form_urlencoded::parse` which handles all standard percent-encoding.
117fn encode_query_value(s: &str) -> Cow<'_, str> {
118    if !s.contains(['%', '&', '=', '#', '+']) {
119        return Cow::Borrowed(s);
120    }
121    let mut out = String::with_capacity(s.len());
122    for ch in s.chars() {
123        match ch {
124            '%' => out.push_str("%25"),
125            '&' => out.push_str("%26"),
126            '=' => out.push_str("%3D"),
127            '#' => out.push_str("%23"),
128            '+' => out.push_str("%2B"),
129            _ => out.push(ch),
130        }
131    }
132    Cow::Owned(out)
133}
134
135/// Format a transport [`Address`] as a `type:value` string.
136///
137/// The transport name is prefixed before the transport's native encoding,
138/// separated by a single colon: `http:192.168.1.1:8080`,
139/// `iroh:endpointABC...`, etc.
140fn encode_address(addr: &Address) -> String {
141    format!("{}:{}", addr.transport_type, addr.address)
142}
143
144/// Parse a `type:value` string back into a transport [`Address`].
145///
146/// Splits on the first `:` to recover the transport name and the
147/// transport-specific address. Returns `None` for values without a `:`
148/// separator.
149fn decode_address(value: &str) -> Option<Address> {
150    let (transport_type, address) = value.split_once(':')?;
151    Some(Address::new(transport_type, address))
152}
153
154impl fmt::Display for DatabaseTicket {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(
157            f,
158            "{SCHEME}{DB_PARAM}={}",
159            encode_query_value(self.database_id.as_str())
160        )?;
161
162        for addr in &self.addresses {
163            let encoded = encode_address(addr);
164            write!(f, "&{PR_PARAM}={}", encode_query_value(&encoded))?;
165        }
166
167        Ok(())
168    }
169}
170
171impl FromStr for DatabaseTicket {
172    type Err = crate::Error;
173
174    fn from_str(s: &str) -> Result<Self> {
175        let query = s.strip_prefix(SCHEME).ok_or_else(|| {
176            let preview: String = s.chars().take(20).collect();
177            SyncError::InvalidAddress(format!("Expected '{SCHEME}' prefix, got: {preview}"))
178        })?;
179
180        let mut database_id = None;
181        let mut addresses = Vec::new();
182
183        for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
184            match key.as_ref() {
185                DB_PARAM => {
186                    // TODO: if `db` appears more than once, the last value
187                    // silently wins.
188                    database_id = Some(ID::new(value.to_string()));
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::new(format!("sha256:{SHA256_HEX}"))
247    }
248
249    fn blake3_id() -> ID {
250        ID::new(format!("blake3:{BLAKE3_HEX}"))
251    }
252
253    #[test]
254    fn round_trip_no_hints() {
255        let ticket = DatabaseTicket::new(sha256_id());
256        let url = ticket.to_string();
257        let parsed: DatabaseTicket = url.parse().unwrap();
258        assert_eq!(parsed.database_id(), ticket.database_id());
259        assert!(parsed.addresses().is_empty());
260    }
261
262    #[test]
263    fn round_trip_one_hint() {
264        let ticket =
265            DatabaseTicket::with_addresses(sha256_id(), vec![Address::http("192.168.1.1:8080")]);
266        let url = ticket.to_string();
267        let parsed: DatabaseTicket = url.parse().unwrap();
268        assert_eq!(parsed.database_id(), ticket.database_id());
269        assert_eq!(parsed.addresses().len(), 1);
270        assert_eq!(parsed.addresses()[0].transport_type, "http");
271        assert_eq!(parsed.addresses()[0].address, "192.168.1.1:8080");
272    }
273
274    #[test]
275    fn round_trip_multiple_hints() {
276        let iroh_addr = test_iroh_address();
277        let ticket = DatabaseTicket::with_addresses(
278            sha256_id(),
279            vec![iroh_addr.clone(), Address::http("192.168.1.1:8080")],
280        );
281        let url = ticket.to_string();
282        // Iroh address should be in EndpointTicket format in the URL
283        assert!(url.contains("pr=iroh:endpoint"));
284        let parsed: DatabaseTicket = url.parse().unwrap();
285        assert_eq!(parsed.database_id(), ticket.database_id());
286        assert_eq!(parsed.addresses().len(), 2);
287        assert_eq!(parsed.addresses()[0].transport_type, "iroh");
288        // Round-trip through EndpointTicket preserves the internal address
289        assert_eq!(parsed.addresses()[0].address, iroh_addr.address);
290        assert_eq!(parsed.addresses()[1].transport_type, "http");
291        assert_eq!(parsed.addresses()[1].address, "192.168.1.1:8080");
292    }
293
294    #[test]
295    fn database_id_passed_through_opaquely() {
296        // sha256 ID round-trips without transformation
297        let ticket = DatabaseTicket::new(sha256_id());
298        let url = ticket.to_string();
299        assert!(url.contains(&format!("db=sha256:{SHA256_HEX}")));
300        let parsed: DatabaseTicket = url.parse().unwrap();
301        assert_eq!(
302            parsed.database_id().as_str(),
303            format!("sha256:{SHA256_HEX}")
304        );
305
306        // blake3 ID round-trips without transformation
307        let ticket = DatabaseTicket::new(blake3_id());
308        let url = ticket.to_string();
309        assert!(url.contains(&format!("db=blake3:{BLAKE3_HEX}")));
310        let parsed: DatabaseTicket = url.parse().unwrap();
311        assert_eq!(
312            parsed.database_id().as_str(),
313            format!("blake3:{BLAKE3_HEX}")
314        );
315    }
316
317    #[test]
318    fn wrong_scheme_error() {
319        let result = "https:?db=sha256:abc123".parse::<DatabaseTicket>();
320        assert!(result.is_err());
321    }
322
323    #[test]
324    fn missing_db_param_error() {
325        let result = "eidetica:?pr=http:localhost:8080".parse::<DatabaseTicket>();
326        assert!(result.is_err());
327    }
328
329    #[test]
330    fn unknown_hash_algorithm_round_trips() {
331        // Future hash algorithms round-trip as opaque strings
332        let id = ID::new("future_hash:deadbeef");
333        let ticket = DatabaseTicket::new(id);
334        let url = ticket.to_string();
335        assert!(url.contains("db=future_hash:deadbeef"));
336        let parsed: DatabaseTicket = url.parse().unwrap();
337        assert_eq!(parsed.database_id().as_str(), "future_hash:deadbeef");
338    }
339
340    #[test]
341    fn unknown_query_params_ignored() {
342        let url =
343            format!("eidetica:?db=sha256:{SHA256_HEX}&future_param=value&pr=http:localhost:8080");
344        let parsed: DatabaseTicket = url.parse().unwrap();
345        // Unknown params are silently ignored (forward compat)
346        assert_eq!(parsed.addresses().len(), 1);
347        assert_eq!(parsed.addresses()[0].transport_type, "http");
348        assert_eq!(parsed.addresses()[0].address, "localhost:8080");
349    }
350
351    #[test]
352    fn url_encoding_special_characters() {
353        let ticket = DatabaseTicket::with_addresses(
354            sha256_id(),
355            vec![Address::new("http", "host:8080/path?q=1&r=2")],
356        );
357        let url = ticket.to_string();
358        // The & and = in the address value should be encoded
359        let parsed: DatabaseTicket = url.parse().unwrap();
360        assert_eq!(parsed.addresses()[0].address, "host:8080/path?q=1&r=2");
361    }
362
363    #[test]
364    fn multiple_values_same_transport() {
365        let ticket = DatabaseTicket::with_addresses(
366            sha256_id(),
367            vec![
368                Address::http("192.168.1.1:8080"),
369                Address::http("10.0.0.1:8080"),
370            ],
371        );
372        let url = ticket.to_string();
373        let parsed: DatabaseTicket = url.parse().unwrap();
374        assert_eq!(parsed.addresses().len(), 2);
375        assert_eq!(parsed.addresses()[0].address, "192.168.1.1:8080");
376        assert_eq!(parsed.addresses()[1].address, "10.0.0.1:8080");
377    }
378
379    #[test]
380    fn add_address_method() {
381        let mut ticket = DatabaseTicket::new(sha256_id());
382        assert!(ticket.addresses().is_empty());
383        ticket.add_address(Address::http("localhost:8080"));
384        assert_eq!(ticket.addresses().len(), 1);
385    }
386
387    #[test]
388    fn display_format_no_hints() {
389        let ticket = DatabaseTicket::new(sha256_id());
390        let url = ticket.to_string();
391        assert_eq!(url, format!("eidetica:?db=sha256:{SHA256_HEX}"));
392    }
393
394    #[test]
395    fn display_format_with_hints() {
396        let ticket =
397            DatabaseTicket::with_addresses(sha256_id(), vec![Address::http("localhost:8080")]);
398        let url = ticket.to_string();
399        assert_eq!(
400            url,
401            format!("eidetica:?db=sha256:{SHA256_HEX}&pr=http:localhost:8080")
402        );
403    }
404
405    #[test]
406    fn malformed_pr_value_skipped() {
407        // A pr value without : is silently skipped
408        let url = format!("eidetica:?db=sha256:{SHA256_HEX}&pr=no_colon_here");
409        let parsed: DatabaseTicket = url.parse().unwrap();
410        assert!(parsed.addresses().is_empty());
411    }
412
413    #[test]
414    fn display_format_multiple_transports() {
415        let iroh_addr = test_iroh_address();
416        let ticket = DatabaseTicket::with_addresses(
417            sha256_id(),
418            vec![iroh_addr, Address::http("192.168.1.1:8080")],
419        );
420        let url = ticket.to_string();
421        // Iroh addresses appear as EndpointTicket format in ticket URLs
422        assert!(url.starts_with(&format!(
423            "eidetica:?db=sha256:{SHA256_HEX}&pr=iroh:endpoint"
424        )));
425        assert!(url.ends_with("&pr=http:192.168.1.1:8080"));
426    }
427
428    #[test]
429    fn serde_round_trip() {
430        let ticket =
431            DatabaseTicket::with_addresses(sha256_id(), vec![Address::http("192.168.1.1:8080")]);
432        let json = serde_json::to_string(&ticket).unwrap();
433        // Serializes as a plain URL string
434        assert!(json.starts_with('"'));
435        assert!(json.contains("eidetica:?db="));
436
437        let deserialized: DatabaseTicket = serde_json::from_str(&json).unwrap();
438        assert_eq!(deserialized, ticket);
439    }
440}