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