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