Store plugin packages

This commit is contained in:
2026-05-07 23:10:34 -04:00
parent 6bd2e3f69f
commit 36aefc929c
7 changed files with 618 additions and 23 deletions
+1
View File
@@ -1,3 +1,4 @@
pub mod download_checksums;
pub mod download_files;
pub mod plugin_package_store;
pub mod plugin_packages;
@@ -0,0 +1,361 @@
use std::{
fs, io,
path::{Path, PathBuf},
time::{SystemTime, SystemTimeError, UNIX_EPOCH},
};
use directories::ProjectDirs;
use ely_domain::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("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<Self, PluginPackageStoreError> {
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<StoredPluginPackage, PluginPackageStoreError> {
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,
))
}
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<StoredPluginPackage, PluginPackageStoreError> {
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<String, PluginPackageStoreError> {
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<dyn Error>> {
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<dyn Error>> {
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(())
}
fn write_package(root: &Path, name: &str, component: &[u8]) -> Result<PathBuf, Box<dyn Error>> {
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<Self, Box<dyn Error>> {
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());
}
}
}
+194 -4
View File
@@ -1,13 +1,21 @@
use std::{
ffi::OsStr,
fs,
io::{self, Read},
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
};
use ely_domain::PluginManifest;
use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VerifiedPluginPackage {
source_path: PathBuf,
manifest: PluginManifest,
package_hash: String,
}
#[derive(Debug, Error)]
pub enum PluginPackageError {
#[error("plugin package must use .rplug extension: {path}")]
@@ -25,6 +33,15 @@ pub enum PluginPackageError {
#[error("failed to read plugin package entry {entry}: {path}")]
ReadFailed { entry: &'static str, path: PathBuf, source: io::Error },
#[error("plugin package entry name must be valid utf-8: {path}")]
InvalidEntryName { path: PathBuf },
#[error("plugin package entry cannot be a symlink: {path}")]
SymlinkEntry { path: PathBuf },
#[error("plugin package entry type is unsupported: {path}")]
UnsupportedEntry { path: PathBuf },
#[error("plugin package checksum mismatch for component.wasm")]
ChecksumMismatch,
@@ -38,7 +55,9 @@ pub enum PluginPackageError {
pub struct PluginPackageReader;
impl PluginPackageReader {
pub fn read_directory_package(path: &Path) -> Result<PluginManifest, PluginPackageError> {
pub fn read_directory_package(
path: &Path,
) -> Result<VerifiedPluginPackage, PluginPackageError> {
require_rplug_directory(path)?;
let manifest_path = path.join("plugin.toml");
@@ -60,7 +79,29 @@ impl PluginPackageReader {
return Err(PluginPackageError::SignatureMismatch);
}
Ok(manifest)
let package_hash = sha256_directory(path)?;
Ok(VerifiedPluginPackage::new(path.to_path_buf(), manifest, package_hash))
}
}
impl VerifiedPluginPackage {
fn new(source_path: PathBuf, manifest: PluginManifest, package_hash: String) -> Self {
Self { source_path, manifest, package_hash }
}
#[must_use]
pub fn source_path(&self) -> &Path {
&self.source_path
}
#[must_use]
pub fn manifest(&self) -> &PluginManifest {
&self.manifest
}
#[must_use]
pub fn package_hash(&self) -> &str {
&self.package_hash
}
}
@@ -120,6 +161,140 @@ fn sha256_file(path: &Path) -> Result<String, PluginPackageError> {
Ok(format!("{:x}", hasher.finalize()))
}
fn sha256_directory(path: &Path) -> Result<String, PluginPackageError> {
let mut entries = Vec::new();
collect_package_entries(path, Path::new(""), &mut entries)?;
entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
let mut hasher = Sha256::new();
for entry in entries {
let relative_path = canonical_relative_path(entry.relative_path.as_path())?;
match entry.kind {
PackageEntryKind::Directory => {
hasher.update(b"dir\0");
hasher.update(relative_path.as_bytes());
hasher.update(b"\0");
}
PackageEntryKind::File => {
hasher.update(b"file\0");
hasher.update(relative_path.as_bytes());
hasher.update(b"\0");
hash_file_content(entry.absolute_path.as_path(), &mut hasher)?;
}
}
}
Ok(format!("{:x}", hasher.finalize()))
}
fn collect_package_entries(
root: &Path,
relative_dir: &Path,
entries: &mut Vec<PackageEntry>,
) -> Result<(), PluginPackageError> {
let absolute_dir = root.join(relative_dir);
for entry in fs::read_dir(absolute_dir.as_path()).map_err(|source| {
PluginPackageError::ReadFailed { entry: "package", path: absolute_dir.clone(), source }
})? {
let entry = entry.map_err(|source| PluginPackageError::ReadFailed {
entry: "package",
path: absolute_dir.clone(),
source,
})?;
let absolute_path = entry.path();
let relative_path = relative_dir.join(entry.file_name());
let metadata = fs::symlink_metadata(absolute_path.as_path()).map_err(|source| {
PluginPackageError::ReadFailed { entry: "package", path: absolute_path.clone(), source }
})?;
let file_type = metadata.file_type();
if file_type.is_symlink() {
return Err(PluginPackageError::SymlinkEntry { path: absolute_path });
}
if file_type.is_dir() {
entries.push(PackageEntry::new(
relative_path.clone(),
absolute_path,
PackageEntryKind::Directory,
));
collect_package_entries(root, relative_path.as_path(), entries)?;
continue;
}
if file_type.is_file() {
entries.push(PackageEntry::new(relative_path, absolute_path, PackageEntryKind::File));
continue;
}
return Err(PluginPackageError::UnsupportedEntry { path: absolute_path });
}
Ok(())
}
fn canonical_relative_path(path: &Path) -> Result<String, PluginPackageError> {
let mut parts = Vec::new();
for component in path.components() {
let Component::Normal(value) = component else {
return Err(PluginPackageError::InvalidEntryName { path: path.to_path_buf() });
};
parts.push(utf8_entry_name(value, path)?);
}
Ok(parts.join("/"))
}
fn utf8_entry_name<'a>(value: &'a OsStr, path: &Path) -> Result<&'a str, PluginPackageError> {
value.to_str().ok_or_else(|| PluginPackageError::InvalidEntryName { path: path.to_path_buf() })
}
fn hash_file_content(path: &Path, hasher: &mut Sha256) -> Result<(), PluginPackageError> {
let mut file = fs::File::open(path).map_err(|source| PluginPackageError::ReadFailed {
entry: "package",
path: path.to_path_buf(),
source,
})?;
let metadata = file.metadata().map_err(|source| PluginPackageError::ReadFailed {
entry: "package",
path: path.to_path_buf(),
source,
})?;
hasher.update(metadata.len().to_le_bytes());
hasher.update(b"\0");
let mut buffer = [0_u8; 8192];
loop {
let read = file.read(&mut buffer).map_err(|source| PluginPackageError::ReadFailed {
entry: "package",
path: path.to_path_buf(),
source,
})?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct PackageEntry {
relative_path: PathBuf,
absolute_path: PathBuf,
kind: PackageEntryKind,
}
impl PackageEntry {
fn new(relative_path: PathBuf, absolute_path: PathBuf, kind: PackageEntryKind) -> Self {
Self { relative_path, absolute_path, kind }
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum PackageEntryKind {
Directory,
File,
}
#[cfg(test)]
mod tests {
use std::{
@@ -137,7 +312,8 @@ mod tests {
fn reads_verified_directory_package() -> Result<(), Box<dyn Error>> {
let package = write_package("verified", b"wasm component")?;
let manifest = PluginPackageReader::read_directory_package(&package)?;
let package = PluginPackageReader::read_directory_package(&package)?;
let manifest = package.manifest();
assert_eq!(manifest.id().as_str(), "com.elydora.verified");
assert_eq!(manifest.name(), "Verified Plugin");
@@ -183,6 +359,20 @@ mod tests {
Ok(())
}
#[test]
fn package_hash_covers_non_wasm_entries() -> Result<(), Box<dyn Error>> {
let package = write_package("package-hash", b"wasm component")?;
let first_hash =
PluginPackageReader::read_directory_package(&package)?.package_hash().to_string();
fs::write(package.join("README.md"), "updated package metadata")?;
let second_hash =
PluginPackageReader::read_directory_package(&package)?.package_hash().to_string();
assert_ne!(first_hash, second_hash);
Ok(())
}
fn write_package(name: &str, component: &[u8]) -> Result<PathBuf, Box<dyn Error>> {
let package = temp_root()?.join(format!("{name}.rplug"));
fs::create_dir_all(package.join("signatures"))?;