1use 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
43const SCHEME: &str = "eidetica:?";
45
46const DB_PARAM: &str = "db";
48
49const PR_PARAM: &str = "pr";
51
52#[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 pub fn new(database_id: ID) -> Self {
73 Self {
74 database_id,
75 addresses: Vec::new(),
76 }
77 }
78
79 pub fn with_addresses(database_id: ID, addresses: Vec<Address>) -> Self {
81 Self {
82 database_id,
83 addresses,
84 }
85 }
86
87 pub fn database_id(&self) -> &ID {
89 &self.database_id
90 }
91
92 pub fn addresses(&self) -> &[Address] {
94 &self.addresses
95 }
96
97 pub fn add_address(&mut self, address: Address) {
99 self.addresses.push(address);
100 }
101}
102
103fn 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
135fn encode_address(addr: &Address) -> String {
141 format!("{}:{}", addr.transport_type, addr.address)
142}
143
144fn 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 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 }
196 _ => {} }
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 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 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 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 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 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 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 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 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 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 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 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}