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#[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 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#[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#[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 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 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 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 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#[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 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 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 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 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 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 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 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 pub fn is_upgrade_from(&self, other: &Self) -> Result<bool> {
337 Ok(self.version_ordering(other)? == Ordering::Greater)
338 }
339
340 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 pub fn content_id(&self) -> Result<Hash> {
355 Ok(Hash::new(&self.unsigned_bytes()?))
356 }
357
358 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#[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#[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#[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#[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 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 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 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}