use std::{ fs, io, path::{Path, PathBuf}, time::{SystemTime, SystemTimeError, UNIX_EPOCH}, }; use directories::ProjectDirs; use ely_domain::{PluginId, PluginManifest}; use thiserror::Error; use super::plugin_packages::{PluginPackageError, PluginPackageReader, VerifiedPluginPackage}; const PLUGIN_PACKAGES_DIR: &str = "plugin-packages"; #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoredPluginPackage { manifest: PluginManifest, package_hash: String, path: PathBuf, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct PluginPackageStore { root: PathBuf, } #[derive(Debug, Error)] pub enum PluginPackageStoreError { #[error("application data directory is unavailable")] DataDirectoryUnavailable, #[error("stored plugin package is invalid: {path}")] InvalidStoredPackage { path: PathBuf, source: PluginPackageError }, #[error("stored plugin package does not match selected package: {path}")] StoredPackageMismatch { path: PathBuf }, #[error("plugin package changed while storing: {path}")] SourcePackageChanged { path: PathBuf }, #[error("plugin package entry cannot be copied through a symlink: {path}")] SymlinkEntry { path: PathBuf }, #[error("plugin package entry type is unsupported: {path}")] UnsupportedEntry { path: PathBuf }, #[error("failed to create plugin package directory: {path}")] CreateDirectoryFailed { path: PathBuf, source: io::Error }, #[error("failed to read plugin package directory: {path}")] ReadDirectoryFailed { path: PathBuf, source: io::Error }, #[error("failed to copy plugin package file from {source_path} to {destination_path}")] CopyFileFailed { source_path: PathBuf, destination_path: PathBuf, source: io::Error }, #[error("failed to move plugin package from {source_path} to {destination_path}")] MovePackageFailed { source_path: PathBuf, destination_path: PathBuf, source: io::Error }, #[error("failed to clean plugin package staging directory: {path}")] CleanupFailed { path: PathBuf, source: io::Error }, #[error("failed to remove plugin package directory: {path}")] RemoveDirectoryFailed { path: PathBuf, source: io::Error }, #[error("system clock is unavailable for plugin package staging")] ClockUnavailable { source: SystemTimeError }, } impl StoredPluginPackage { fn new(manifest: PluginManifest, package_hash: String, path: PathBuf) -> Self { Self { manifest, package_hash, path } } #[must_use] pub fn manifest(&self) -> &PluginManifest { &self.manifest } } impl PluginPackageStore { pub fn application() -> Result { let Some(project_dirs) = ProjectDirs::from("com", "elydora", "ELY Browser") else { return Err(PluginPackageStoreError::DataDirectoryUnavailable); }; Ok(Self::new(project_dirs.data_local_dir().join(PLUGIN_PACKAGES_DIR))) } #[must_use] pub fn new(root: PathBuf) -> Self { Self { root } } pub fn store( &self, package: &VerifiedPluginPackage, ) -> Result { let destination = self.package_path(package); if destination.exists() { return self.read_existing_package(package, destination); } let parent = self.plugin_root(package); create_directory(&parent)?; let staging = parent.join(staging_package_name(package.package_hash())?); if let Err(error) = copy_package_directory(package.source_path(), staging.as_path()) { return Err(remove_staging_after_error(staging.as_path(), error)); } let staged_package = match PluginPackageReader::read_directory_package(staging.as_path()) { Ok(staged_package) => staged_package, Err(source) => { let error = PluginPackageStoreError::InvalidStoredPackage { path: staging.clone(), source }; return Err(remove_staging_after_error(staging.as_path(), error)); } }; if staged_package.manifest().id() != package.manifest().id() || staged_package.package_hash() != package.package_hash() { let error = PluginPackageStoreError::SourcePackageChanged { path: package.source_path().to_path_buf(), }; return Err(remove_staging_after_error(staging.as_path(), error)); } if let Err(source) = fs::rename(staging.as_path(), destination.as_path()) { let error = PluginPackageStoreError::MovePackageFailed { source_path: staging.clone(), destination_path: destination.clone(), source, }; return Err(remove_staging_after_error(staging.as_path(), error)); } Ok(StoredPluginPackage::new( staged_package.manifest().clone(), staged_package.package_hash().to_string(), destination, )) } pub fn remove_plugin(&self, plugin_id: &PluginId) -> Result<(), PluginPackageStoreError> { let path = self.root.join(plugin_id.as_str()); match fs::remove_dir_all(path.as_path()) { Ok(()) => Ok(()), Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(()), Err(source) => Err(PluginPackageStoreError::RemoveDirectoryFailed { path, source }), } } fn package_path(&self, package: &VerifiedPluginPackage) -> PathBuf { self.plugin_root(package).join(format!("{}.rplug", package.package_hash())) } fn plugin_root(&self, package: &VerifiedPluginPackage) -> PathBuf { self.root.join(package.manifest().id().as_str()) } fn read_existing_package( &self, source_package: &VerifiedPluginPackage, path: PathBuf, ) -> Result { let stored_package = PluginPackageReader::read_directory_package(path.as_path()).map_err(|source| { PluginPackageStoreError::InvalidStoredPackage { path: path.clone(), source } })?; if stored_package.manifest().id() != source_package.manifest().id() || stored_package.package_hash() != source_package.package_hash() { return Err(PluginPackageStoreError::StoredPackageMismatch { path }); } Ok(StoredPluginPackage::new( stored_package.manifest().clone(), stored_package.package_hash().to_string(), path, )) } } fn copy_package_directory( source_path: &Path, destination_path: &Path, ) -> Result<(), PluginPackageStoreError> { create_directory(destination_path)?; let entries = fs::read_dir(source_path).map_err(|source| { PluginPackageStoreError::ReadDirectoryFailed { path: source_path.to_path_buf(), source } })?; for entry in entries { let entry = entry.map_err(|source| PluginPackageStoreError::ReadDirectoryFailed { path: source_path.to_path_buf(), source, })?; let source_entry = entry.path(); let destination_entry = destination_path.join(entry.file_name()); let metadata = fs::symlink_metadata(source_entry.as_path()).map_err(|source| { PluginPackageStoreError::ReadDirectoryFailed { path: source_entry.clone(), source } })?; let file_type = metadata.file_type(); if file_type.is_symlink() { return Err(PluginPackageStoreError::SymlinkEntry { path: source_entry }); } if file_type.is_dir() { copy_package_directory(source_entry.as_path(), destination_entry.as_path())?; continue; } if file_type.is_file() { fs::copy(source_entry.as_path(), destination_entry.as_path()).map_err(|source| { PluginPackageStoreError::CopyFileFailed { source_path: source_entry, destination_path: destination_entry, source, } })?; continue; } return Err(PluginPackageStoreError::UnsupportedEntry { path: source_entry }); } Ok(()) } fn create_directory(path: &Path) -> Result<(), PluginPackageStoreError> { fs::create_dir_all(path).map_err(|source| PluginPackageStoreError::CreateDirectoryFailed { path: path.to_path_buf(), source, }) } fn remove_staging_after_error( path: &Path, error: PluginPackageStoreError, ) -> PluginPackageStoreError { match fs::remove_dir_all(path) { Ok(()) => error, Err(source) if source.kind() == io::ErrorKind::NotFound => error, Err(source) => PluginPackageStoreError::CleanupFailed { path: path.to_path_buf(), source }, } } fn staging_package_name(package_hash: &str) -> Result { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|source| PluginPackageStoreError::ClockUnavailable { source })? .as_nanos(); Ok(format!(".staging-{package_hash}-{nanos}.rplug")) } #[cfg(test)] mod tests { use std::{ error::Error, fs, path::{Path, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; use sha2::{Digest, Sha256}; use super::{PluginPackageStore, PluginPackageStoreError}; use crate::services::plugin_packages::PluginPackageReader; #[test] fn stores_verified_package_under_plugin_hash_path() -> Result<(), Box> { let tree = TempTree::new("store")?; let source_package = write_package(tree.path(), "verified", b"wasm component")?; let package = PluginPackageReader::read_directory_package(source_package.as_path())?; let store = PluginPackageStore::new(tree.path().join("store")); let stored_package = store.store(&package)?; assert_eq!(stored_package.manifest().id().as_str(), "com.elydora.verified"); assert_eq!(stored_package.package_hash, package.package_hash()); assert_eq!( stored_package.path, tree.path() .join("store") .join("com.elydora.verified") .join(format!("{}.rplug", package.package_hash())) ); assert!(stored_package.path.join("plugin.toml").is_file()); assert!(stored_package.path.join("component.wasm").is_file()); assert!(stored_package.path.join("signatures").join("ed25519.sig").is_file()); Ok(()) } #[test] fn rejects_corrupt_existing_stored_package() -> Result<(), Box> { let tree = TempTree::new("corrupt")?; let source_package = write_package(tree.path(), "verified", b"wasm component")?; let package = PluginPackageReader::read_directory_package(source_package.as_path())?; let store = PluginPackageStore::new(tree.path().join("store")); let stored_package = store.store(&package)?; fs::write(stored_package.path.join("component.wasm"), b"changed")?; let error = store .store(&package) .err() .ok_or_else(|| std::io::Error::other("corrupt stored package was accepted"))?; assert!(matches!(error, PluginPackageStoreError::InvalidStoredPackage { .. })); Ok(()) } #[test] fn removes_stored_packages_for_plugin() -> Result<(), Box> { let tree = TempTree::new("remove")?; let source_package = write_package(tree.path(), "verified", b"wasm component")?; let package = PluginPackageReader::read_directory_package(source_package.as_path())?; let store = PluginPackageStore::new(tree.path().join("store")); let stored_package = store.store(&package)?; store.remove_plugin(package.manifest().id())?; assert!(!stored_package.path.exists()); assert!(!tree.path().join("store").join("com.elydora.verified").exists()); Ok(()) } fn write_package(root: &Path, name: &str, component: &[u8]) -> Result> { let package = root.join(format!("{name}.rplug")); fs::create_dir_all(package.join("signatures"))?; fs::write(package.join("component.wasm"), component)?; let checksum = sha256_bytes(component); let signature = "b".repeat(128); fs::write(package.join("signatures").join("ed25519.sig"), &signature)?; fs::write( package.join("plugin.toml"), manifest_toml(name, checksum.as_str(), signature.as_str()), )?; Ok(package) } fn manifest_toml(name: &str, checksum: &str, signature: &str) -> String { format!( r#" id = "com.elydora.{name}" name = "Verified Plugin" description = "Exports verified content." author = "Elydora" homepage = "https://elydora.com/plugins/{name}" permissions = ["page:metadata"] contributes = ["command-bar-command"] min_ely_build = "0.1.0" checksum = "{checksum}" [signature] algorithm = "ed25519" value = "{signature}" "# ) } fn sha256_bytes(bytes: &[u8]) -> String { format!("{:x}", Sha256::digest(bytes)) } struct TempTree { path: PathBuf, } impl TempTree { fn new(name: &str) -> Result> { let nanos = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); let path = std::env::temp_dir().join(format!("ely-plugin-store-{name}-{nanos}")); fs::create_dir_all(&path)?; Ok(Self { path }) } fn path(&self) -> &Path { &self.path } } impl Drop for TempTree { fn drop(&mut self) { _ = fs::remove_dir_all(self.path.as_path()); } } }