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::theme::Theme;
22use crate::services::timer::types::{TimerCallback, TimerId};
23use crate::services::IconRequest;
24
25pub struct PluginHandle {
30 plugin_id: String,
31 config: RwLock<PluginSdkConfig>,
32 capabilities: PlatformCapabilities,
33 host: Arc<dyn PluginHost>,
35}
36
37impl PluginHandle {
38 pub fn new(
42 plugin_id: String,
43 config: PluginSdkConfig,
44 capabilities: PlatformCapabilities,
45 host: Arc<dyn PluginHost>,
46 ) -> Self {
47 Self {
48 plugin_id,
49 config: RwLock::new(config),
50 capabilities,
51 host,
52 }
53 }
54
55 pub fn plugin_id(&self) -> &str {
57 &self.plugin_id
58 }
59
60 fn icon_cache_level(&self) -> CacheLevel {
62 self.config.read().icon_cache_level.unwrap_or_default()
63 }
64
65 pub fn update_config(&self, config: PluginSdkConfig) {
67 *self.config.write() = config;
68 }
69
70 pub fn capabilities(&self) -> &PlatformCapabilities {
72 &self.capabilities
73 }
74
75 pub async fn get_icon(&self, request: IconRequest) -> Result<Vec<u8>, HostApiError> {
79 let level = self.icon_cache_level();
80 self.host.get_icon(&request, level).await
81 }
82
83 pub async fn get_icon_or_default(&self, request: IconRequest) -> Vec<u8> {
85 let level = self.icon_cache_level();
86 self.host.get_icon_or_default(&request, level).await
87 }
88
89 pub async fn get_icon_and_update_cache(
91 &self,
92 request: IconRequest,
93 ) -> Result<Vec<u8>, HostApiError> {
94 let level = self.icon_cache_level();
95 self.host.get_icon_and_update_cache(&request, level).await
96 }
97
98 pub async fn override_icon_cache(
100 &self,
101 original_request: &IconRequest,
102 custom_icon_path: &str,
103 ) -> Result<(), HostApiError> {
104 self.host
105 .override_icon_cache(original_request, custom_icon_path)
106 .await
107 }
108
109 pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
113 self.host.shell_open(target).await
114 }
115
116 pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
118 self.host.shell_open_folder(path).await
119 }
120
121 pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
123 self.host.shell_execute_elevation(path).await
124 }
125
126 pub async fn shell_execute_command(&self, command: &str) -> Result<(), HostApiError> {
128 self.host.shell_execute_command(command).await
129 }
130
131 pub async fn activate_window_by_process(
135 &self,
136 process_name: &str,
137 ) -> Result<bool, HostApiError> {
138 self.host.activate_window_by_process(process_name).await
139 }
140
141 pub async fn activate_window_by_title(&self, title: &str) -> Result<bool, HostApiError> {
143 self.host.activate_window_by_title(title).await
144 }
145
146 pub async fn activate_window_by_pid(&self, pid: u32) -> Result<bool, HostApiError> {
148 self.host.activate_window_by_pid(pid).await
149 }
150
151 pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
155 self.host.resolve_path(path)
156 }
157
158 pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
162 self.host.set_clipboard_text(text)
163 }
164
165 pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
169 self.host.enumerate_apps().await
170 }
171
172 pub async fn launch_app(
174 &self,
175 app_id: &str,
176 args: Option<&[String]>,
177 ) -> Result<u32, HostApiError> {
178 self.host.launch_app(app_id, args).await
179 }
180
181 pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
185 self.host.get_app_icon_path(name)
186 }
187
188 pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
192 self.host.resolve_lnk_target(lnk_path)
193 }
194
195 pub fn parse_localized_names_from_dir(&self, dir_path: &Path) -> HashMap<String, String> {
197 self.host.parse_localized_names_from_dir(dir_path)
198 }
199
200 pub fn get_theme(&self) -> Result<Theme, HostApiError> {
204 self.host.get_theme()
205 }
206
207 pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
209 self.host.get_system_theme()
210 }
211
212 pub fn model_list(&self) -> Vec<ModelInfo> {
216 self.host.model_list()
217 }
218
219 pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
221 self.host.model_chat(req).await
222 }
223
224 pub async fn model_embedding(
226 &self,
227 req: ModelEmbeddingRequest,
228 ) -> Result<ModelEmbeddingResponse, ModelError> {
229 self.host.model_embedding(req).await
230 }
231
232 pub async fn model_similarity(
234 &self,
235 req: ModelSimilarityRequest,
236 ) -> Result<ModelSimilarityResponse, ModelError> {
237 self.host.model_similarity(req).await
238 }
239
240 pub async fn resolve_parameters(
244 &self,
245 template: &str,
246 user_args: &[String],
247 snapshot: &ParameterSnapshot,
248 ) -> Result<String, HostApiError> {
249 self.host
250 .resolve_parameters(template, user_args, snapshot)
251 .await
252 }
253
254 pub fn count_user_parameters(&self, template: &str) -> usize {
256 self.host.count_user_parameters(template)
257 }
258
259 pub fn has_system_parameters(&self, template: &str) -> bool {
261 self.host.has_system_parameters(template)
262 }
263
264 pub async fn set_timeout(
268 &self,
269 delay: Duration,
270 callback: TimerCallback,
271 ) -> Result<TimerId, HostApiError> {
272 self.host.set_timeout(delay, callback).await
273 }
274
275 pub async fn set_interval(
277 &self,
278 interval: Duration,
279 callback: TimerCallback,
280 ) -> Result<TimerId, HostApiError> {
281 self.host.set_interval(interval, callback).await
282 }
283
284 pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
286 self.host.cancel_timer(id).await
287 }
288
289 pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
291 self.host.cancel_all_timers().await
292 }
293
294 pub async fn resource_upload(
298 &self,
299 resource_id: &str,
300 file_path: &str,
301 max_size: Option<u64>,
302 ) -> Result<String, HostApiError> {
303 self.host
304 .resource_upload(&self.plugin_id, resource_id, file_path, max_size)
305 .await
306 }
307
308 pub async fn resource_put(&self, resource_id: &str, data: &[u8]) -> Result<(), HostApiError> {
310 self.host
311 .resource_put(&self.plugin_id, resource_id, data)
312 .await
313 }
314
315 pub async fn resource_get(&self, resource_id: &str) -> Result<Vec<u8>, HostApiError> {
317 self.host.resource_get(&self.plugin_id, resource_id).await
318 }
319
320 pub async fn resource_delete(&self, resource_id: &str) -> Result<(), HostApiError> {
322 self.host
323 .resource_delete(&self.plugin_id, resource_id)
324 .await
325 }
326
327 pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
329 self.host.resource_list(&self.plugin_id).await
330 }
331
332 pub async fn cache_put(
338 &self,
339 domain: &str,
340 key: &str,
341 data: &[u8],
342 ) -> Result<(), HostApiError> {
343 self.host
344 .cache_put(&self.plugin_id, domain, key, data)
345 .await
346 }
347
348 pub async fn cache_get(
350 &self,
351 domain: &str,
352 key: &str,
353 ) -> Result<Option<Vec<u8>>, HostApiError> {
354 self.host.cache_get(&self.plugin_id, domain, key).await
355 }
356
357 pub async fn cache_delete(&self, domain: &str, key: &str) -> Result<(), HostApiError> {
359 self.host.cache_delete(&self.plugin_id, domain, key).await
360 }
361
362 pub async fn cache_cleanup(
364 &self,
365 domain: &str,
366 max_entries: usize,
367 ) -> Result<(), HostApiError> {
368 self.host
369 .cache_cleanup(&self.plugin_id, domain, max_entries)
370 .await
371 }
372
373 pub fn register_hotkey_callback(
377 &self,
378 id: &str,
379 filter: HotkeyEventFilter,
380 callback: HotkeyCallback,
381 ) {
382 self.host
383 .register_hotkey_callback(&self.plugin_id, id, filter, callback);
384 }
385
386 pub fn unregister_hotkey_callback(&self, id: &str) {
388 self.host.unregister_hotkey_callback(&self.plugin_id, id);
389 }
390
391 pub fn register_installation_callback(&self, id: &str, callback: InstallationCallback) {
393 self.host
394 .register_installation_callback(&self.plugin_id, id, callback);
395 }
396
397 pub fn unregister_installation_callback(&self, id: &str) {
399 self.host
400 .unregister_installation_callback(&self.plugin_id, id);
401 }
402
403 pub fn register_focus_callback(&self, id: &str, callback: FocusCallback) {
405 self.host
406 .register_focus_callback(&self.plugin_id, id, callback);
407 }
408
409 pub fn unregister_focus_callback(&self, id: &str) {
411 self.host.unregister_focus_callback(&self.plugin_id, id);
412 }
413}
414
415pub fn build_resource_path(
420 plugin_id: &str,
421 filename: Option<&str>,
422) -> Result<String, HostApiError> {
423 let base = std::path::PathBuf::from_iter(["resources", plugin_id]);
424 let base_normalized = normalize_path(&base);
425
426 let mut path = base.clone();
427 if let Some(name) = filename {
428 if name.is_empty() || name == "." || name == ".." {
430 return Err(HostApiError::PathTraversalRejected {
431 path: name.to_string(),
432 });
433 }
434 path.push(name);
435 let normalized = normalize_path(&path);
436 let is_valid = normalized == base_normalized || normalized.starts_with(&base_normalized);
439 if !is_valid {
440 return Err(HostApiError::PathTraversalRejected {
441 path: name.to_string(),
442 });
443 }
444 }
445 Ok(path.to_string_lossy().replace('\\', "/"))
446}
447
448fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
451 let mut result = std::path::PathBuf::new();
452 for component in path.components() {
453 match component {
454 std::path::Component::ParentDir => {
455 result.pop();
456 }
457 std::path::Component::CurDir => {
458 }
460 other => {
461 result.push(other);
462 }
463 }
464 }
465 result
466}
467
468pub fn build_cache_path(
473 cache_root: &str,
474 plugin_id: &str,
475 domain: &str,
476 key: &str,
477) -> Result<String, HostApiError> {
478 for segment in [domain, key] {
479 if segment.is_empty() || segment == "." || segment == ".." {
480 return Err(HostApiError::PathTraversalRejected {
481 path: segment.to_string(),
482 });
483 }
484 }
485 let base = std::path::Path::new(cache_root)
486 .join(plugin_id)
487 .join(domain);
488 let base_normalized = normalize_path(&base);
489 let mut path = base;
490 path.push(key);
491 let normalized = normalize_path(&path);
492 if normalized != base_normalized && !normalized.starts_with(&base_normalized) {
493 return Err(HostApiError::PathTraversalRejected {
494 path: key.to_string(),
495 });
496 }
497 Ok(path.to_string_lossy().replace('\\', "/"))
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn normalize_path_removes_cur_dir() {
506 let input = std::path::Path::new("a/./b/./c");
507 let result = normalize_path(input);
508 assert_eq!(result, std::path::PathBuf::from("a/b/c"));
509 }
510
511 #[test]
512 fn normalize_path_resolves_parent_dir() {
513 let input = std::path::Path::new("a/b/../c");
514 let result = normalize_path(input);
515 assert_eq!(result, std::path::PathBuf::from("a/c"));
516 }
517
518 #[test]
519 fn normalize_path_handles_leading_dotdot() {
520 let input = std::path::Path::new("../../../etc/passwd");
521 let result = normalize_path(input);
522 assert_eq!(result, std::path::PathBuf::from("etc/passwd"));
524 }
525
526 #[test]
527 fn build_resource_path_rejects_parent_dir_traversal() {
528 let result = build_resource_path("test-plugin", Some("../../../secret"));
529 assert!(result.is_err());
530 match result {
531 Err(HostApiError::PathTraversalRejected { path }) => {
532 assert!(path.contains(".."));
533 }
534 _ => panic!("expected PathTraversalRejected"),
535 }
536 }
537
538 #[test]
539 fn build_resource_path_rejects_cross_plugin_traversal() {
540 let result = build_resource_path("test", Some("../test_evil/secret.txt"));
542 assert!(matches!(
543 result,
544 Err(HostApiError::PathTraversalRejected { .. })
545 ));
546 }
547
548 #[test]
549 fn build_resource_path_rejects_dot_literal() {
550 let result = build_resource_path("test-plugin", Some("."));
551 assert!(matches!(
552 result,
553 Err(HostApiError::PathTraversalRejected { .. })
554 ));
555 }
556
557 #[test]
558 fn build_resource_path_rejects_dotdot_literal() {
559 let result = build_resource_path("test-plugin", Some(".."));
560 assert!(matches!(
561 result,
562 Err(HostApiError::PathTraversalRejected { .. })
563 ));
564 }
565
566 #[test]
567 fn build_resource_path_accepts_valid_filename() {
568 let result = build_resource_path("test-plugin", Some("icon.png"));
569 assert!(result.is_ok());
570 let path = result.unwrap();
571 assert!(path.starts_with("resources/test-plugin/"));
572 assert!(path.ends_with("icon.png"));
573 }
574
575 #[test]
576 fn build_cache_path_accepts_valid_segments() {
577 let result = build_cache_path(
578 "C:/mock/zl-cache",
579 "test-plugin",
580 "model-embedding",
581 "ab/abc123.bin",
582 );
583 assert!(result.is_ok());
584 let path = result.unwrap();
585 assert!(path.starts_with("C:/mock/zl-cache/test-plugin/model-embedding/"));
586 assert!(path.ends_with("abc123.bin"));
587 }
588
589 #[test]
590 fn build_cache_path_rejects_traversal() {
591 for (domain, key) in [
592 ("..", "a.bin"),
593 ("a", "../../x.bin"),
594 ("a", "../b/x.bin"),
595 ("a", ".."),
596 ("a", "."),
597 ("a", ""),
598 ] {
599 assert!(
600 build_cache_path("C:/mock/zl-cache", "test-plugin", domain, key).is_err(),
601 "应拒绝 domain={domain:?} key={key:?}"
602 );
603 }
604 }
605
606 #[test]
607 fn build_resource_path_accepts_none_filename() {
608 let result = build_resource_path("test-plugin", None);
609 assert!(result.is_ok());
610 let path = result.unwrap();
611 assert_eq!(path, "resources/test-plugin");
612 }
613
614 #[test]
615 fn build_resource_path_rejects_empty_filename() {
616 let result = build_resource_path("test-plugin", Some(""));
617 assert!(matches!(
618 result,
619 Err(HostApiError::PathTraversalRejected { .. })
620 ));
621 }
622
623 #[test]
626 fn starts_with_component_boundary_prevents_false_prefix_match() {
627 let base = std::path::Path::new("resources/test");
631 let evil = std::path::Path::new("resources/test_evil/secret.txt");
632 assert!(!evil.starts_with(base));
633 }
634
635 #[test]
636 fn build_resource_path_rejects_same_prefix_traversal() {
637 let result = build_resource_path("test", Some("../test_evil/secret.txt"));
641 assert!(matches!(
642 result,
643 Err(HostApiError::PathTraversalRejected { .. })
644 ));
645 }
646
647 #[test]
648 fn build_resource_path_allows_subdirectory_with_same_prefix() {
649 let result = build_resource_path("test", Some("test_data.txt"));
652 assert!(result.is_ok());
653 let path = result.unwrap();
654 assert_eq!(path, "resources/test/test_data.txt");
655 }
656
657 #[test]
658 fn build_resource_path_allows_nested_subdir() {
659 let result = build_resource_path("test", Some("subdir/file.png"));
661 assert!(result.is_ok());
662 let path = result.unwrap();
663 assert_eq!(path, "resources/test/subdir/file.png");
664 }
665
666 #[test]
667 fn pathbuf_push_empty_is_functionally_noop() {
668 let mut with_trailing = std::path::PathBuf::from("resources/test");
671 with_trailing.push("");
672 let without_trailing = std::path::PathBuf::from("resources/test");
673
674 assert_eq!(with_trailing, without_trailing);
676
677 let child = std::path::Path::new("resources/test/icon.png");
679 assert!(child.starts_with(&with_trailing));
680 assert!(child.starts_with(&without_trailing));
681
682 let unrelated = std::path::Path::new("resources/test_evil/secret.txt");
683 assert!(!unrelated.starts_with(&with_trailing));
684 assert!(!unrelated.starts_with(&without_trailing));
685 }
686}