Skip to main content

syncweb_core/folder/
collection.rs

1use std::{
2    cmp::Ordering,
3    collections::{BTreeMap, BTreeSet},
4    path::{Component, Path, PathBuf},
5};
6
7use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
8use iroh_blobs::Hash;
9use iroh_docs::{AuthorId, api::Doc};
10use semver::{Version, VersionReq};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use crate::{
15    editorial::{Channel, ContentType, EditorialState},
16    error::{Result, SyncwebError},
17    node::{blob_store::BlobStore, docs_engine::DocsEngine},
18};
19
20const SCHEMA_VERSION: u32 = 1;
21
22/// A content-addressed file included in a collection.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24#[non_exhaustive]
25pub struct CollectionEntry {
26    pub content_id: Hash,
27    pub logical_path: PathBuf,
28    pub size: u64,
29    pub media_type: Option<String>,
30    pub role: String,
31    pub relationships: Vec<String>,
32}
33
34impl CollectionEntry {
35    /// # Errors
36    ///
37    /// Returns an error when the logical path is absolute or escapes the collection root.
38    pub fn new(content_id: Hash, path: impl Into<PathBuf>, size: u64) -> Result<Self> {
39        let logical_path = path.into();
40        validate_logical_path(&logical_path)?;
41        Ok(Self {
42            content_id,
43            logical_path,
44            size,
45            media_type: None,
46            role: "primary".to_owned(),
47            relationships: Vec::new(),
48        })
49    }
50}
51
52/// Package-specific metadata layered on a general collection.
53#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
54#[non_exhaustive]
55pub struct PackageProfile {
56    pub name: String,
57    pub dependencies: Vec<PackageDependency>,
58}
59
60impl PackageProfile {
61    #[must_use]
62    pub fn new(name: impl Into<String>) -> Self {
63        Self {
64            name: name.into(),
65            dependencies: Vec::new(),
66        }
67    }
68
69    #[must_use]
70    pub fn with_dependency(mut self, dependency: PackageDependency) -> Self {
71        self.dependencies.push(dependency);
72        self
73    }
74}
75
76/// A version constraint for another package collection.
77#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct PackageDependency {
80    pub collection_id: Uuid,
81    pub version_requirement: String,
82}
83
84impl PackageDependency {
85    #[must_use]
86    pub fn new(collection_id: Uuid, version_requirement: impl Into<String>) -> Self {
87        Self {
88            collection_id,
89            version_requirement: version_requirement.into(),
90        }
91    }
92
93    /// # Errors
94    ///
95    /// Returns an error when the version requirement is not valid semver.
96    pub fn validate(&self) -> Result<()> {
97        if self.version_requirement.trim().is_empty() {
98            return Err(SyncwebError::InvalidConfig(format!(
99                "dependency {} has an empty version requirement",
100                self.collection_id
101            )));
102        }
103        VersionReq::parse(&self.version_requirement).map_err(|error| {
104            SyncwebError::InvalidConfig(format!(
105                "invalid dependency version requirement for {}: {error}",
106                self.collection_id
107            ))
108        })?;
109        Ok(())
110    }
111
112    /// # Errors
113    ///
114    /// Returns an error when the dependency requirement is not valid semver.
115    pub fn accepts(&self, version: &Version) -> Result<bool> {
116        self.validate()?;
117        let requirement = VersionReq::parse(&self.version_requirement).map_err(|error| {
118            SyncwebError::InvalidConfig(format!(
119                "invalid dependency version requirement for {}: {error}",
120                self.collection_id
121            ))
122        })?;
123        Ok(requirement.matches(version))
124    }
125}
126
127impl PackageProfile {
128    /// # Errors
129    ///
130    /// Returns an error when the package name or one of its dependencies is invalid.
131    pub fn validate(&self) -> Result<()> {
132        if self.name.trim().is_empty() {
133            return Err(SyncwebError::InvalidConfig("package name cannot be empty".to_owned()));
134        }
135        for dependency in &self.dependencies {
136            dependency.validate()?;
137        }
138        Ok(())
139    }
140
141    /// Check whether every dependency is available at an accepted version.
142    ///
143    /// The map contains the installed version for each collection ID.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error when a dependency requirement is invalid.
148    pub fn dependencies_satisfied(&self, available: &BTreeMap<Uuid, Version>) -> Result<bool> {
149        self.validate()?;
150        self.dependencies.iter().try_fold(true, |satisfied, dependency| {
151            let dependency_satisfied = available
152                .get(&dependency.collection_id)
153                .map(|version| dependency.accepts(version))
154                .transpose()?
155                .unwrap_or(false);
156            Ok(satisfied && dependency_satisfied)
157        })
158    }
159}
160
161/// Immutable description of a collection version.
162#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
163#[non_exhaustive]
164pub struct CollectionManifest {
165    pub schema_version: u32,
166    pub collection_id: Uuid,
167    pub version: String,
168    pub parent: Option<Hash>,
169    #[serde(default)]
170    pub changelog: Option<String>,
171    pub entries: Vec<CollectionEntry>,
172    pub package: Option<PackageProfile>,
173    #[serde(default)]
174    pub signature: Option<String>,
175    #[serde(default)]
176    pub public_key: Option<String>,
177    /// Content type taxonomy — `None` means the collection behaves as a
178    /// generic file set (default, backward-compatible behaviour).
179    #[serde(default)]
180    pub content_type: Option<ContentType>,
181    /// Editorial lifecycle stage — `None` means the collection is in the
182    /// implicit `Published` state (backward-compatible).
183    #[serde(default)]
184    pub editorial_state: Option<EditorialState>,
185    /// Channels this collection version should be announced on.
186    ///
187    /// An empty list means the collection is broadcast only on the default
188    /// public catalog topic.
189    #[serde(default)]
190    pub channels: Vec<Channel>,
191}
192
193impl CollectionManifest {
194    #[must_use]
195    pub fn new(collection_id: Uuid, version: impl Into<String>) -> Self {
196        Self {
197            schema_version: SCHEMA_VERSION,
198            collection_id,
199            version: version.into(),
200            parent: None,
201            changelog: None,
202            entries: Vec::new(),
203            package: None,
204            signature: None,
205            public_key: None,
206            content_type: None,
207            editorial_state: None,
208            channels: Vec::new(),
209        }
210    }
211
212    /// # Errors
213    ///
214    /// Returns an error when the schema, version, paths, dependencies, or
215    /// duplicate entries are invalid.
216    pub fn validate(&self) -> Result<()> {
217        self.validate_content()?;
218        validate_signature_metadata(self)?;
219        Ok(())
220    }
221
222    fn validate_content(&self) -> Result<()> {
223        if self.schema_version != SCHEMA_VERSION {
224            return Err(SyncwebError::InvalidConfig(format!(
225                "unsupported collection schema version {}",
226                self.schema_version
227            )));
228        }
229        Version::parse(&self.version)
230            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
231        let mut paths = BTreeSet::new();
232        for entry in &self.entries {
233            validate_logical_path(&entry.logical_path)?;
234            if !paths.insert(&entry.logical_path) {
235                return Err(SyncwebError::InvalidConfig(format!(
236                    "duplicate collection path {}",
237                    entry.logical_path.display()
238                )));
239            }
240        }
241        if let Some(package) = &self.package {
242            package.validate()?;
243            if package
244                .dependencies
245                .iter()
246                .any(|dependency| dependency.collection_id == self.collection_id)
247            {
248                return Err(SyncwebError::InvalidConfig(
249                    "package cannot depend on its own collection".to_owned(),
250                ));
251            }
252        }
253        Ok(())
254    }
255
256    /// # Errors
257    ///
258    /// Returns an error when the manifest is invalid or cannot be serialized.
259    pub fn to_bytes(&self) -> Result<Vec<u8>> {
260        self.validate()?;
261        serde_json::to_vec(self)
262            .map_err(|error| SyncwebError::operation("failed to serialize collection manifest", error))
263    }
264
265    /// # Errors
266    ///
267    /// Returns an error when the manifest cannot be decoded or is invalid.
268    pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
269        let manifest: Self = serde_json::from_slice(bytes.as_ref())
270            .map_err(|error| SyncwebError::operation("failed to deserialize collection manifest", error))?;
271        manifest.validate()?;
272        Ok(manifest)
273    }
274
275    /// Serialize the manifest without its signature.
276    ///
277    /// The public key remains part of this representation so changing the
278    /// signer changes the manifest identity.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error when the manifest is invalid or cannot be serialized.
283    pub fn unsigned_bytes(&self) -> Result<Vec<u8>> {
284        let mut unsigned = self.clone();
285        unsigned.signature = None;
286        unsigned.validate_content()?;
287        if let Some(public_key) = &unsigned.public_key {
288            decode_public_key(public_key)?;
289        }
290        serde_json::to_vec(&unsigned)
291            .map_err(|error| SyncwebError::operation("failed to serialize unsigned collection manifest", error))
292    }
293
294    /// Sign the manifest with an Ed25519 maintainer key.
295    ///
296    /// The signature is encoded as lowercase hexadecimal in the manifest.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error when the manifest is invalid or cannot be serialized.
301    pub fn sign(&mut self, signing_key: &SigningKey) -> Result<()> {
302        let mut unsigned = self.clone();
303        unsigned.signature = None;
304        unsigned.public_key = Some(hex::encode(signing_key.verifying_key().to_bytes()));
305        let signature = signing_key.sign(&unsigned.unsigned_bytes()?);
306        self.public_key = unsigned.public_key;
307        self.signature = Some(hex::encode(signature.to_bytes()));
308        Ok(())
309    }
310
311    /// Verify the manifest's embedded Ed25519 signature.
312    ///
313    /// Unsigned manifests have no signature to verify and return `Ok(())`;
314    /// a partially signed manifest is rejected.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error when signature metadata is malformed or verification fails.
319    pub fn verify_signature(&self) -> Result<()> {
320        self.validate()?;
321        let (Some(signature_text), Some(public_key)) = (&self.signature, &self.public_key) else {
322            if self.signature.is_none() && self.public_key.is_none() {
323                return Ok(());
324            }
325            return Err(SyncwebError::InvalidConfig(
326                "manifest signature and public key must be provided together".to_owned(),
327            ));
328        };
329        let verifying_key = decode_public_key(public_key)?;
330        let signature = decode_signature(signature_text)?;
331        verifying_key
332            .verify(&self.unsigned_bytes()?, &signature)
333            .map_err(|error| SyncwebError::InvalidConfig(format!("manifest signature is invalid: {error}")))
334    }
335
336    /// Compare this manifest's semver version with another manifest.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error when either manifest contains an invalid semver version.
341    pub fn version_ordering(&self, other_manifest: &Self) -> Result<Ordering> {
342        let current = Version::parse(&self.version)
343            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
344        let other_version = Version::parse(&other_manifest.version)
345            .map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
346        Ok(current.cmp(&other_version))
347    }
348
349    /// Return whether this manifest is a newer version than another manifest.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error when either manifest contains an invalid semver version.
354    pub fn is_upgrade_from(&self, other: &Self) -> Result<bool> {
355        Ok(self.version_ordering(other)? == Ordering::Greater)
356    }
357
358    /// Check whether all package dependencies are available.
359    ///
360    /// # Errors
361    ///
362    /// Returns an error when package metadata or dependency requirements are invalid.
363    pub fn dependencies_satisfied(&self, available: &BTreeMap<Uuid, Version>) -> Result<bool> {
364        self.package
365            .as_ref()
366            .map_or(Ok(true), |package| package.dependencies_satisfied(available))
367    }
368
369    /// # Errors
370    ///
371    /// Returns an error when the manifest cannot be serialized.
372    pub fn content_id(&self) -> Result<Hash> {
373        Ok(Hash::new(&self.unsigned_bytes()?))
374    }
375
376    /// Return the content hash of the serialized manifest blob.
377    ///
378    /// This differs from [`Self::content_id`] for signed manifests because
379    /// the signature is intentionally excluded from the logical manifest ID.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error when the manifest is invalid or cannot be serialized.
384    pub fn blob_id(&self) -> Result<Hash> {
385        Ok(Hash::new(&self.to_bytes()?))
386    }
387
388    /// Whether this collection has opted into editorial flows.
389    #[must_use]
390    pub const fn has_editorial_metadata(&self) -> bool {
391        self.content_type.is_some() || self.editorial_state.is_some() || !self.channels.is_empty()
392    }
393
394    /// The effective editorial state (defaults to `Published`).
395    #[must_use]
396    pub fn effective_state(&self) -> EditorialState {
397        self.editorial_state.unwrap_or_default()
398    }
399
400    /// The effective content type (defaults to `Generic`).
401    #[must_use]
402    pub fn effective_content_type(&self) -> &ContentType {
403        static GENERIC: ContentType = ContentType::Generic;
404        self.content_type.as_ref().unwrap_or(&GENERIC)
405    }
406}
407
408fn validate_signature_metadata(manifest: &CollectionManifest) -> Result<()> {
409    match (&manifest.signature, &manifest.public_key) {
410        (None, None) => Ok(()),
411        (Some(signature), Some(public_key)) => {
412            decode_signature(signature)?;
413            decode_public_key(public_key)?;
414            Ok(())
415        }
416        _ => Err(SyncwebError::InvalidConfig(
417            "manifest signature and public key must be provided together".to_owned(),
418        )),
419    }
420}
421
422fn decode_signature(encoded: &str) -> Result<Signature> {
423    let bytes = hex::decode(encoded)
424        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid manifest signature encoding: {error}")))?;
425    Signature::from_slice(&bytes)
426        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid manifest signature: {error}")))
427}
428
429fn decode_public_key(encoded: &str) -> Result<VerifyingKey> {
430    let decoded_bytes = hex::decode(encoded)
431        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid manifest public key encoding: {error}")))?;
432    let key_bytes: [u8; 32] = decoded_bytes.try_into().map_err(|error: Vec<u8>| {
433        SyncwebError::InvalidConfig(format!("manifest public key must be 32 bytes, got {}", error.len()))
434    })?;
435    VerifyingKey::from_bytes(&key_bytes)
436        .map_err(|error| SyncwebError::InvalidConfig(format!("invalid manifest public key: {error}")))
437}
438
439/// Mutable pointer to the current immutable manifest for a collection.
440#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
441#[non_exhaustive]
442pub struct CollectionHead {
443    pub collection_id: Uuid,
444    pub manifest: Hash,
445    pub sequence: u64,
446}
447
448/// Local record of a collection version installed on this device.
449#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
450#[non_exhaustive]
451pub struct InstalledCollection {
452    pub manifest: Hash,
453    pub versions: BTreeMap<String, PathBuf>,
454    pub current: String,
455}
456
457/// Local collection installation state.
458#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
459#[non_exhaustive]
460pub struct CollectionState {
461    pub installed: BTreeMap<Uuid, InstalledCollection>,
462}
463
464impl CollectionState {
465    #[must_use]
466    pub fn current(&self, collection_id: Uuid) -> Option<&InstalledCollection> {
467        self.installed.get(&collection_id)
468    }
469}
470
471/// Persists immutable manifests as pinned blobs and mutable heads in iroh-docs.
472#[derive(Clone)]
473pub struct CollectionStore {
474    doc: Doc,
475    author: AuthorId,
476    blobs: BlobStore,
477    docs: DocsEngine,
478}
479
480impl CollectionStore {
481    #[must_use]
482    pub const fn new(doc: Doc, author: AuthorId, blobs: BlobStore, docs: DocsEngine) -> Self {
483        Self {
484            doc,
485            author,
486            blobs,
487            docs,
488        }
489    }
490
491    /// # Errors
492    ///
493    /// Returns an error if manifest storage or head publication fails.
494    pub async fn publish(&self, manifest: &CollectionManifest, sequence: u64) -> Result<CollectionHead> {
495        manifest.validate()?;
496        if sequence == 0 {
497            return Err(SyncwebError::InvalidConfig(
498                "collection sequence must be greater than zero".to_owned(),
499            ));
500        }
501        if let Some(previous) = self.head(manifest.collection_id).await?
502            && sequence <= previous.sequence
503        {
504            return Err(SyncwebError::InvalidConfig(format!(
505                "collection sequence {sequence} must be greater than the current sequence {}",
506                previous.sequence
507            )));
508        }
509        let bytes = manifest.to_bytes()?;
510        for entry in &manifest.entries {
511            if !self.blobs.has(entry.content_id).await? {
512                return Err(SyncwebError::InvalidConfig(format!(
513                    "collection content is missing from the blob store: {}",
514                    entry.logical_path.display()
515                )));
516            }
517            self.blobs
518                .pin(
519                    content_pin_name(manifest.collection_id, entry.content_id),
520                    entry.content_id,
521                )
522                .await?;
523        }
524        let hash = self.blobs.add_bytes(&bytes).await?;
525        self.blobs.pin(manifest_pin_name(hash), hash).await?;
526        self.docs
527            .set_blob(
528                &self.doc,
529                self.author,
530                manifest_key(manifest.collection_id, &manifest.version),
531                hash,
532                u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("manifest is too large", error))?,
533            )
534            .await?;
535        let head = CollectionHead {
536            collection_id: manifest.collection_id,
537            manifest: hash,
538            sequence,
539        };
540        let head_bytes = serde_json::to_vec(&head)
541            .map_err(|error| SyncwebError::operation("failed to serialize collection head", error))?;
542        self.docs
543            .set(&self.doc, self.author, head_key(manifest.collection_id), head_bytes)
544            .await?;
545        Ok(head)
546    }
547
548    /// Read the mutable collection head stored in the document.
549    ///
550    /// # Errors
551    ///
552    /// Returns an error if the head cannot be read or decoded.
553    pub async fn head(&self, collection_id: Uuid) -> Result<Option<CollectionHead>> {
554        let Some(entry) = self.docs.get_any(&self.doc, head_key(collection_id)).await? else {
555            return Ok(None);
556        };
557        let bytes = self.blobs.get(entry.content_hash()).await?;
558        let head: CollectionHead = serde_json::from_slice(&bytes)
559            .map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
560        if head.collection_id != collection_id {
561            return Err(SyncwebError::InvalidConfig(
562                "collection head ID does not match its key".to_owned(),
563            ));
564        }
565        Ok(Some(head))
566    }
567
568    /// # Errors
569    ///
570    /// Returns an error if the manifest blob cannot be read or decoded.
571    pub async fn load(&self, hash: Hash) -> Result<CollectionManifest> {
572        CollectionManifest::from_bytes(self.blobs.get(hash).await?)
573    }
574}
575
576fn validate_logical_path(path: &Path) -> Result<()> {
577    if path.as_os_str().is_empty()
578        || path.is_absolute()
579        || path.components().any(|component| {
580            matches!(
581                component,
582                Component::ParentDir | Component::RootDir | Component::Prefix(_)
583            )
584        })
585    {
586        return Err(SyncwebError::InvalidConfig(format!(
587            "collection path must be a non-empty relative path: {}",
588            path.display()
589        )));
590    }
591    Ok(())
592}
593
594fn manifest_key(collection_id: Uuid, version: &str) -> String {
595    format!("collections/{collection_id}/manifests/{version}")
596}
597
598fn head_key(collection_id: Uuid) -> String {
599    format!("collections/{collection_id}/head")
600}
601
602fn manifest_pin_name(hash: Hash) -> String {
603    format!("syncweb/collection-manifest/{hash}")
604}
605
606fn content_pin_name(collection_id: Uuid, hash: Hash) -> String {
607    format!("syncweb/collection/{collection_id}/{hash}")
608}