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#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27#[non_exhaustive]
28pub struct PackageMetadata {
29 pub intended_use: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub lang: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub description: Option<String>,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub tags: Vec<String>,
40}
41
42#[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 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#[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#[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 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 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 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 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#[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 #[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 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 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 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 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 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 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 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 pub fn is_upgrade_from(&self, other: &Self) -> Result<bool> {
358 Ok(self.version_ordering(other)? == Ordering::Greater)
359 }
360
361 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 pub fn content_id(&self) -> Result<Hash> {
376 Ok(Hash::new(&self.unsigned_bytes()?))
377 }
378
379 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#[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#[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#[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#[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 #[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 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.pin(crate::pins::collection_manifest_pin(hash), hash).await?;
504 self.docs
505 .set_blob(
506 &self.doc,
507 self.author,
508 manifest_key(manifest.collection_id, &manifest.version),
509 hash,
510 u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("manifest is too large", error))?,
511 )
512 .await?;
513 let head = CollectionHead {
514 collection_id: manifest.collection_id,
515 manifest: hash,
516 sequence,
517 };
518 let head_bytes = serde_json::to_vec(&head)
519 .map_err(|error| SyncwebError::operation("failed to serialize collection head", error))?;
520 self.docs
521 .set(&self.doc, self.author, head_key(manifest.collection_id), head_bytes)
522 .await?;
523 Ok(head)
524 }
525
526 pub async fn head(&self, collection_id: Uuid) -> Result<Option<CollectionHead>> {
532 let Some(entry) = self.docs.get_any(&self.doc, head_key(collection_id)).await? else {
533 return Ok(None);
534 };
535 let bytes = self.blobs.get(entry.content_hash()).await?;
536 let head: CollectionHead = serde_json::from_slice(&bytes)
537 .map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
538 if head.collection_id != collection_id {
539 return Err(SyncwebError::InvalidConfig(
540 "collection head ID does not match its key".to_owned(),
541 ));
542 }
543 Ok(Some(head))
544 }
545
546 pub async fn load(&self, hash: Hash) -> Result<CollectionManifest> {
550 CollectionManifest::from_bytes(self.blobs.get(hash).await?)
551 }
552}
553
554fn validate_logical_path(path: &Path) -> Result<()> {
555 if path.as_os_str().is_empty()
556 || path.is_absolute()
557 || path.components().any(|component| {
558 matches!(
559 component,
560 Component::ParentDir | Component::RootDir | Component::Prefix(_)
561 )
562 })
563 {
564 return Err(SyncwebError::InvalidConfig(format!(
565 "collection path must be a non-empty relative path: {}",
566 path.display()
567 )));
568 }
569 Ok(())
570}
571
572fn manifest_key(collection_id: Uuid, version: &str) -> String {
573 format!("collections/{collection_id}/manifests/{version}")
574}
575
576fn head_key(collection_id: Uuid) -> String {
577 format!("collections/{collection_id}/head")
578}