Store plugin packages
This commit is contained in:
Generated
+35
-2
@@ -1997,6 +1997,15 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "directories"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys 0.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
@@ -2022,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"redox_users",
|
"redox_users 0.4.6",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2034,10 +2043,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users 0.4.6",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users 0.5.2",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch"
|
name = "dispatch"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2196,6 +2217,7 @@ dependencies = [
|
|||||||
name = "ely_app"
|
name = "ely_app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"directories",
|
||||||
"ely_browser_core",
|
"ely_browser_core",
|
||||||
"ely_design_system",
|
"ely_design_system",
|
||||||
"ely_domain",
|
"ely_domain",
|
||||||
@@ -7210,6 +7232,17 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ref-cast"
|
name = "ref-cast"
|
||||||
version = "1.0.25"
|
version = "1.0.25"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ license = "Apache-2.0"
|
|||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
directories = "6.0.0"
|
||||||
dpi = "0.1"
|
dpi = "0.1"
|
||||||
gpui = "0.2.2"
|
gpui = "0.2.2"
|
||||||
gpui-component = "0.5.1"
|
gpui-component = "0.5.1"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
directories.workspace = true
|
||||||
ely_browser_core = { path = "../ely_browser_core" }
|
ely_browser_core = { path = "../ely_browser_core" }
|
||||||
ely_design_system = { path = "../ely_design_system" }
|
ely_design_system = { path = "../ely_design_system" }
|
||||||
ely_domain = { path = "../ely_domain" }
|
ely_domain = { path = "../ely_domain" }
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub mod download_checksums;
|
pub mod download_checksums;
|
||||||
pub mod download_files;
|
pub mod download_files;
|
||||||
|
pub mod plugin_package_store;
|
||||||
pub mod plugin_packages;
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
ffi::OsStr,
|
||||||
fs,
|
fs,
|
||||||
io::{self, Read},
|
io::{self, Read},
|
||||||
path::{Path, PathBuf},
|
path::{Component, Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use ely_domain::PluginManifest;
|
use ely_domain::PluginManifest;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct VerifiedPluginPackage {
|
||||||
|
source_path: PathBuf,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
package_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PluginPackageError {
|
pub enum PluginPackageError {
|
||||||
#[error("plugin package must use .rplug extension: {path}")]
|
#[error("plugin package must use .rplug extension: {path}")]
|
||||||
@@ -25,6 +33,15 @@ pub enum PluginPackageError {
|
|||||||
#[error("failed to read plugin package entry {entry}: {path}")]
|
#[error("failed to read plugin package entry {entry}: {path}")]
|
||||||
ReadFailed { entry: &'static str, path: PathBuf, source: io::Error },
|
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")]
|
#[error("plugin package checksum mismatch for component.wasm")]
|
||||||
ChecksumMismatch,
|
ChecksumMismatch,
|
||||||
|
|
||||||
@@ -38,7 +55,9 @@ pub enum PluginPackageError {
|
|||||||
pub struct PluginPackageReader;
|
pub struct PluginPackageReader;
|
||||||
|
|
||||||
impl 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)?;
|
require_rplug_directory(path)?;
|
||||||
|
|
||||||
let manifest_path = path.join("plugin.toml");
|
let manifest_path = path.join("plugin.toml");
|
||||||
@@ -60,7 +79,29 @@ impl PluginPackageReader {
|
|||||||
return Err(PluginPackageError::SignatureMismatch);
|
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()))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{
|
use std::{
|
||||||
@@ -137,7 +312,8 @@ mod tests {
|
|||||||
fn reads_verified_directory_package() -> Result<(), Box<dyn Error>> {
|
fn reads_verified_directory_package() -> Result<(), Box<dyn Error>> {
|
||||||
let package = write_package("verified", b"wasm component")?;
|
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.id().as_str(), "com.elydora.verified");
|
||||||
assert_eq!(manifest.name(), "Verified Plugin");
|
assert_eq!(manifest.name(), "Verified Plugin");
|
||||||
@@ -183,6 +359,20 @@ mod tests {
|
|||||||
Ok(())
|
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>> {
|
fn write_package(name: &str, component: &[u8]) -> Result<PathBuf, Box<dyn Error>> {
|
||||||
let package = temp_root()?.join(format!("{name}.rplug"));
|
let package = temp_root()?.join(format!("{name}.rplug"));
|
||||||
fs::create_dir_all(package.join("signatures"))?;
|
fs::create_dir_all(package.join("signatures"))?;
|
||||||
|
|||||||
@@ -3,23 +3,26 @@ use std::path::PathBuf;
|
|||||||
use ely_domain::{PluginManifest, PluginPermission};
|
use ely_domain::{PluginManifest, PluginPermission};
|
||||||
use gpui::{Context, PathPromptOptions, Window};
|
use gpui::{Context, PathPromptOptions, Window};
|
||||||
|
|
||||||
use crate::services::plugin_packages::{PluginPackageError, PluginPackageReader};
|
use crate::services::{
|
||||||
|
plugin_package_store::PluginPackageStore,
|
||||||
|
plugin_packages::{PluginPackageError, PluginPackageReader, VerifiedPluginPackage},
|
||||||
|
};
|
||||||
|
|
||||||
use super::{ElyShell, ShellState};
|
use super::{ElyShell, ShellState};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(super) struct PendingPluginInstall {
|
pub(super) struct PendingPluginInstall {
|
||||||
manifest: PluginManifest,
|
package: VerifiedPluginPackage,
|
||||||
high_risk_permissions: Vec<PluginPermission>,
|
high_risk_permissions: Vec<PluginPermission>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PendingPluginInstall {
|
impl PendingPluginInstall {
|
||||||
fn new(manifest: PluginManifest, high_risk_permissions: Vec<PluginPermission>) -> Self {
|
fn new(package: VerifiedPluginPackage, high_risk_permissions: Vec<PluginPermission>) -> Self {
|
||||||
Self { manifest, high_risk_permissions }
|
Self { package, high_risk_permissions }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn manifest(&self) -> &PluginManifest {
|
pub(super) fn manifest(&self) -> &PluginManifest {
|
||||||
&self.manifest
|
self.package.manifest()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn high_risk_permissions(&self) -> &[PluginPermission] {
|
pub(super) fn high_risk_permissions(&self) -> &[PluginPermission] {
|
||||||
@@ -75,7 +78,7 @@ impl ElyShell {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.install_plugin_manifest(pending.manifest, true, cx);
|
self.install_plugin_package(pending.package, true, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn cancel_plugin_install(&mut self, cx: &mut Context<Self>) {
|
pub(super) fn cancel_plugin_install(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -85,11 +88,11 @@ impl ElyShell {
|
|||||||
|
|
||||||
fn handle_plugin_package_result(
|
fn handle_plugin_package_result(
|
||||||
&mut self,
|
&mut self,
|
||||||
result: Result<PluginManifest, PluginPackageError>,
|
result: Result<VerifiedPluginPackage, PluginPackageError>,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
match result {
|
match result {
|
||||||
Ok(manifest) => self.install_plugin_manifest(manifest, false, cx),
|
Ok(package) => self.install_plugin_package(package, false, cx),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.plugin_install_error = Some(error.to_string());
|
self.plugin_install_error = Some(error.to_string());
|
||||||
self.pending_plugin_install = None;
|
self.pending_plugin_install = None;
|
||||||
@@ -98,26 +101,31 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_plugin_manifest(
|
fn install_plugin_package(
|
||||||
&mut self,
|
&mut self,
|
||||||
manifest: PluginManifest,
|
package: VerifiedPluginPackage,
|
||||||
high_risk_confirmed: bool,
|
high_risk_confirmed: bool,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let high_risk_permissions = manifest.high_risk_permissions().cloned().collect::<Vec<_>>();
|
let high_risk_permissions =
|
||||||
|
package.manifest().high_risk_permissions().cloned().collect::<Vec<_>>();
|
||||||
if !high_risk_confirmed && !high_risk_permissions.is_empty() {
|
if !high_risk_confirmed && !high_risk_permissions.is_empty() {
|
||||||
self.pending_plugin_install =
|
self.pending_plugin_install =
|
||||||
Some(PendingPluginInstall::new(manifest, high_risk_permissions));
|
Some(PendingPluginInstall::new(package, high_risk_permissions));
|
||||||
self.plugin_install_error = None;
|
self.plugin_install_error = None;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = match &mut self.state {
|
let result = match &mut self.state {
|
||||||
ShellState::Ready(core) => core
|
ShellState::Ready(core) => PluginPackageStore::application()
|
||||||
.install_plugin(manifest, high_risk_confirmed)
|
.and_then(|store| store.store(&package))
|
||||||
.map(|_| ())
|
.map_err(|error| error.to_string())
|
||||||
.map_err(|error| error.to_string()),
|
.and_then(|stored_package| {
|
||||||
|
core.install_plugin(stored_package.manifest().clone(), high_risk_confirmed)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}),
|
||||||
ShellState::StartupError(message) => Err(message.clone()),
|
ShellState::StartupError(message) => Err(message.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,6 +137,6 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_plugin_package(path: PathBuf) -> Result<PluginManifest, PluginPackageError> {
|
fn load_plugin_package(path: PathBuf) -> Result<VerifiedPluginPackage, PluginPackageError> {
|
||||||
PluginPackageReader::read_directory_package(&path)
|
PluginPackageReader::read_directory_package(&path)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user