use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use parking_lot::RwLock;
use crate::host::plugin_host::PluginHost;
use crate::host::{CacheLevel, HostApiError, OpenTarget, PluginSdkConfig};
use crate::platform::capabilities::PlatformCapabilities;
use crate::services::app::AppInfo;
use crate::services::focus_monitor::FocusCallback;
use crate::services::hotkey::types::{HotkeyCallback, HotkeyEventFilter};
use crate::services::installation_monitor::types::InstallationCallback;
use crate::services::model::{
ModelChatRequest, ModelChatResponse, ModelEmbeddingRequest, ModelEmbeddingResponse, ModelError,
ModelInfo, ModelSimilarityRequest, ModelSimilarityResponse,
};
use crate::services::parameter::types::ParameterSnapshot;
use crate::services::path::path_resolver::KnownPath;
use crate::services::theme::Theme;
use crate::services::timer::types::{TimerCallback, TimerId};
use crate::services::IconRequest;
pub struct PluginHandle {
plugin_id: String,
config: RwLock<PluginSdkConfig>,
capabilities: PlatformCapabilities,
host: Arc<dyn PluginHost>,
}
impl PluginHandle {
pub fn new(
plugin_id: String,
config: PluginSdkConfig,
capabilities: PlatformCapabilities,
host: Arc<dyn PluginHost>,
) -> Self {
Self {
plugin_id,
config: RwLock::new(config),
capabilities,
host,
}
}
pub fn plugin_id(&self) -> &str {
&self.plugin_id
}
fn icon_cache_level(&self) -> CacheLevel {
self.config.read().icon_cache_level.unwrap_or_default()
}
pub fn update_config(&self, config: PluginSdkConfig) {
*self.config.write() = config;
}
pub fn capabilities(&self) -> &PlatformCapabilities {
&self.capabilities
}
pub async fn get_icon(&self, request: IconRequest) -> Result<Vec<u8>, HostApiError> {
let level = self.icon_cache_level();
self.host.get_icon(&request, level).await
}
pub async fn get_icon_or_default(&self, request: IconRequest) -> Vec<u8> {
let level = self.icon_cache_level();
self.host.get_icon_or_default(&request, level).await
}
pub async fn get_icon_and_update_cache(
&self,
request: IconRequest,
) -> Result<Vec<u8>, HostApiError> {
let level = self.icon_cache_level();
self.host.get_icon_and_update_cache(&request, level).await
}
pub async fn override_icon_cache(
&self,
original_request: &IconRequest,
custom_icon_path: &str,
) -> Result<(), HostApiError> {
self.host
.override_icon_cache(original_request, custom_icon_path)
.await
}
pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
self.host.shell_open(target).await
}
pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
self.host.shell_open_folder(path).await
}
pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
self.host.shell_execute_elevation(path).await
}
pub async fn shell_execute_command(&self, command: &str) -> Result<(), HostApiError> {
self.host.shell_execute_command(command).await
}
pub async fn activate_window_by_process(
&self,
process_name: &str,
) -> Result<bool, HostApiError> {
self.host.activate_window_by_process(process_name).await
}
pub async fn activate_window_by_title(&self, title: &str) -> Result<bool, HostApiError> {
self.host.activate_window_by_title(title).await
}
pub async fn activate_window_by_pid(&self, pid: u32) -> Result<bool, HostApiError> {
self.host.activate_window_by_pid(pid).await
}
pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
self.host.resolve_path(path)
}
pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
self.host.set_clipboard_text(text)
}
pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
self.host.enumerate_apps().await
}
pub async fn launch_app(
&self,
app_id: &str,
args: Option<&[String]>,
) -> Result<u32, HostApiError> {
self.host.launch_app(app_id, args).await
}
pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
self.host.get_app_icon_path(name)
}
pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
self.host.resolve_lnk_target(lnk_path)
}
pub fn parse_localized_names_from_dir(&self, dir_path: &Path) -> HashMap<String, String> {
self.host.parse_localized_names_from_dir(dir_path)
}
pub fn get_theme(&self) -> Result<Theme, HostApiError> {
self.host.get_theme()
}
pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
self.host.get_system_theme()
}
pub fn model_list(&self) -> Vec<ModelInfo> {
self.host.model_list()
}
pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
self.host.model_chat(req).await
}
pub async fn model_embedding(
&self,
req: ModelEmbeddingRequest,
) -> Result<ModelEmbeddingResponse, ModelError> {
self.host.model_embedding(req).await
}
pub async fn model_similarity(
&self,
req: ModelSimilarityRequest,
) -> Result<ModelSimilarityResponse, ModelError> {
self.host.model_similarity(req).await
}
pub async fn resolve_parameters(
&self,
template: &str,
user_args: &[String],
snapshot: &ParameterSnapshot,
) -> Result<String, HostApiError> {
self.host
.resolve_parameters(template, user_args, snapshot)
.await
}
pub fn count_user_parameters(&self, template: &str) -> usize {
self.host.count_user_parameters(template)
}
pub fn has_system_parameters(&self, template: &str) -> bool {
self.host.has_system_parameters(template)
}
pub async fn set_timeout(
&self,
delay: Duration,
callback: TimerCallback,
) -> Result<TimerId, HostApiError> {
self.host.set_timeout(delay, callback).await
}
pub async fn set_interval(
&self,
interval: Duration,
callback: TimerCallback,
) -> Result<TimerId, HostApiError> {
self.host.set_interval(interval, callback).await
}
pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
self.host.cancel_timer(id).await
}
pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
self.host.cancel_all_timers().await
}
pub async fn resource_upload(
&self,
resource_id: &str,
file_path: &str,
max_size: Option<u64>,
) -> Result<String, HostApiError> {
self.host
.resource_upload(&self.plugin_id, resource_id, file_path, max_size)
.await
}
pub async fn resource_put(&self, resource_id: &str, data: &[u8]) -> Result<(), HostApiError> {
self.host
.resource_put(&self.plugin_id, resource_id, data)
.await
}
pub async fn resource_get(&self, resource_id: &str) -> Result<Vec<u8>, HostApiError> {
self.host.resource_get(&self.plugin_id, resource_id).await
}
pub async fn resource_delete(&self, resource_id: &str) -> Result<(), HostApiError> {
self.host
.resource_delete(&self.plugin_id, resource_id)
.await
}
pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
self.host.resource_list(&self.plugin_id).await
}
pub async fn cache_put(
&self,
domain: &str,
key: &str,
data: &[u8],
) -> Result<(), HostApiError> {
self.host
.cache_put(&self.plugin_id, domain, key, data)
.await
}
pub async fn cache_get(
&self,
domain: &str,
key: &str,
) -> Result<Option<Vec<u8>>, HostApiError> {
self.host.cache_get(&self.plugin_id, domain, key).await
}
pub async fn cache_delete(&self, domain: &str, key: &str) -> Result<(), HostApiError> {
self.host.cache_delete(&self.plugin_id, domain, key).await
}
pub async fn cache_cleanup(
&self,
domain: &str,
max_entries: usize,
) -> Result<(), HostApiError> {
self.host
.cache_cleanup(&self.plugin_id, domain, max_entries)
.await
}
pub fn register_hotkey_callback(
&self,
id: &str,
filter: HotkeyEventFilter,
callback: HotkeyCallback,
) {
self.host
.register_hotkey_callback(&self.plugin_id, id, filter, callback);
}
pub fn unregister_hotkey_callback(&self, id: &str) {
self.host.unregister_hotkey_callback(&self.plugin_id, id);
}
pub fn register_installation_callback(&self, id: &str, callback: InstallationCallback) {
self.host
.register_installation_callback(&self.plugin_id, id, callback);
}
pub fn unregister_installation_callback(&self, id: &str) {
self.host
.unregister_installation_callback(&self.plugin_id, id);
}
pub fn register_focus_callback(&self, id: &str, callback: FocusCallback) {
self.host
.register_focus_callback(&self.plugin_id, id, callback);
}
pub fn unregister_focus_callback(&self, id: &str) {
self.host.unregister_focus_callback(&self.plugin_id, id);
}
}
pub fn build_resource_path(
plugin_id: &str,
filename: Option<&str>,
) -> Result<String, HostApiError> {
let base = std::path::PathBuf::from_iter(["resources", plugin_id]);
let base_normalized = normalize_path(&base);
let mut path = base.clone();
if let Some(name) = filename {
if name.is_empty() || name == "." || name == ".." {
return Err(HostApiError::PathTraversalRejected {
path: name.to_string(),
});
}
path.push(name);
let normalized = normalize_path(&path);
let is_valid = normalized == base_normalized || normalized.starts_with(&base_normalized);
if !is_valid {
return Err(HostApiError::PathTraversalRejected {
path: name.to_string(),
});
}
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
let mut result = std::path::PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::CurDir => {
}
other => {
result.push(other);
}
}
}
result
}
pub fn build_cache_path(
cache_root: &str,
plugin_id: &str,
domain: &str,
key: &str,
) -> Result<String, HostApiError> {
for segment in [domain, key] {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(HostApiError::PathTraversalRejected {
path: segment.to_string(),
});
}
}
let base = std::path::Path::new(cache_root)
.join(plugin_id)
.join(domain);
let base_normalized = normalize_path(&base);
let mut path = base;
path.push(key);
let normalized = normalize_path(&path);
if normalized != base_normalized && !normalized.starts_with(&base_normalized) {
return Err(HostApiError::PathTraversalRejected {
path: key.to_string(),
});
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_path_removes_cur_dir() {
let input = std::path::Path::new("a/./b/./c");
let result = normalize_path(input);
assert_eq!(result, std::path::PathBuf::from("a/b/c"));
}
#[test]
fn normalize_path_resolves_parent_dir() {
let input = std::path::Path::new("a/b/../c");
let result = normalize_path(input);
assert_eq!(result, std::path::PathBuf::from("a/c"));
}
#[test]
fn normalize_path_handles_leading_dotdot() {
let input = std::path::Path::new("../../../etc/passwd");
let result = normalize_path(input);
assert_eq!(result, std::path::PathBuf::from("etc/passwd"));
}
#[test]
fn build_resource_path_rejects_parent_dir_traversal() {
let result = build_resource_path("test-plugin", Some("../../../secret"));
assert!(result.is_err());
match result {
Err(HostApiError::PathTraversalRejected { path }) => {
assert!(path.contains(".."));
}
_ => panic!("expected PathTraversalRejected"),
}
}
#[test]
fn build_resource_path_rejects_cross_plugin_traversal() {
let result = build_resource_path("test", Some("../test_evil/secret.txt"));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_rejects_dot_literal() {
let result = build_resource_path("test-plugin", Some("."));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_rejects_dotdot_literal() {
let result = build_resource_path("test-plugin", Some(".."));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_accepts_valid_filename() {
let result = build_resource_path("test-plugin", Some("icon.png"));
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.starts_with("resources/test-plugin/"));
assert!(path.ends_with("icon.png"));
}
#[test]
fn build_cache_path_accepts_valid_segments() {
let result = build_cache_path(
"C:/mock/zl-cache",
"test-plugin",
"model-embedding",
"ab/abc123.bin",
);
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.starts_with("C:/mock/zl-cache/test-plugin/model-embedding/"));
assert!(path.ends_with("abc123.bin"));
}
#[test]
fn build_cache_path_rejects_traversal() {
for (domain, key) in [
("..", "a.bin"),
("a", "../../x.bin"),
("a", "../b/x.bin"),
("a", ".."),
("a", "."),
("a", ""),
] {
assert!(
build_cache_path("C:/mock/zl-cache", "test-plugin", domain, key).is_err(),
"应拒绝 domain={domain:?} key={key:?}"
);
}
}
#[test]
fn build_resource_path_accepts_none_filename() {
let result = build_resource_path("test-plugin", None);
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, "resources/test-plugin");
}
#[test]
fn build_resource_path_rejects_empty_filename() {
let result = build_resource_path("test-plugin", Some(""));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn starts_with_component_boundary_prevents_false_prefix_match() {
let base = std::path::Path::new("resources/test");
let evil = std::path::Path::new("resources/test_evil/secret.txt");
assert!(!evil.starts_with(base));
}
#[test]
fn build_resource_path_rejects_same_prefix_traversal() {
let result = build_resource_path("test", Some("../test_evil/secret.txt"));
assert!(matches!(
result,
Err(HostApiError::PathTraversalRejected { .. })
));
}
#[test]
fn build_resource_path_allows_subdirectory_with_same_prefix() {
let result = build_resource_path("test", Some("test_data.txt"));
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, "resources/test/test_data.txt");
}
#[test]
fn build_resource_path_allows_nested_subdir() {
let result = build_resource_path("test", Some("subdir/file.png"));
assert!(result.is_ok());
let path = result.unwrap();
assert_eq!(path, "resources/test/subdir/file.png");
}
#[test]
fn pathbuf_push_empty_is_functionally_noop() {
let mut with_trailing = std::path::PathBuf::from("resources/test");
with_trailing.push("");
let without_trailing = std::path::PathBuf::from("resources/test");
assert_eq!(with_trailing, without_trailing);
let child = std::path::Path::new("resources/test/icon.png");
assert!(child.starts_with(&with_trailing));
assert!(child.starts_with(&without_trailing));
let unrelated = std::path::Path::new("resources/test_evil/secret.txt");
assert!(!unrelated.starts_with(&with_trailing));
assert!(!unrelated.starts_with(&without_trailing));
}
}