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