1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4use std::time::Duration;
5
6use parking_lot::RwLock;
7
8use crate::host::plugin_host::PluginHost;
9use crate::host::{CacheLevel, HostApiError, OpenTarget, PluginSdkConfig};
10use crate::platform::capabilities::PlatformCapabilities;
11use crate::services::app::AppInfo;
12use crate::services::focus_monitor::FocusCallback;
13use crate::services::hotkey::types::{HotkeyCallback, HotkeyEventFilter};
14use crate::services::installation_monitor::types::InstallationCallback;
15use crate::services::model::{
16 ModelChatRequest, ModelChatResponse, ModelEmbeddingRequest, ModelEmbeddingResponse, ModelError,
17 ModelInfo, ModelSimilarityRequest, ModelSimilarityResponse,
18};
19use crate::services::parameter::types::ParameterSnapshot;
20use crate::services::path::path_resolver::KnownPath;
21use crate::services::shell::CommandRunMode;
22use crate::services::theme::Theme;
23use crate::services::timer::types::{TimerCallback, TimerId};
24use crate::services::IconRequest;
25
26pub struct PluginHandle {
31 plugin_id: String,
32 config: RwLock<PluginSdkConfig>,
33 capabilities: PlatformCapabilities,
34 host: Arc<dyn PluginHost>,
36}
37
38impl PluginHandle {
39 pub fn new(
43 plugin_id: String,
44 config: PluginSdkConfig,
45 capabilities: PlatformCapabilities,
46 host: Arc<dyn PluginHost>,
47 ) -> Self {
48 Self {
49 plugin_id,
50 config: RwLock::new(config),
51 capabilities,
52 host,
53 }
54 }
55
56 pub fn plugin_id(&self) -> &str {
58 &self.plugin_id
59 }
60
61 fn icon_cache_level(&self) -> CacheLevel {
63 self.config.read().icon_cache_level.unwrap_or_default()
64 }
65
66 pub fn update_config(&self, config: PluginSdkConfig) {
68 *self.config.write() = config;
69 }
70
71 pub fn capabilities(&self) -> &PlatformCapabilities {
73 &self.capabilities
74 }
75
76 pub async fn get_icon(&self, request: IconRequest) -> Result<Vec<u8>, HostApiError> {
80 let level = self.icon_cache_level();
81 self.host.get_icon(&request, level).await
82 }
83
84 pub async fn get_icon_or_default(&self, request: IconRequest) -> Vec<u8> {
86 let level = self.icon_cache_level();
87 self.host.get_icon_or_default(&request, level).await
88 }
89
90 pub async fn get_icon_and_update_cache(
92 &self,
93 request: IconRequest,
94 ) -> Result<Vec<u8>, HostApiError> {
95 let level = self.icon_cache_level();
96 self.host.get_icon_and_update_cache(&request, level).await
97 }
98
99 pub async fn override_icon_cache(
101 &self,
102 original_request: &IconRequest,
103 custom_icon_path: &str,
104 ) -> Result<(), HostApiError> {
105 self.host
106 .override_icon_cache(original_request, custom_icon_path)
107 .await
108 }
109
110 pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
114 self.host.shell_open(target).await
115 }
116
117 pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
119 self.host.shell_open_folder(path).await
120 }
121
122 pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
124 self.host.shell_execute_elevation(path).await
125 }
126
127 pub async fn shell_execute_command(
130 &self,
131 command: &str,
132 mode: CommandRunMode,
133 ) -> Result<(), HostApiError> {
134 self.host.shell_execute_command(command, mode).await
135 }
136
137 pub async fn activate_window_by_process(
141 &self,
142 process_name: &str,
143 ) -> Result<bool, HostApiError> {
144 self.host.activate_window_by_process(process_name).await
145 }
146
147 pub async fn activate_window_by_title(&self, title: &str) -> Result<bool, HostApiError> {
149 self.host.activate_window_by_title(title).await
150 }
151
152 pub async fn activate_window_by_pid(&self, pid: u32) -> Result<bool, HostApiError> {
154 self.host.activate_window_by_pid(pid).await
155 }
156
157 pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
161 self.host.resolve_path(path)
162 }
163
164 pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
168 self.host.set_clipboard_text(text)
169 }
170
171 pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
175 self.host.enumerate_apps().await
176 }
177
178 pub async fn launch_app(
180 &self,
181 app_id: &str,
182 args: Option<&[String]>,
183 ) -> Result<u32, HostApiError> {
184 self.host.launch_app(app_id, args).await
185 }
186
187 pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
191 self.host.get_app_icon_path(name)
192 }
193
194 pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
198 self.host.resolve_lnk_target(lnk_path)
199 }
200
201 pub fn parse_localized_names_from_dir(&self, dir_path: &Path) -> HashMap<String, String> {
203 self.host.parse_localized_names_from_dir(dir_path)
204 }
205
206 pub fn get_theme(&self) -> Result<Theme, HostApiError> {
210 self.host.get_theme()
211 }
212
213 pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
215 self.host.get_system_theme()
216 }
217
218 pub fn model_list(&self) -> Vec<ModelInfo> {
222 self.host.model_list()
223 }
224
225 pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
227 self.host.model_chat(req).await
228 }
229
230 pub async fn model_embedding(
232 &self,
233 req: ModelEmbeddingRequest,
234 ) -> Result<ModelEmbeddingResponse, ModelError> {
235 self.host.model_embedding(req).await
236 }
237
238 pub async fn model_similarity(
240 &self,
241 req: ModelSimilarityRequest,
242 ) -> Result<ModelSimilarityResponse, ModelError> {
243 self.host.model_similarity(req).await
244 }
245
246 pub async fn resolve_parameters(
250 &self,
251 template: &str,
252 user_args: &[String],
253 snapshot: &ParameterSnapshot,
254 ) -> Result<String, HostApiError> {
255 self.host
256 .resolve_parameters(template, user_args, snapshot)
257 .await
258 }
259
260 pub fn count_user_parameters(&self, template: &str) -> usize {
262 self.host.count_user_parameters(template)
263 }
264
265 pub fn has_system_parameters(&self, template: &str) -> bool {
267 self.host.has_system_parameters(template)
268 }
269
270 pub async fn set_timeout(
274 &self,
275 delay: Duration,
276 callback: TimerCallback,
277 ) -> Result<TimerId, HostApiError> {
278 self.host
279 .set_timeout(&self.plugin_id, delay, callback)
280 .await
281 }
282
283 pub async fn set_interval(
285 &self,
286 interval: Duration,
287 callback: TimerCallback,
288 ) -> Result<TimerId, HostApiError> {
289 self.host
290 .set_interval(&self.plugin_id, interval, callback)
291 .await
292 }
293
294 pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
296 self.host.cancel_timer(&self.plugin_id, id).await
297 }
298
299 pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
301 self.host.cancel_all_timers(&self.plugin_id).await
302 }
303
304 pub async fn resource_upload(
308 &self,
309 resource_id: &str,
310 file_path: &str,
311 max_size: Option<u64>,
312 ) -> Result<String, HostApiError> {
313 self.host
314 .resource_upload(&self.plugin_id, resource_id, file_path, max_size)
315 .await
316 }
317
318 pub async fn resource_put(&self, resource_id: &str, data: &[u8]) -> Result<(), HostApiError> {
320 self.host
321 .resource_put(&self.plugin_id, resource_id, data)
322 .await
323 }
324
325 pub async fn resource_get(&self, resource_id: &str) -> Result<Vec<u8>, HostApiError> {
327 self.host.resource_get(&self.plugin_id, resource_id).await
328 }
329
330 pub async fn resource_delete(&self, resource_id: &str) -> Result<(), HostApiError> {
332 self.host
333 .resource_delete(&self.plugin_id, resource_id)
334 .await
335 }
336
337 pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
339 self.host.resource_list(&self.plugin_id).await
340 }
341
342 pub async fn cache_put(
348 &self,
349 domain: &str,
350 key: &str,
351 data: &[u8],
352 ) -> Result<(), HostApiError> {
353 self.host
354 .cache_put(&self.plugin_id, domain, key, data)
355 .await
356 }
357
358 pub async fn cache_get(
360 &self,
361 domain: &str,
362 key: &str,
363 ) -> Result<Option<Vec<u8>>, HostApiError> {
364 self.host.cache_get(&self.plugin_id, domain, key).await
365 }
366
367 pub async fn cache_delete(&self, domain: &str, key: &str) -> Result<(), HostApiError> {
369 self.host.cache_delete(&self.plugin_id, domain, key).await
370 }
371
372 pub async fn cache_cleanup(
374 &self,
375 domain: &str,
376 max_entries: usize,
377 ) -> Result<(), HostApiError> {
378 self.host
379 .cache_cleanup(&self.plugin_id, domain, max_entries)
380 .await
381 }
382
383 pub fn register_hotkey_callback(
387 &self,
388 id: &str,
389 filter: HotkeyEventFilter,
390 callback: HotkeyCallback,
391 ) {
392 self.host
393 .register_hotkey_callback(&self.plugin_id, id, filter, callback);
394 }
395
396 pub fn unregister_hotkey_callback(&self, id: &str) {
398 self.host.unregister_hotkey_callback(&self.plugin_id, id);
399 }
400
401 pub fn register_installation_callback(&self, id: &str, callback: InstallationCallback) {
403 self.host
404 .register_installation_callback(&self.plugin_id, id, callback);
405 }
406
407 pub fn unregister_installation_callback(&self, id: &str) {
409 self.host
410 .unregister_installation_callback(&self.plugin_id, id);
411 }
412
413 pub fn register_focus_callback(&self, id: &str, callback: FocusCallback) {
415 self.host
416 .register_focus_callback(&self.plugin_id, id, callback);
417 }
418
419 pub fn unregister_focus_callback(&self, id: &str) {
421 self.host.unregister_focus_callback(&self.plugin_id, id);
422 }
423}
424
425pub fn build_resource_path(
430 plugin_id: &str,
431 filename: Option<&str>,
432) -> Result<String, HostApiError> {
433 let base = std::path::PathBuf::from_iter(["resources", plugin_id]);
434 let base_normalized = normalize_path(&base);
435
436 let mut path = base.clone();
437 if let Some(name) = filename {
438 if name.is_empty() || name == "." || name == ".." {
440 return Err(HostApiError::PathTraversalRejected {
441 path: name.to_string(),
442 });
443 }
444 path.push(name);
445 let normalized = normalize_path(&path);
446 let is_valid = normalized == base_normalized || normalized.starts_with(&base_normalized);
449 if !is_valid {
450 return Err(HostApiError::PathTraversalRejected {
451 path: name.to_string(),
452 });
453 }
454 }
455 Ok(path.to_string_lossy().replace('\\', "/"))
456}
457
458fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
461 let mut result = std::path::PathBuf::new();
462 for component in path.components() {
463 match component {
464 std::path::Component::ParentDir => {
465 result.pop();
466 }
467 std::path::Component::CurDir => {
468 }
470 other => {
471 result.push(other);
472 }
473 }
474 }
475 result
476}
477
478pub fn build_cache_path(
483 cache_root: &str,
484 plugin_id: &str,
485 domain: &str,
486 key: &str,
487) -> Result<String, HostApiError> {
488 for segment in [domain, key] {
489 if segment.is_empty() || segment == "." || segment == ".." {
490 return Err(HostApiError::PathTraversalRejected {
491 path: segment.to_string(),
492 });
493 }
494 }
495 let base = std::path::Path::new(cache_root)
496 .join(plugin_id)
497 .join(domain);
498 let base_normalized = normalize_path(&base);
499 let mut path = base;
500 path.push(key);
501 let normalized = normalize_path(&path);
502 if normalized != base_normalized && !normalized.starts_with(&base_normalized) {
503 return Err(HostApiError::PathTraversalRejected {
504 path: key.to_string(),
505 });
506 }
507 Ok(path.to_string_lossy().replace('\\', "/"))
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn normalize_path_removes_cur_dir() {
516 let input = std::path::Path::new("a/./b/./c");
517 let result = normalize_path(input);
518 assert_eq!(result, std::path::PathBuf::from("a/b/c"));
519 }
520
521 #[test]
522 fn normalize_path_resolves_parent_dir() {
523 let input = std::path::Path::new("a/b/../c");
524 let result = normalize_path(input);
525 assert_eq!(result, std::path::PathBuf::from("a/c"));
526 }
527
528 #[test]
529 fn normalize_path_handles_leading_dotdot() {
530 let input = std::path::Path::new("../../../etc/passwd");
531 let result = normalize_path(input);
532 assert_eq!(result, std::path::PathBuf::from("etc/passwd"));
534 }
535
536 #[test]
537 fn build_resource_path_rejects_parent_dir_traversal() {
538 let result = build_resource_path("test-plugin", Some("../../../secret"));
539 assert!(result.is_err());
540 match result {
541 Err(HostApiError::PathTraversalRejected { path }) => {
542 assert!(path.contains(".."));
543 }
544 _ => panic!("expected PathTraversalRejected"),
545 }
546 }
547
548 #[test]
549 fn build_resource_path_rejects_cross_plugin_traversal() {
550 let result = build_resource_path("test", Some("../test_evil/secret.txt"));
552 assert!(matches!(
553 result,
554 Err(HostApiError::PathTraversalRejected { .. })
555 ));
556 }
557
558 #[test]
559 fn build_resource_path_rejects_dot_literal() {
560 let result = build_resource_path("test-plugin", Some("."));
561 assert!(matches!(
562 result,
563 Err(HostApiError::PathTraversalRejected { .. })
564 ));
565 }
566
567 #[test]
568 fn build_resource_path_rejects_dotdot_literal() {
569 let result = build_resource_path("test-plugin", Some(".."));
570 assert!(matches!(
571 result,
572 Err(HostApiError::PathTraversalRejected { .. })
573 ));
574 }
575
576 #[test]
577 fn build_resource_path_accepts_valid_filename() {
578 let result = build_resource_path("test-plugin", Some("icon.png"));
579 assert!(result.is_ok());
580 let path = result.unwrap();
581 assert!(path.starts_with("resources/test-plugin/"));
582 assert!(path.ends_with("icon.png"));
583 }
584
585 #[test]
586 fn build_cache_path_accepts_valid_segments() {
587 let result = build_cache_path(
588 "C:/mock/zl-cache",
589 "test-plugin",
590 "model-embedding",
591 "ab/abc123.bin",
592 );
593 assert!(result.is_ok());
594 let path = result.unwrap();
595 assert!(path.starts_with("C:/mock/zl-cache/test-plugin/model-embedding/"));
596 assert!(path.ends_with("abc123.bin"));
597 }
598
599 #[test]
600 fn build_cache_path_rejects_traversal() {
601 for (domain, key) in [
602 ("..", "a.bin"),
603 ("a", "../../x.bin"),
604 ("a", "../b/x.bin"),
605 ("a", ".."),
606 ("a", "."),
607 ("a", ""),
608 ] {
609 assert!(
610 build_cache_path("C:/mock/zl-cache", "test-plugin", domain, key).is_err(),
611 "应拒绝 domain={domain:?} key={key:?}"
612 );
613 }
614 }
615
616 #[test]
617 fn build_resource_path_accepts_none_filename() {
618 let result = build_resource_path("test-plugin", None);
619 assert!(result.is_ok());
620 let path = result.unwrap();
621 assert_eq!(path, "resources/test-plugin");
622 }
623
624 #[test]
625 fn build_resource_path_rejects_empty_filename() {
626 let result = build_resource_path("test-plugin", Some(""));
627 assert!(matches!(
628 result,
629 Err(HostApiError::PathTraversalRejected { .. })
630 ));
631 }
632
633 #[test]
636 fn starts_with_component_boundary_prevents_false_prefix_match() {
637 let base = std::path::Path::new("resources/test");
641 let evil = std::path::Path::new("resources/test_evil/secret.txt");
642 assert!(!evil.starts_with(base));
643 }
644
645 #[test]
646 fn build_resource_path_rejects_same_prefix_traversal() {
647 let result = build_resource_path("test", Some("../test_evil/secret.txt"));
651 assert!(matches!(
652 result,
653 Err(HostApiError::PathTraversalRejected { .. })
654 ));
655 }
656
657 #[test]
658 fn build_resource_path_allows_subdirectory_with_same_prefix() {
659 let result = build_resource_path("test", Some("test_data.txt"));
662 assert!(result.is_ok());
663 let path = result.unwrap();
664 assert_eq!(path, "resources/test/test_data.txt");
665 }
666
667 #[test]
668 fn build_resource_path_allows_nested_subdir() {
669 let result = build_resource_path("test", Some("subdir/file.png"));
671 assert!(result.is_ok());
672 let path = result.unwrap();
673 assert_eq!(path, "resources/test/subdir/file.png");
674 }
675
676 #[test]
677 fn pathbuf_push_empty_is_functionally_noop() {
678 let mut with_trailing = std::path::PathBuf::from("resources/test");
681 with_trailing.push("");
682 let without_trailing = std::path::PathBuf::from("resources/test");
683
684 assert_eq!(with_trailing, without_trailing);
686
687 let child = std::path::Path::new("resources/test/icon.png");
689 assert!(child.starts_with(&with_trailing));
690 assert!(child.starts_with(&without_trailing));
691
692 let unrelated = std::path::Path::new("resources/test_evil/secret.txt");
693 assert!(!unrelated.starts_with(&with_trailing));
694 assert!(!unrelated.starts_with(&without_trailing));
695 }
696}