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)]
27#[non_exhaustive]
28pub struct PackageMetadata {
29 pub intended_use: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub menu_def_name: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub lang: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub description: Option<String>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub tags: Vec<String>,
43 pub library_subdir: String,
45 #[serde(default = "default_true")]
47 pub symlink_to_library: bool,
48}
49
50const fn default_true() -> bool {
51 true
52}
53
54#[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 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#[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#[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 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 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 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 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#[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 #[serde(default)]
212 pub content_type: Option<ContentType>,
213 #[serde(default)]
216 pub editorial_state: Option<EditorialState>,
217 #[serde(default)]
222 pub channels: Vec<Channel>,
223 #[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 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 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 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 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 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 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 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 pub fn is_upgrade_from(&self, other: &Self) -> Result<bool> {
391 Ok(self.version_ordering(other)? == Ordering::Greater)
392 }
393
394 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 pub fn content_id(&self) -> Result<Hash> {
409 Ok(Hash::new(&self.unsigned_bytes()?))
410 }
411
412 pub fn blob_id(&self) -> Result<Hash> {
421 Ok(Hash::new(&self.to_bytes()?))
422 }
423
424 #[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 #[must_use]
432 pub fn effective_state(&self) -> EditorialState {
433 self.editorial_state.unwrap_or_default()
434 }
435
436 #[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#[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#[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#[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#[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 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 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 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}