use std::{
cmp::Ordering,
collections::{BTreeMap, BTreeSet},
path::{Component, Path, PathBuf},
};
use ed25519_dalek::SigningKey;
use iroh_blobs::Hash;
use iroh_docs::{AuthorId, api::Doc};
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
error::{Result, SyncwebError},
folder::SyncwebFolder,
node::{blob_store::BlobStore, docs_engine::DocsEngine},
};
const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PackageMetadata {
pub intended_use: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CollectionEntry {
pub content_id: Hash,
pub logical_path: PathBuf,
pub size: u64,
pub media_type: Option<String>,
pub role: String,
pub relationships: Vec<String>,
}
impl CollectionEntry {
pub fn new(content_id: Hash, path: impl Into<PathBuf>, size: u64) -> Result<Self> {
let logical_path = path.into();
validate_logical_path(&logical_path)?;
Ok(Self {
content_id,
logical_path,
size,
media_type: None,
role: "primary".to_owned(),
relationships: Vec::new(),
})
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PackageProfile {
pub name: String,
pub dependencies: Vec<PackageDependency>,
}
impl PackageProfile {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
dependencies: Vec::new(),
}
}
#[must_use]
pub fn with_dependency(mut self, dependency: PackageDependency) -> Self {
self.dependencies.push(dependency);
self
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct PackageDependency {
pub collection_id: Uuid,
pub version_requirement: String,
}
impl PackageDependency {
#[must_use]
pub fn new(collection_id: Uuid, version_requirement: impl Into<String>) -> Self {
Self {
collection_id,
version_requirement: version_requirement.into(),
}
}
pub fn validate(&self) -> Result<()> {
if self.version_requirement.trim().is_empty() {
return Err(SyncwebError::InvalidConfig(format!(
"dependency {} has an empty version requirement",
self.collection_id
)));
}
VersionReq::parse(&self.version_requirement).map_err(|error| {
SyncwebError::InvalidConfig(format!(
"invalid dependency version requirement for {}: {error}",
self.collection_id
))
})?;
Ok(())
}
pub fn accepts(&self, version: &Version) -> Result<bool> {
self.validate()?;
let requirement = VersionReq::parse(&self.version_requirement).map_err(|error| {
SyncwebError::InvalidConfig(format!(
"invalid dependency version requirement for {}: {error}",
self.collection_id
))
})?;
Ok(requirement.matches(version))
}
}
impl PackageProfile {
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(SyncwebError::InvalidConfig("package name cannot be empty".to_owned()));
}
for dependency in &self.dependencies {
dependency.validate()?;
}
Ok(())
}
pub fn dependencies_satisfied(&self, available: &BTreeMap<Uuid, Version>) -> Result<bool> {
self.validate()?;
self.dependencies.iter().try_fold(true, |satisfied, dependency| {
let dependency_satisfied = available
.get(&dependency.collection_id)
.map(|version| dependency.accepts(version))
.transpose()?
.unwrap_or(false);
Ok(satisfied && dependency_satisfied)
})
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CollectionManifest {
pub schema_version: u32,
pub collection_id: Uuid,
pub version: String,
pub parent: Option<Hash>,
#[serde(default)]
pub changelog: Option<String>,
pub entries: Vec<CollectionEntry>,
pub package: Option<PackageProfile>,
#[serde(default)]
pub signature: Option<String>,
#[serde(default)]
pub public_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<PackageMetadata>,
}
impl CollectionManifest {
#[must_use]
pub fn new(collection_id: Uuid, version: impl Into<String>) -> Self {
Self {
schema_version: SCHEMA_VERSION,
collection_id,
version: version.into(),
parent: None,
changelog: None,
entries: Vec::new(),
package: None,
signature: None,
public_key: None,
metadata: None,
}
}
pub fn validate(&self) -> Result<()> {
self.validate_content()?;
validate_signature_metadata(self)?;
Ok(())
}
fn validate_content(&self) -> Result<()> {
if self.schema_version != SCHEMA_VERSION {
return Err(SyncwebError::InvalidConfig(format!(
"unsupported collection schema version {}",
self.schema_version
)));
}
Version::parse(&self.version)
.map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
let mut paths = BTreeSet::new();
for entry in &self.entries {
validate_logical_path(&entry.logical_path)?;
if !paths.insert(&entry.logical_path) {
return Err(SyncwebError::InvalidConfig(format!(
"duplicate collection path {}",
entry.logical_path.display()
)));
}
}
if let Some(package) = &self.package {
package.validate()?;
if package
.dependencies
.iter()
.any(|dependency| dependency.collection_id == self.collection_id)
{
return Err(SyncwebError::InvalidConfig(
"package cannot depend on its own collection".to_owned(),
));
}
}
Ok(())
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
self.validate()?;
serde_json::to_vec(self)
.map_err(|error| SyncwebError::operation("failed to serialize collection manifest", error))
}
pub fn from_bytes(bytes: impl AsRef<[u8]>) -> Result<Self> {
let manifest: Self = serde_json::from_slice(bytes.as_ref())
.map_err(|error| SyncwebError::operation("failed to deserialize collection manifest", error))?;
manifest.validate()?;
Ok(manifest)
}
pub fn unsigned_bytes(&self) -> Result<Vec<u8>> {
let mut unsigned = self.clone();
unsigned.signature = None;
unsigned.validate_content()?;
if let Some(public_key) = &unsigned.public_key {
crate::signature::decode_verifying_key(public_key)?;
}
serde_json::to_vec(&unsigned)
.map_err(|error| SyncwebError::operation("failed to serialize unsigned collection manifest", error))
}
pub fn sign(&mut self, signing_key: &SigningKey) -> Result<()> {
let mut unsigned = self.clone();
unsigned.signature = None;
unsigned.public_key = Some(crate::signature::public_key_hex(signing_key));
let signature = crate::signature::sign_hex(signing_key, &unsigned.unsigned_bytes()?);
self.public_key = unsigned.public_key;
self.signature = Some(signature);
Ok(())
}
pub fn verify_signature(&self) -> Result<()> {
self.validate()?;
let (Some(signature_text), Some(public_key)) = (&self.signature, &self.public_key) else {
if self.signature.is_none() && self.public_key.is_none() {
return Ok(());
}
return Err(SyncwebError::InvalidConfig(
"manifest signature and public key must be provided together".to_owned(),
));
};
crate::signature::verify_hex(public_key, &self.unsigned_bytes()?, signature_text)
}
pub fn version_ordering(&self, other_manifest: &Self) -> Result<Ordering> {
let current = Version::parse(&self.version)
.map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
let other_version = Version::parse(&other_manifest.version)
.map_err(|error| SyncwebError::InvalidConfig(format!("invalid collection version: {error}")))?;
Ok(current.cmp(&other_version))
}
pub fn is_upgrade_from(&self, other: &Self) -> Result<bool> {
Ok(self.version_ordering(other)? == Ordering::Greater)
}
pub fn dependencies_satisfied(&self, available: &BTreeMap<Uuid, Version>) -> Result<bool> {
self.package
.as_ref()
.map_or(Ok(true), |package| package.dependencies_satisfied(available))
}
pub fn content_id(&self) -> Result<Hash> {
Ok(Hash::new(&self.unsigned_bytes()?))
}
pub fn blob_id(&self) -> Result<Hash> {
Ok(Hash::new(&self.to_bytes()?))
}
}
fn validate_signature_metadata(manifest: &CollectionManifest) -> Result<()> {
match (&manifest.signature, &manifest.public_key) {
(None, None) => Ok(()),
(Some(signature), Some(public_key)) => {
crate::signature::decode_signature(signature)?;
crate::signature::decode_verifying_key(public_key)?;
Ok(())
}
_ => Err(SyncwebError::InvalidConfig(
"manifest signature and public key must be provided together".to_owned(),
)),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CollectionHead {
pub collection_id: Uuid,
pub manifest: Hash,
pub sequence: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct InstalledCollection {
pub manifest: Hash,
pub versions: BTreeMap<String, PathBuf>,
pub current: String,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CollectionState {
pub installed: BTreeMap<Uuid, InstalledCollection>,
}
impl CollectionState {
#[must_use]
pub fn current(&self, collection_id: Uuid) -> Option<&InstalledCollection> {
self.installed.get(&collection_id)
}
}
#[derive(Clone)]
pub struct CollectionStore {
doc: Doc,
author: AuthorId,
blobs: BlobStore,
docs: DocsEngine,
}
impl CollectionStore {
#[must_use]
pub const fn new(doc: Doc, author: AuthorId, blobs: BlobStore, docs: DocsEngine) -> Self {
Self {
doc,
author,
blobs,
docs,
}
}
#[must_use]
pub fn for_node(node: &crate::node::iroh_node::IrohNode, folder: &SyncwebFolder) -> Self {
Self::new(
folder.doc().clone(),
folder.author(),
node.blob_store().clone(),
node.docs_engine().clone(),
)
}
pub async fn publish(&self, manifest: &CollectionManifest, sequence: u64) -> Result<CollectionHead> {
manifest.validate()?;
if sequence == 0 {
return Err(SyncwebError::InvalidConfig(
"collection sequence must be greater than zero".to_owned(),
));
}
if let Some(previous) = self.head(manifest.collection_id).await?
&& sequence <= previous.sequence
{
return Err(SyncwebError::InvalidConfig(format!(
"collection sequence {sequence} must be greater than the current sequence {}",
previous.sequence
)));
}
let bytes = manifest.to_bytes()?;
for entry in &manifest.entries {
if !self.blobs.has(entry.content_id).await? {
return Err(SyncwebError::InvalidConfig(format!(
"collection content is missing from the blob store: {}",
entry.logical_path.display()
)));
}
self.blobs
.pin(
crate::pins::collection_content_pin(manifest.collection_id, entry.content_id),
entry.content_id,
)
.await?;
}
let hash = self.blobs.add_bytes(&bytes).await?;
self.blobs.pin(crate::pins::collection_manifest_pin(hash), hash).await?;
self.docs
.set_blob(
&self.doc,
self.author,
manifest_key(manifest.collection_id, &manifest.version),
hash,
u64::try_from(bytes.len()).map_err(|error| SyncwebError::operation("manifest is too large", error))?,
)
.await?;
let head = CollectionHead {
collection_id: manifest.collection_id,
manifest: hash,
sequence,
};
let head_bytes = serde_json::to_vec(&head)
.map_err(|error| SyncwebError::operation("failed to serialize collection head", error))?;
self.docs
.set(&self.doc, self.author, head_key(manifest.collection_id), head_bytes)
.await?;
Ok(head)
}
pub async fn head(&self, collection_id: Uuid) -> Result<Option<CollectionHead>> {
let Some(entry) = self.docs.get_any(&self.doc, head_key(collection_id)).await? else {
return Ok(None);
};
let bytes = self.blobs.get(entry.content_hash()).await?;
let head: CollectionHead = serde_json::from_slice(&bytes)
.map_err(|error| SyncwebError::operation("failed to deserialize collection head", error))?;
if head.collection_id != collection_id {
return Err(SyncwebError::InvalidConfig(
"collection head ID does not match its key".to_owned(),
));
}
Ok(Some(head))
}
pub async fn load(&self, hash: Hash) -> Result<CollectionManifest> {
CollectionManifest::from_bytes(self.blobs.get(hash).await?)
}
}
fn validate_logical_path(path: &Path) -> Result<()> {
if path.as_os_str().is_empty()
|| path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(SyncwebError::InvalidConfig(format!(
"collection path must be a non-empty relative path: {}",
path.display()
)));
}
Ok(())
}
fn manifest_key(collection_id: Uuid, version: &str) -> String {
format!("collections/{collection_id}/manifests/{version}")
}
fn head_key(collection_id: Uuid) -> String {
format!("collections/{collection_id}/head")
}