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#[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 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#[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#[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 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 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 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 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#[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 #[serde(default)]
180 pub content_type: Option<ContentType>,
181 #[serde(default)]
184 pub editorial_state: Option<EditorialState>,
185 #[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 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 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 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 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 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 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 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 pub fn is_upgrade_from(&self, other: &Self) -> Result<bool> {
355 Ok(self.version_ordering(other)? == Ordering::Greater)
356 }
357
358 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 pub fn content_id(&self) -> Result<Hash> {
373 Ok(Hash::new(&self.unsigned_bytes()?))
374 }
375
376 pub fn blob_id(&self) -> Result<Hash> {
385 Ok(Hash::new(&self.to_bytes()?))
386 }
387
388 #[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 #[must_use]
396 pub fn effective_state(&self) -> EditorialState {
397 self.editorial_state.unwrap_or_default()
398 }
399
400 #[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#[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#[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#[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#[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 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 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 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}