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