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)]
65#[serde(into = "String", try_from = "String")]
66pub struct DatabaseTicket {
67 database_id: ID,
68 addresses: Vec<Address>,
69}
70
71impl DatabaseTicket {
72 pub fn new(database_id: ID) -> Self {
74 Self {
75 database_id,
76 addresses: Vec::new(),
77 }
78 }
79
80 pub fn with_addresses(database_id: ID, addresses: Vec<Address>) -> Self {
82 Self {
83 database_id,
84 addresses,
85 }
86 }
87
88 pub fn database_id(&self) -> &ID {
90 &self.database_id
91 }
92
93 pub fn addresses(&self) -> &[Address] {
95 &self.addresses
96 }
97
98 pub fn add_address(&mut self, address: Address) {
100 self.addresses.push(address);
101 }
102}
103
104fn 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
136fn encode_address(addr: &Address) -> String {
142 format!("{}:{}", addr.transport_type, addr.address)
143}
144
145fn 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 database_id = Some(ID::parse(&value)?);
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::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 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 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 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 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 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 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 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 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 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 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 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}