Skip to main content

syncweb_core/indexing/
links.rs

1//! Stable content links, signed mutable names, capabilities, and mirrors.
2//!
3//! Links are deliberately independent of the document and blob stores.  A
4//! caller can persist the records in either store, or use [`LinkResolver`] as
5//! a small in-memory resolver for local indexing and tests.  Immutable links
6//! identify a hash directly; mutable links identify a signed, monotonically
7//! advancing pointer.
8
9use std::{
10    collections::{BTreeMap, HashMap, HashSet},
11    fmt,
12    str::{FromStr, Split},
13    sync::{Arc, Mutex},
14    time::{SystemTime, UNIX_EPOCH},
15};
16
17use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
18use iroh::{PublicKey, SecretKey};
19use iroh_blobs::{Hash, ticket::BlobTicket};
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::{
24    error::{Result, SyncwebError},
25    indexing::ProviderLease,
26};
27
28const LINK_SIGNATURE_CONTEXT: &[u8] = b"syncweb/name-pointer/v1\0";
29const LINK_SCHEME: &str = "syncweb://";
30
31/// Return the current Unix epoch in seconds.
32#[must_use]
33pub fn current_epoch_seconds() -> u64 {
34    SystemTime::now()
35        .duration_since(UNIX_EPOCH)
36        .map_or(0, |duration| duration.as_secs())
37}
38
39/// An immutable content-addressed link.
40#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
41#[non_exhaustive]
42pub struct ContentLink {
43    /// The content or manifest hash identified by this link.
44    pub hash: Hash,
45}
46
47impl ContentLink {
48    /// Create an immutable content link.
49    #[must_use]
50    pub const fn new(hash: Hash) -> Self {
51        Self { hash }
52    }
53
54    /// Return the content hash.
55    #[must_use]
56    pub const fn hash(self) -> Hash {
57        self.hash
58    }
59
60    /// Alias for [`Self::new`].
61    #[must_use]
62    pub const fn create(hash: Hash) -> Self {
63        Self::new(hash)
64    }
65
66    /// Parse an immutable content URI.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if the URI or hash is malformed.
71    pub fn parse(value: &str) -> Result<Self> {
72        value.parse()
73    }
74
75    /// Return the canonical URI.
76    #[must_use]
77    pub fn uri(self) -> String {
78        self.to_string()
79    }
80
81    /// Validate this link.
82    ///
83    /// # Errors
84    ///
85    /// This currently cannot fail, but is provided so all link kinds have a
86    /// common validation API.
87    pub const fn validate(self) -> Result<()> {
88        Ok(())
89    }
90}
91
92impl fmt::Display for ContentLink {
93    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
94        write!(formatter, "{LINK_SCHEME}content/{}", self.hash)
95    }
96}
97
98impl FromStr for ContentLink {
99    type Err = SyncwebError;
100
101    fn from_str(value: &str) -> Result<Self> {
102        let hash_text = value
103            .strip_prefix("syncweb://content/")
104            .ok_or_else(|| SyncwebError::InvalidConfig("content link must use syncweb://content/".to_owned()))?;
105        if hash_text.is_empty() || hash_text.contains('/') || hash_text.contains('?') || hash_text.contains('#') {
106            return Err(SyncwebError::InvalidConfig(
107                "content link has an invalid hash".to_owned(),
108            ));
109        }
110        let hash = hash_text
111            .parse::<Hash>()
112            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid content link hash: {error}")))?;
113        Ok(Self::new(hash))
114    }
115}
116
117/// Alias used by callers that prefer the term immutable link.
118pub type ImmutableLink = ContentLink;
119
120/// A stable publisher/alias portion of a mutable link.
121#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
122#[non_exhaustive]
123pub struct NameLink {
124    /// The publisher whose signed pointer is followed.
125    pub publisher: PublicKey,
126    /// The publisher-controlled alias.
127    pub alias: String,
128}
129
130impl NameLink {
131    /// Create a mutable name link.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error for an empty publisher alias or an alias that could
136    /// change URI path semantics.
137    pub fn new(publisher: PublicKey, alias: impl Into<String>) -> Result<Self> {
138        let link = Self {
139            publisher,
140            alias: alias.into(),
141        };
142        link.validate()?;
143        Ok(link)
144    }
145
146    /// Alias for [`Self::new`].
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the alias is invalid.
151    pub fn create(publisher: PublicKey, alias: impl Into<String>) -> Result<Self> {
152        Self::new(publisher, alias)
153    }
154
155    /// Parse a mutable name URI.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if the URI or publisher is malformed.
160    pub fn parse(value: &str) -> Result<Self> {
161        value.parse()
162    }
163
164    /// Return the canonical URI.
165    #[must_use]
166    pub fn uri(&self) -> String {
167        self.to_string()
168    }
169
170    /// Validate the publisher alias.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the alias is empty or contains URI delimiters.
175    pub fn validate(&self) -> Result<()> {
176        validate_path_segment(&self.alias, "mutable link alias")
177    }
178}
179
180impl fmt::Display for NameLink {
181    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(formatter, "{LINK_SCHEME}name/{}/{}", self.publisher, self.alias)
183    }
184}
185
186impl FromStr for NameLink {
187    type Err = SyncwebError;
188
189    fn from_str(value: &str) -> Result<Self> {
190        let remainder = value
191            .strip_prefix("syncweb://name/")
192            .ok_or_else(|| SyncwebError::InvalidConfig("name link must use syncweb://name/".to_owned()))?;
193        let mut parts = remainder.split('/');
194        let publisher_text = next_part(&mut parts, "name link publisher")?;
195        let alias = next_part(&mut parts, "name link alias")?;
196        if parts.next().is_some() {
197            return Err(SyncwebError::InvalidConfig(
198                "name link has too many path segments".to_owned(),
199            ));
200        }
201        let publisher = publisher_text
202            .parse::<PublicKey>()
203            .map_err(|error| SyncwebError::InvalidIdentity(format!("invalid name link publisher: {error}")))?;
204        Self::new(publisher, alias)
205    }
206}
207
208/// Alias used by callers that prefer the term mutable link.
209pub type MutableLink = NameLink;
210
211/// A signed mutable pointer to an immutable manifest.
212///
213/// Every accepted pointer has a publisher, alias, sequence, and manifest
214/// bound by an Ed25519 signature.  A resolver accepts only a sequence greater
215/// than the currently accepted sequence for that publisher and alias.
216#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
217#[non_exhaustive]
218pub struct MutablePointer {
219    /// Publisher identity; this must match the signing key.
220    pub publisher: PublicKey,
221    /// Publisher-controlled alias.
222    pub alias: String,
223    /// Monotonically increasing update number.
224    pub sequence: u64,
225    /// Immutable manifest identified by this pointer.
226    pub manifest: Hash,
227    /// Optional semantic version used for version pinning.
228    #[serde(default)]
229    pub version: Option<String>,
230    /// Signed provider leases for the manifest.
231    #[serde(default)]
232    pub providers: Vec<ProviderLease>,
233    /// Hex-encoded Ed25519 signature over the pointer without this field.
234    #[serde(default)]
235    pub signature: Option<String>,
236}
237
238impl MutablePointer {
239    /// Create an unsigned pointer without provider leases.
240    ///
241    /// Call [`Self::sign`] before registering it with a resolver.
242    ///
243    /// # Errors
244    ///
245    /// Returns an error if the alias, sequence, or provider data is invalid.
246    pub fn new(publisher: PublicKey, alias: impl Into<String>, manifest: Hash, sequence: u64) -> Result<Self> {
247        Self::new_with_providers(publisher, alias, manifest, sequence, Vec::new())
248    }
249
250    /// Create an unsigned pointer with provider leases.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the alias, sequence, or provider data is invalid.
255    pub fn new_with_providers(
256        publisher: PublicKey,
257        alias: impl Into<String>,
258        manifest: Hash,
259        sequence: u64,
260        providers: Vec<ProviderLease>,
261    ) -> Result<Self> {
262        let pointer = Self {
263            publisher,
264            alias: alias.into(),
265            sequence,
266            manifest,
267            version: None,
268            providers,
269            signature: None,
270        };
271        pointer.validate()?;
272        Ok(pointer)
273    }
274
275    /// Create and sign a pointer with a Dalek signing key.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the pointer is invalid or the signer does not
280    /// match the publisher.
281    pub fn signed(
282        publisher: PublicKey,
283        alias: impl Into<String>,
284        manifest: Hash,
285        sequence: u64,
286        signing_key: &SigningKey,
287    ) -> Result<Self> {
288        let mut pointer = Self::new(publisher, alias, manifest, sequence)?;
289        pointer.sign(signing_key)?;
290        Ok(pointer)
291    }
292
293    /// Create and sign a pointer with an iroh secret key.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if the pointer is invalid or the signer does not
298    /// match the publisher.
299    pub fn signed_with_secret_key(
300        publisher: PublicKey,
301        alias: impl Into<String>,
302        manifest: Hash,
303        sequence: u64,
304        secret_key: &SecretKey,
305    ) -> Result<Self> {
306        let signing_key = SigningKey::from_bytes(&secret_key.to_bytes());
307        Self::signed(publisher, alias, manifest, sequence, &signing_key)
308    }
309
310    /// Set a semantic version on this pointer.
311    #[must_use]
312    pub fn with_version(mut self, version: impl Into<String>) -> Self {
313        self.version = Some(version.into());
314        self.signature = None;
315        self
316    }
317
318    /// Add a provider lease to this pointer.
319    #[must_use]
320    pub fn with_provider(mut self, provider: ProviderLease) -> Self {
321        self.providers.push(provider);
322        self.signature = None;
323        self
324    }
325
326    /// Add several provider leases to this pointer.
327    #[must_use]
328    pub fn with_providers(mut self, providers: impl IntoIterator<Item = ProviderLease>) -> Self {
329        self.providers.extend(providers);
330        self.signature = None;
331        self
332    }
333
334    /// Return the corresponding stable name link.
335    ///
336    /// # Errors
337    ///
338    /// Returns an error if the alias is invalid.
339    pub fn link(&self) -> Result<NameLink> {
340        NameLink::new(self.publisher, self.alias.clone())
341    }
342
343    /// Return the immutable manifest hash targeted by this pointer.
344    #[must_use]
345    pub const fn manifest_hash(&self) -> Hash {
346        self.manifest
347    }
348
349    /// Serialize the pointer without its signature.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if the pointer is invalid or cannot be serialized.
354    pub fn unsigned_bytes(&self) -> Result<Vec<u8>> {
355        let mut unsigned = self.clone();
356        unsigned.signature = None;
357        unsigned.validate()?;
358        let mut bytes = LINK_SIGNATURE_CONTEXT.to_vec();
359        let encoded = serde_json::to_vec(&unsigned)
360            .map_err(|error| SyncwebError::operation("failed to serialize mutable link pointer", error))?;
361        bytes.extend_from_slice(&encoded);
362        Ok(bytes)
363    }
364
365    /// Sign this pointer with an Ed25519 key.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the signer does not match the publisher or the
370    /// pointer cannot be serialized.
371    pub fn sign(&mut self, signing_key: &SigningKey) -> Result<()> {
372        if signing_key.verifying_key().to_bytes() != *self.publisher.as_bytes() {
373            return Err(SyncwebError::InvalidIdentity(
374                "mutable link signer does not match publisher".to_owned(),
375            ));
376        }
377        self.validate()?;
378        self.signature = Some(hex::encode(signing_key.sign(&self.unsigned_bytes()?).to_bytes()));
379        Ok(())
380    }
381
382    /// Verify the pointer's signature and all provider leases.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if the signature, pointer, or provider lease is
387    /// invalid.
388    pub fn verify_signature(&self) -> Result<()> {
389        self.validate()?;
390        let signature_text = self
391            .signature
392            .as_deref()
393            .ok_or_else(|| SyncwebError::InvalidConfig("mutable link pointer must be signed".to_owned()))?;
394        let signature_bytes = hex::decode(signature_text)
395            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid mutable link signature: {error}")))?;
396        let signature = Signature::from_slice(&signature_bytes)
397            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid mutable link signature: {error}")))?;
398        let key = VerifyingKey::from_bytes(self.publisher.as_bytes())
399            .map_err(|error| SyncwebError::InvalidIdentity(format!("invalid mutable link publisher: {error}")))?;
400        key.verify(&self.unsigned_bytes()?, &signature)
401            .map_err(|error| SyncwebError::InvalidConfig(format!("mutable link signature is invalid: {error}")))?;
402        for provider in &self.providers {
403            provider.verify_signature()?;
404        }
405        Ok(())
406    }
407
408    /// Validate non-cryptographic pointer fields.
409    ///
410    /// # Errors
411    ///
412    /// Returns an error if a pointer field or provider lease is invalid.
413    pub fn validate(&self) -> Result<()> {
414        validate_path_segment(&self.alias, "mutable link alias")?;
415        if self.sequence == 0 {
416            return Err(SyncwebError::InvalidConfig(
417                "mutable link sequence must be greater than zero".to_owned(),
418            ));
419        }
420        if let Some(version) = &self.version
421            && version.trim().is_empty()
422        {
423            return Err(SyncwebError::InvalidConfig(
424                "mutable link version cannot be empty".to_owned(),
425            ));
426        }
427        for provider in &self.providers {
428            provider.validate()?;
429            if provider.hash != self.manifest {
430                return Err(SyncwebError::InvalidTicket(
431                    "mutable link provider lease does not match its manifest".to_owned(),
432                ));
433            }
434        }
435        Ok(())
436    }
437
438    /// Encode the signed pointer as JSON.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if the pointer is unsigned, invalid, or cannot be
443    /// serialized.
444    pub fn to_bytes(&self) -> Result<Vec<u8>> {
445        self.verify_signature()?;
446        serde_json::to_vec(self)
447            .map_err(|error| SyncwebError::operation("failed to serialize mutable link pointer", error))
448    }
449
450    /// Decode and validate a signed pointer.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if the bytes do not contain a valid signed pointer.
455    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
456        let pointer: Self = serde_json::from_slice(bytes.as_ref())
457            .map_err(|error| SyncwebError::operation("failed to deserialize mutable link pointer", error))?;
458        pointer.verify_signature()?;
459        Ok(pointer)
460    }
461}
462
463/// Alias for the signed mutable pointer terminology used by the protocol.
464pub type SignedMutablePointer = MutablePointer;
465
466/// A bearer capability link to a private manifest.
467#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
468#[non_exhaustive]
469pub struct PrivateLink {
470    /// Immutable manifest granted by this capability.
471    pub manifest: Hash,
472    /// Secret read capability.  It is intentionally included in the URI.
473    pub capability: String,
474    /// Unix timestamp after which the capability is no longer accepted.
475    pub expires_at: u64,
476}
477
478impl PrivateLink {
479    /// Generate a private capability link with a cryptographically random
480    /// bearer token.
481    ///
482    /// # Errors
483    ///
484    /// Returns an error if the expiration is invalid.
485    pub fn generate(manifest: Hash, expires_at: u64) -> Result<Self> {
486        let capability = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
487        Self::new(manifest, capability, expires_at)
488    }
489
490    /// Alias for [`Self::generate`].
491    ///
492    /// # Errors
493    ///
494    /// Returns an error if the expiration is invalid.
495    pub fn create(manifest: Hash, expires_at: u64) -> Result<Self> {
496        Self::generate(manifest, expires_at)
497    }
498
499    /// Parse a private capability URI.
500    ///
501    /// # Errors
502    ///
503    /// Returns an error if the URI, hash, capability, or expiration is
504    /// malformed.
505    pub fn parse(value: &str) -> Result<Self> {
506        value.parse()
507    }
508
509    /// Create a private capability link.
510    ///
511    /// # Errors
512    ///
513    /// Returns an error if the capability or expiration is invalid.
514    pub fn new(manifest: Hash, capability: impl Into<String>, expires_at: u64) -> Result<Self> {
515        let link = Self {
516            manifest,
517            capability: capability.into(),
518            expires_at,
519        };
520        link.validate()?;
521        Ok(link)
522    }
523
524    /// Return the canonical capability URI.
525    #[must_use]
526    pub fn uri(&self) -> String {
527        self.to_string()
528    }
529
530    /// Validate the capability and expiry.
531    ///
532    /// # Errors
533    ///
534    /// Returns an error if the capability is empty or contains URI
535    /// delimiters, or if the expiration is zero.
536    pub fn validate(&self) -> Result<()> {
537        if self.expires_at == 0 {
538            return Err(SyncwebError::InvalidConfig(
539                "private link expiration must be greater than zero".to_owned(),
540            ));
541        }
542        validate_path_segment(&self.capability, "private link capability")
543    }
544
545    /// Return whether this link is expired at `now`.
546    #[must_use]
547    pub const fn is_expired_at(&self, now: u64) -> bool {
548        self.expires_at <= now
549    }
550
551    /// Return a stable key used by revocation lists.
552    #[must_use]
553    pub fn revocation_key(&self) -> (Hash, String) {
554        (self.manifest, self.capability.clone())
555    }
556}
557
558impl fmt::Display for PrivateLink {
559    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
560        write!(
561            formatter,
562            "{LINK_SCHEME}private/{}/{}?expires={}",
563            self.manifest, self.capability, self.expires_at
564        )
565    }
566}
567
568impl FromStr for PrivateLink {
569    type Err = SyncwebError;
570
571    fn from_str(value: &str) -> Result<Self> {
572        let (path, query) = value
573            .strip_prefix("syncweb://private/")
574            .ok_or_else(|| SyncwebError::InvalidConfig("private link must use syncweb://private/".to_owned()))?
575            .split_once('?')
576            .ok_or_else(|| SyncwebError::InvalidConfig("private link must contain an expiration".to_owned()))?;
577        let mut parts = path.split('/');
578        let manifest = next_part(&mut parts, "private link manifest")?;
579        let capability = next_part(&mut parts, "private link capability")?;
580        if parts.next().is_some() {
581            return Err(SyncwebError::InvalidConfig(
582                "private link has too many path segments".to_owned(),
583            ));
584        }
585        let manifest_hash = manifest
586            .parse::<Hash>()
587            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid private link manifest: {error}")))?;
588        let expires_text = query
589            .strip_prefix("expires=")
590            .ok_or_else(|| SyncwebError::InvalidConfig("private link query must contain expires".to_owned()))?;
591        let expires_at = expires_text
592            .parse::<u64>()
593            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid private link expiration: {error}")))?;
594        Self::new(manifest_hash, capability, expires_at)
595    }
596}
597
598/// Alias for capability links.
599pub type CapabilityLink = PrivateLink;
600
601/// A direct provider ticket registered as a mirror.
602#[derive(Clone, Debug, Eq, PartialEq)]
603#[non_exhaustive]
604pub struct Mirror {
605    /// Hash served by this mirror.
606    pub hash: Hash,
607    /// Blob ticket for the mirror.
608    pub ticket: BlobTicket,
609}
610
611impl Mirror {
612    /// Construct a mirror from a blob ticket.
613    ///
614    /// # Errors
615    ///
616    /// Returns an error if the ticket does not match its hash.
617    pub fn new(ticket: BlobTicket) -> Result<Self> {
618        let mirror = Self {
619            hash: ticket.hash(),
620            ticket,
621        };
622        mirror.validate()?;
623        Ok(mirror)
624    }
625
626    /// Validate that the ticket is consistent with the mirror hash.
627    ///
628    /// # Errors
629    ///
630    /// Returns an error if the ticket does not match its hash.
631    pub fn validate(&self) -> Result<()> {
632        if self.ticket.hash() != self.hash {
633            return Err(SyncwebError::InvalidTicket(
634                "mirror ticket does not match its content hash".to_owned(),
635            ));
636        }
637        Ok(())
638    }
639}
640
641/// Result of resolving any public or private link.
642#[derive(Clone, Debug, Eq, PartialEq)]
643#[non_exhaustive]
644pub struct LinkResolution {
645    /// Manifest or content hash identified by the link.
646    pub manifest: Hash,
647    /// Semantic version selected by a mutable link, if available.
648    pub version: Option<String>,
649    /// Mutable pointer sequence, if the link is mutable.
650    pub sequence: Option<u64>,
651    /// Signed provider leases advertised by the pointer or resolver.
652    pub providers: Vec<ProviderLease>,
653    /// Direct blob tickets in provider/mirror fallback order.
654    pub tickets: Vec<BlobTicket>,
655}
656
657impl LinkResolution {
658    /// Return the resolved hash.
659    #[must_use]
660    pub const fn hash(&self) -> Hash {
661        self.manifest
662    }
663
664    /// Return the resolved immutable manifest hash.
665    #[must_use]
666    pub const fn manifest_hash(&self) -> Hash {
667        self.manifest
668    }
669
670    /// Return provider and mirror tickets in fallback order.
671    #[must_use]
672    pub fn provider_tickets(&self) -> &[BlobTicket] {
673        &self.tickets
674    }
675
676    /// Fetch using the first provider that succeeds.
677    ///
678    /// # Errors
679    ///
680    /// Returns an error when no provider exists or every provider fails.
681    pub fn fetch_with<T, Fetch>(&self, mut fetch: Fetch) -> Result<T>
682    where
683        Fetch: FnMut(&BlobTicket) -> Result<T>,
684    {
685        fetch_from_mirrors(&self.tickets, &mut fetch)
686    }
687}
688
689/// Alias used by callers that prefer the shorter resolution name.
690pub type ResolvedLink = LinkResolution;
691
692/// A parsed syncweb stable link.
693#[derive(Clone, Debug, Eq, PartialEq, Hash)]
694#[non_exhaustive]
695pub enum Link {
696    /// Immutable content link.
697    Content(ContentLink),
698    /// Mutable publisher name.
699    Name(NameLink),
700    /// Private bearer capability.
701    Private(PrivateLink),
702}
703
704/// Alias for the public link enum.
705pub type SyncwebLink = Link;
706
707impl Link {
708    /// Parse a stable link.
709    ///
710    /// # Errors
711    ///
712    /// Returns an error if the URI is not a supported syncweb link.
713    pub fn parse(value: &str) -> Result<Self> {
714        value.parse()
715    }
716
717    /// Return the link URI.
718    #[must_use]
719    pub fn uri(&self) -> String {
720        self.to_string()
721    }
722}
723
724impl fmt::Display for Link {
725    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
726        match self {
727            Self::Content(link) => link.fmt(formatter),
728            Self::Name(link) => link.fmt(formatter),
729            Self::Private(link) => link.fmt(formatter),
730        }
731    }
732}
733
734impl FromStr for Link {
735    type Err = SyncwebError;
736
737    fn from_str(value: &str) -> Result<Self> {
738        if value.starts_with("syncweb://content/") {
739            return Ok(Self::Content(value.parse()?));
740        }
741        if value.starts_with("syncweb://name/") {
742            return Ok(Self::Name(value.parse()?));
743        }
744        if value.starts_with("syncweb://private/") {
745            return Ok(Self::Private(value.parse()?));
746        }
747        Err(SyncwebError::InvalidConfig(
748            "unsupported syncweb link; expected content, name, or private".to_owned(),
749        ))
750    }
751}
752
753/// Options for a link resolution operation.
754#[derive(Clone, Debug, Default, Eq, PartialEq)]
755#[non_exhaustive]
756pub struct ResolveOptions {
757    /// Select an exact semantic version from a mutable pointer history.
758    pub version: Option<String>,
759    /// Evaluate expiry and provider leases at this timestamp.
760    pub now: Option<u64>,
761}
762
763impl ResolveOptions {
764    /// Select an exact version.
765    #[must_use]
766    pub fn version(version: impl Into<String>) -> Self {
767        Self {
768            version: Some(version.into()),
769            now: None,
770        }
771    }
772
773    /// Evaluate the link at an explicit time.
774    #[must_use]
775    pub const fn at(now: u64) -> Self {
776        Self {
777            version: None,
778            now: Some(now),
779        }
780    }
781
782    /// Set an exact version while retaining the other options.
783    #[must_use]
784    pub fn with_version(mut self, version: impl Into<String>) -> Self {
785        self.version = Some(version.into());
786        self
787    }
788}
789
790#[derive(Clone, Debug, Default)]
791struct ResolverState {
792    mirrors: HashMap<Hash, Vec<Mirror>>,
793    pointers: HashMap<(PublicKey, String), PointerHistory>,
794    revoked: HashSet<(Hash, String)>,
795}
796
797#[derive(Clone, Debug, Default)]
798struct PointerHistory {
799    current: Option<MutablePointer>,
800    versions: BTreeMap<String, MutablePointer>,
801}
802
803/// In-memory resolver and mirror registry.
804#[derive(Clone, Debug, Default)]
805pub struct LinkResolver {
806    state: Arc<Mutex<ResolverState>>,
807}
808
809impl LinkResolver {
810    /// Create an empty resolver.
811    #[must_use]
812    pub fn new() -> Self {
813        Self::default()
814    }
815
816    /// Register a direct mirror ticket for a hash.
817    ///
818    /// # Errors
819    ///
820    /// Returns an error if the ticket is malformed.
821    pub fn register_mirror(&self, ticket: BlobTicket) -> Result<()> {
822        let mirror = Mirror::new(ticket)?;
823        let mut state = self.lock_state()?;
824        let mirrors = state.mirrors.entry(mirror.hash).or_default();
825        if !mirrors.iter().any(|existing| existing.ticket == mirror.ticket) {
826            mirrors.push(mirror);
827        }
828        drop(state);
829        Ok(())
830    }
831
832    /// Alias for [`Self::register_mirror`].
833    ///
834    /// # Errors
835    ///
836    /// Returns an error if the ticket is malformed.
837    pub fn add_mirror(&self, ticket: BlobTicket) -> Result<()> {
838        self.register_mirror(ticket)
839    }
840
841    /// Register a provider ticket as a mirror.
842    ///
843    /// # Errors
844    ///
845    /// Returns an error if the ticket is malformed.
846    pub fn register_provider_ticket(&self, ticket: BlobTicket) -> Result<()> {
847        self.register_mirror(ticket)
848    }
849
850    /// Register a signed provider lease for a hash.
851    ///
852    /// # Errors
853    ///
854    /// Returns an error if the lease signature, expiry, or ticket is invalid.
855    pub fn register_provider_lease(&self, lease: ProviderLease) -> Result<()> {
856        let now = current_epoch_seconds();
857        let validated_lease = checked_lease(lease, now)?;
858        let ticket = lease_ticket(&validated_lease)?;
859        let mut state = self.lock_state()?;
860        let mirrors = state.mirrors.entry(validated_lease.hash).or_default();
861        if !mirrors.iter().any(|existing| existing.ticket == ticket) {
862            mirrors.push(Mirror {
863                hash: validated_lease.hash,
864                ticket,
865            });
866        }
867        drop(state);
868        Ok(())
869    }
870
871    /// Return registered direct mirror tickets in registration order.
872    ///
873    /// # Errors
874    ///
875    /// Returns an error if the resolver state lock is poisoned.
876    pub fn mirrors(&self, hash: Hash) -> Result<Vec<BlobTicket>> {
877        let state = self.lock_state()?;
878        Ok(state
879            .mirrors
880            .get(&hash)
881            .map(|mirrors| mirrors.iter().map(|mirror| mirror.ticket.clone()).collect())
882            .unwrap_or_default())
883    }
884
885    /// Accept a signed mutable pointer, enforcing monotonic sequence.
886    ///
887    /// # Errors
888    ///
889    /// Returns an error if the pointer is unsigned, invalid, or older than
890    /// the current pointer.
891    pub fn publish(&self, pointer: MutablePointer) -> Result<()> {
892        pointer.verify_signature()?;
893        let key = (pointer.publisher, pointer.alias.clone());
894        let mut state = self.lock_state()?;
895        let history = state.pointers.entry(key).or_default();
896        if let Some(current) = &history.current
897            && pointer.sequence <= current.sequence
898        {
899            return Err(SyncwebError::InvalidConfig(format!(
900                "mutable link sequence {} must be greater than {}",
901                pointer.sequence, current.sequence
902            )));
903        }
904        if let Some(version) = &pointer.version {
905            if history.versions.contains_key(version) {
906                return Err(SyncwebError::InvalidConfig(format!(
907                    "mutable link version {version:?} is already published"
908                )));
909            }
910            history.versions.insert(version.clone(), pointer.clone());
911        }
912        history.current = Some(pointer);
913        drop(state);
914        Ok(())
915    }
916
917    /// Alias for [`Self::publish`].
918    ///
919    /// # Errors
920    ///
921    /// Returns an error if the pointer is invalid or not monotonic.
922    pub fn register_pointer(&self, pointer: MutablePointer) -> Result<()> {
923        self.publish(pointer)
924    }
925
926    /// Alias for [`Self::publish`].
927    ///
928    /// # Errors
929    ///
930    /// Returns an error if the pointer is invalid or not monotonic.
931    pub fn update(&self, pointer: MutablePointer) -> Result<()> {
932        self.publish(pointer)
933    }
934
935    /// Return the current pointer for a mutable name.
936    ///
937    /// # Errors
938    ///
939    /// Returns an error if the link is invalid or the resolver state lock is
940    /// poisoned.
941    pub fn current_pointer(&self, link: &NameLink) -> Result<Option<MutablePointer>> {
942        link.validate()?;
943        let state = self.lock_state()?;
944        Ok(state
945            .pointers
946            .get(&(link.publisher, link.alias.clone()))
947            .and_then(|history| history.current.clone()))
948    }
949
950    /// Revoke a private capability.
951    ///
952    /// # Errors
953    ///
954    /// Returns an error if the link is invalid or the resolver state lock is
955    /// poisoned.
956    pub fn revoke(&self, link: &PrivateLink) -> Result<()> {
957        link.validate()?;
958        self.lock_state()?.revoked.insert(link.revocation_key());
959        Ok(())
960    }
961
962    /// Alias for [`Self::revoke`].
963    ///
964    /// # Errors
965    ///
966    /// Returns an error if the link is invalid or the resolver state lock is
967    /// poisoned.
968    pub fn revoke_private(&self, link: &PrivateLink) -> Result<()> {
969        self.revoke(link)
970    }
971
972    /// Return whether a private capability has been revoked.
973    ///
974    /// # Errors
975    ///
976    /// Returns an error if the link is invalid or the resolver state lock is
977    /// poisoned.
978    pub fn is_revoked(&self, link: &PrivateLink) -> Result<bool> {
979        link.validate()?;
980        Ok(self.lock_state()?.revoked.contains(&link.revocation_key()))
981    }
982
983    /// Resolve a link using the current time.
984    ///
985    /// # Errors
986    ///
987    /// Returns an error if the link is invalid, unavailable, expired, revoked,
988    /// or has an invalid provider.
989    pub fn resolve(&self, link: &Link) -> Result<LinkResolution> {
990        self.resolve_with_options(link, &ResolveOptions::default())
991    }
992
993    /// Resolve a link at an explicit timestamp.
994    ///
995    /// # Errors
996    ///
997    /// Returns an error if the link is invalid, unavailable, expired, revoked,
998    /// or has an invalid provider.
999    pub fn resolve_at(&self, link: &Link, now: u64) -> Result<LinkResolution> {
1000        self.resolve_with_options(link, &ResolveOptions::at(now))
1001    }
1002
1003    /// Resolve a mutable link to an exact semantic version.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns an error if the link or requested version is unavailable.
1008    pub fn resolve_version(&self, link: &NameLink, version: &str) -> Result<LinkResolution> {
1009        self.resolve_with_options(&Link::Name(link.clone()), &ResolveOptions::version(version))
1010    }
1011
1012    /// Resolve a link using explicit version and expiry options.
1013    ///
1014    /// # Errors
1015    ///
1016    /// Returns an error if the link is invalid, unavailable, expired, revoked,
1017    /// or has an invalid provider.
1018    pub fn resolve_with_options(&self, link: &Link, options: &ResolveOptions) -> Result<LinkResolution> {
1019        let now = options.now.unwrap_or_else(current_epoch_seconds);
1020        let state = self.lock_state()?;
1021        let resolution = match link {
1022            Link::Content(content) => {
1023                content.validate()?;
1024                resolution_for_hash(content.hash, None, None, Vec::new(), &state, now)
1025            }
1026            Link::Private(private) => {
1027                private.validate()?;
1028                if private.is_expired_at(now) {
1029                    return Err(SyncwebError::InvalidConfig("private link has expired".to_owned()));
1030                }
1031                if state.revoked.contains(&private.revocation_key()) {
1032                    return Err(SyncwebError::InvalidConfig("private link has been revoked".to_owned()));
1033                }
1034                resolution_for_hash(private.manifest, None, None, Vec::new(), &state, now)
1035            }
1036            Link::Name(name) => {
1037                name.validate()?;
1038                let history = state
1039                    .pointers
1040                    .get(&(name.publisher, name.alias.clone()))
1041                    .ok_or_else(|| SyncwebError::InvalidConfig("mutable link has no published pointer".to_owned()))?;
1042                let pointer = if let Some(version) = &options.version {
1043                    history.versions.get(version).ok_or_else(|| {
1044                        SyncwebError::InvalidConfig(format!("mutable link version {version:?} not found"))
1045                    })?
1046                } else {
1047                    history
1048                        .current
1049                        .as_ref()
1050                        .ok_or_else(|| SyncwebError::InvalidConfig("mutable link has no current pointer".to_owned()))?
1051                };
1052                resolution_for_hash(
1053                    pointer.manifest,
1054                    pointer.version.clone(),
1055                    Some(pointer.sequence),
1056                    pointer.providers.clone(),
1057                    &state,
1058                    now,
1059                )
1060            }
1061        };
1062        drop(state);
1063        resolution
1064    }
1065
1066    /// Parse and resolve a link URI.
1067    ///
1068    /// # Errors
1069    ///
1070    /// Returns an error if the URI cannot be parsed or the link cannot be
1071    /// resolved.
1072    pub fn resolve_uri(&self, uri: &str) -> Result<LinkResolution> {
1073        let link = uri.parse::<Link>()?;
1074        self.resolve(&link)
1075    }
1076
1077    /// Resolve and fetch a link, trying each provider and mirror in order.
1078    ///
1079    /// # Errors
1080    ///
1081    /// Returns the first successful fetch result, or an error if resolution
1082    /// fails or every provider fails.
1083    pub fn fetch_with_mirrors<T, Fetch>(&self, link: &Link, fetch: Fetch) -> Result<T>
1084    where
1085        Fetch: FnMut(&BlobTicket) -> Result<T>,
1086    {
1087        self.resolve(link)?.fetch_with(fetch)
1088    }
1089
1090    fn lock_state(&self) -> Result<std::sync::MutexGuard<'_, ResolverState>> {
1091        self.state
1092            .lock()
1093            .map_err(|error| SyncwebError::operation("link resolver state lock poisoned", error))
1094    }
1095}
1096
1097/// Try provider tickets in order until one succeeds.
1098///
1099/// # Errors
1100///
1101/// Returns an error when no ticket is supplied or every fetch attempt fails.
1102pub fn fetch_from_mirrors<T, Fetch>(tickets: &[BlobTicket], fetch: &mut Fetch) -> Result<T>
1103where
1104    Fetch: FnMut(&BlobTicket) -> Result<T>,
1105{
1106    if tickets.is_empty() {
1107        return Err(SyncwebError::InvalidConfig("link has no provider tickets".to_owned()));
1108    }
1109    let mut failures = Vec::new();
1110    for ticket in tickets {
1111        match fetch(ticket) {
1112            Ok(value) => return Ok(value),
1113            Err(error) => failures.push(error.to_string()),
1114        }
1115    }
1116    Err(SyncwebError::InvalidConfig(format!(
1117        "all mirror providers failed: {}",
1118        failures.join("; ")
1119    )))
1120}
1121
1122fn resolution_for_hash(
1123    manifest: Hash,
1124    version: Option<String>,
1125    sequence: Option<u64>,
1126    providers: Vec<ProviderLease>,
1127    state: &ResolverState,
1128    now: u64,
1129) -> Result<LinkResolution> {
1130    let mut tickets = Vec::new();
1131    let mut active_providers = Vec::new();
1132    for provider in providers {
1133        if provider.expires_at <= now {
1134            continue;
1135        }
1136        provider.verify_at(now)?;
1137        let ticket = lease_ticket(&provider)?;
1138        if !tickets.contains(&ticket) {
1139            tickets.push(ticket);
1140        }
1141        active_providers.push(provider);
1142    }
1143    if let Some(mirrors) = state.mirrors.get(&manifest) {
1144        for mirror in mirrors {
1145            if !tickets.contains(&mirror.ticket) {
1146                tickets.push(mirror.ticket.clone());
1147            }
1148        }
1149    }
1150    Ok(LinkResolution {
1151        manifest,
1152        version,
1153        sequence,
1154        providers: active_providers,
1155        tickets,
1156    })
1157}
1158
1159fn lease_ticket(lease: &ProviderLease) -> Result<BlobTicket> {
1160    lease
1161        .ticket
1162        .parse::<BlobTicket>()
1163        .map_err(|error| SyncwebError::InvalidTicket(format!("invalid provider ticket: {error}")))
1164}
1165
1166fn checked_lease(lease: ProviderLease, now: u64) -> Result<ProviderLease> {
1167    lease.verify_at(now)?;
1168    Ok(lease)
1169}
1170
1171fn validate_path_segment(value: &str, field: &str) -> Result<()> {
1172    if value.is_empty() || value == "." || value == ".." {
1173        return Err(SyncwebError::InvalidConfig(format!(
1174            "{field} cannot be empty or dot-like"
1175        )));
1176    }
1177    if value
1178        .chars()
1179        .any(|character| character.is_control() || matches!(character, '/' | '?' | '#' | '%'))
1180    {
1181        return Err(SyncwebError::InvalidConfig(format!("{field} contains URI delimiters")));
1182    }
1183    Ok(())
1184}
1185
1186fn next_part<'a>(parts: &mut Split<'a, char>, field: &str) -> Result<&'a str> {
1187    let part = parts
1188        .next()
1189        .ok_or_else(|| SyncwebError::InvalidConfig(format!("{field} is missing")))?;
1190    if part.is_empty() {
1191        return Err(SyncwebError::InvalidConfig(format!("{field} is empty")));
1192    }
1193    Ok(part)
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use iroh::{EndpointAddr, SecretKey};
1200    use iroh_blobs::BlobFormat;
1201
1202    fn ticket(seed: u8, hash: Hash) -> BlobTicket {
1203        let secret = SecretKey::from_bytes(&[seed; 32]);
1204        BlobTicket::new(EndpointAddr::new(secret.public()), hash, BlobFormat::Raw)
1205    }
1206
1207    fn pointer(seed: u8, alias: &str, hash: Hash, sequence: u64, version: &str) -> Result<MutablePointer> {
1208        let secret = SecretKey::from_bytes(&[seed; 32]);
1209        let mut pointer = MutablePointer::new(secret.public(), alias, hash, sequence)?.with_version(version);
1210        pointer.sign(&SigningKey::from_bytes(&secret.to_bytes()))?;
1211        Ok(pointer)
1212    }
1213
1214    #[test]
1215    fn immutable_links_round_trip() {
1216        let hash = Hash::from_bytes([7; 32]);
1217        let link = ContentLink::new(hash);
1218        assert_eq!(link.to_string(), format!("syncweb://content/{hash}"));
1219        assert_eq!(
1220            link.to_string()
1221                .parse::<ContentLink>()
1222                .expect("content link should parse"),
1223            link
1224        );
1225        assert_eq!(
1226            link.to_string().parse::<Link>().expect("link should parse"),
1227            Link::Content(link)
1228        );
1229    }
1230
1231    #[test]
1232    fn signed_names_are_monotonic_and_version_pinnable() {
1233        let hash_one = Hash::from_bytes([1; 32]);
1234        let hash_two = Hash::from_bytes([2; 32]);
1235        let resolver = LinkResolver::new();
1236        let first = pointer(1, "dataset", hash_one, 1, "1.0.0").expect("pointer should build");
1237        let name = first.link().expect("pointer should have a link");
1238        resolver.publish(first.clone()).expect("first pointer should publish");
1239        assert_eq!(
1240            resolver
1241                .resolve(&Link::Name(name.clone()))
1242                .expect("name should resolve")
1243                .manifest,
1244            hash_one
1245        );
1246        let pinned = resolver
1247            .resolve_version(&name, "1.0.0")
1248            .expect("version should resolve");
1249        assert_eq!(pinned.manifest, hash_one);
1250        let second = pointer(1, "dataset", hash_two, 2, "2.0.0").expect("pointer should build");
1251        resolver.publish(second).expect("second pointer should publish");
1252        assert_eq!(
1253            resolver
1254                .resolve(&Link::Name(name.clone()))
1255                .expect("name should resolve")
1256                .manifest,
1257            hash_two
1258        );
1259        assert!(resolver.publish(first).is_err());
1260        assert_eq!(
1261            resolver
1262                .resolve_version(&name, "1.0.0")
1263                .expect("version should resolve")
1264                .manifest,
1265            hash_one
1266        );
1267    }
1268
1269    #[test]
1270    fn mutable_resolution_includes_verified_provider_tickets() {
1271        let hash = Hash::from_bytes([8; 32]);
1272        let provider_secret = SecretKey::from_bytes(&[8; 32]);
1273        let provider_ticket = ticket(8, hash);
1274        let mut lease = ProviderLease::new_with_times(hash, provider_ticket.to_string(), 1, 0, u64::MAX)
1275            .expect("provider lease should build");
1276        lease
1277            .sign_with_secret_key(&provider_secret)
1278            .expect("provider lease should sign");
1279
1280        let publisher_secret = SecretKey::from_bytes(&[9; 32]);
1281        let mut pointer =
1282            MutablePointer::new_with_providers(publisher_secret.public(), "dataset", hash, 1, vec![lease])
1283                .expect("pointer should build")
1284                .with_version("1.0.0");
1285        pointer
1286            .sign(&SigningKey::from_bytes(&publisher_secret.to_bytes()))
1287            .expect("pointer should sign");
1288
1289        let resolver = LinkResolver::new();
1290        let name = pointer.link().expect("pointer should have a link");
1291        resolver.publish(pointer).expect("pointer should publish");
1292        let resolution = resolver.resolve(&Link::Name(name)).expect("name should resolve");
1293        assert_eq!(resolution.manifest_hash(), hash);
1294        assert_eq!(resolution.providers.len(), 1);
1295        assert_eq!(resolution.provider_tickets(), &[provider_ticket]);
1296    }
1297
1298    #[test]
1299    fn private_links_expire_and_revoke() {
1300        let hash = Hash::from_bytes([3; 32]);
1301        let link = PrivateLink::new(hash, "capability-token", 20).expect("private link should build");
1302        let resolver = LinkResolver::new();
1303        assert_eq!(
1304            resolver
1305                .resolve_at(&Link::Private(link.clone()), 10)
1306                .expect("private link should resolve")
1307                .manifest,
1308            hash
1309        );
1310        resolver.revoke(&link).expect("private link should revoke");
1311        assert!(resolver.resolve_at(&Link::Private(link.clone()), 10).is_err());
1312        assert!(resolver.resolve_at(&Link::Private(link), 20).is_err());
1313    }
1314
1315    #[test]
1316    fn mirror_fallback_tries_alternates() {
1317        let hash = Hash::from_bytes([4; 32]);
1318        let resolver = LinkResolver::new();
1319        let first = ticket(4, hash);
1320        let second = ticket(5, hash);
1321        resolver
1322            .register_mirror(first.clone())
1323            .expect("first mirror should register");
1324        resolver.register_mirror(second).expect("second mirror should register");
1325        let link = Link::Content(ContentLink::new(hash));
1326        let value = resolver
1327            .fetch_with_mirrors(&link, |candidate| {
1328                if candidate == &first {
1329                    Err(SyncwebError::InvalidConfig("first mirror unavailable".to_owned()))
1330                } else {
1331                    Ok("second mirror")
1332                }
1333            })
1334            .expect("fallback should find a mirror");
1335        assert_eq!(value, "second mirror");
1336    }
1337}