1use crate::host::{CacheLevel, HostApiError, OpenTarget};
2use crate::platform::capabilities::PlatformCapabilities;
3use crate::services::app::{AppEnumerator, AppInfo, AppLauncher};
4use crate::services::clipboard::ClipboardManager;
5use crate::services::focus_monitor::{FocusCallback, FocusMonitor};
6use crate::services::hotkey::types::{HotkeyCallback, HotkeyEventFilter};
7use crate::services::hotkey::HotkeyManager;
8use crate::services::icon::icon_cache::IconCacheService;
9use crate::services::icon::icon_extractor::IconExtractor;
10use crate::services::installation_monitor::types::InstallationCallback;
11use crate::services::installation_monitor::InstallationMonitor;
12use crate::services::model::{
13 ModelChatRequest, ModelChatResponse, ModelEmbeddingRequest, ModelEmbeddingResponse, ModelError,
14 ModelInfo, ModelService, ModelSimilarityRequest, ModelSimilarityResponse,
15};
16use crate::services::parameter::resolver::ParameterResolver;
17use crate::services::parameter::types::ParameterSnapshot;
18use crate::services::path::path_resolver::{KnownPath, PathResolver};
19use crate::services::resource::AppResourceService;
20use crate::services::shell::lnk_resolver::LnkResolver;
21use crate::services::shell::resource_loader::ResourceLoader;
22use crate::services::shell::ShellExecutor;
23use crate::services::storage::storage_service::StorageService;
24use crate::services::theme::{Theme, ThemeProvider};
25use crate::services::timer::types::{TimerCallback, TimerId, TimerMode};
26use crate::services::timer::TimerManager;
27use crate::services::window::WindowManager;
28use crate::services::IconRequest;
29use parking_lot::RwLock;
30use std::sync::Arc;
31
32use super::sdk_config::PluginSdkConfig;
33pub struct PluginHandle {
38 plugin_id: String,
39 config: RwLock<PluginSdkConfig>,
40 capabilities: PlatformCapabilities,
41 icon_extractor: Arc<dyn IconExtractor>,
43 icon_cache: Arc<IconCacheService>,
45 shell_executor: Arc<dyn ShellExecutor>,
47 window_manager: Arc<dyn WindowManager>,
49 path_resolver: Arc<dyn PathResolver>,
51 app_enumerator: Arc<dyn AppEnumerator>,
53 app_launcher: Arc<dyn AppLauncher>,
55 lnk_resolver: Arc<dyn LnkResolver>,
57 resource_loader: Arc<dyn ResourceLoader>,
59 parameter_resolver: Arc<dyn ParameterResolver>,
61 timer_manager: Arc<dyn TimerManager>,
63 app_resource: Arc<AppResourceService>,
65 storage: Arc<RwLock<Arc<dyn StorageService>>>,
67 hotkey_manager: Arc<dyn HotkeyManager>,
69 installation_monitor: Arc<dyn InstallationMonitor>,
71 focus_monitor: Arc<dyn FocusMonitor>,
73 clipboard_manager: Arc<dyn ClipboardManager>,
75 theme_provider: Arc<dyn ThemeProvider>,
77 theme_mode: Arc<RwLock<String>>,
79 model_service: Arc<dyn ModelService>,
81}
82
83impl PluginHandle {
84 #[allow(clippy::too_many_arguments)]
86 pub fn new(
87 plugin_id: String,
88 config: PluginSdkConfig,
89 capabilities: PlatformCapabilities,
90 theme_provider: Arc<dyn ThemeProvider>,
91 theme_mode: Arc<RwLock<String>>,
92 icon_extractor: Arc<dyn IconExtractor>,
93 icon_cache: Arc<IconCacheService>,
94 shell_executor: Arc<dyn ShellExecutor>,
95 window_manager: Arc<dyn WindowManager>,
96 path_resolver: Arc<dyn PathResolver>,
97 app_enumerator: Arc<dyn AppEnumerator>,
98 app_launcher: Arc<dyn AppLauncher>,
99 lnk_resolver: Arc<dyn LnkResolver>,
100 resource_loader: Arc<dyn ResourceLoader>,
101 parameter_resolver: Arc<dyn ParameterResolver>,
102 timer_manager: Arc<dyn TimerManager>,
103 app_resource: Arc<AppResourceService>,
104 storage: Arc<RwLock<Arc<dyn StorageService>>>,
105 hotkey_manager: Arc<dyn HotkeyManager>,
106 installation_monitor: Arc<dyn InstallationMonitor>,
107 focus_monitor: Arc<dyn FocusMonitor>,
108 clipboard_manager: Arc<dyn ClipboardManager>,
109 model_service: Arc<dyn ModelService>,
110 ) -> Self {
111 Self {
112 plugin_id,
113 config: RwLock::new(config),
114 capabilities,
115 theme_provider,
116 theme_mode,
117 icon_extractor,
118 icon_cache,
119 shell_executor,
120 window_manager,
121 path_resolver,
122 app_enumerator,
123 app_launcher,
124 lnk_resolver,
125 resource_loader,
126 parameter_resolver,
127 timer_manager,
128 app_resource,
129 storage,
130 hotkey_manager,
131 installation_monitor,
132 focus_monitor,
133 clipboard_manager,
134 model_service,
135 }
136 }
137
138 pub fn plugin_id(&self) -> &str {
140 &self.plugin_id
141 }
142
143 fn icon_cache_level(&self) -> CacheLevel {
145 self.config.read().icon_cache_level.unwrap_or_default()
146 }
147
148 pub async fn get_icon(&self, request: IconRequest) -> Result<Vec<u8>, HostApiError> {
154 let level = self.icon_cache_level();
155 self.icon_extractor
156 .get_icon(&self.icon_cache, &request, level)
157 .await
158 }
159
160 pub async fn get_icon_or_default(&self, request: IconRequest) -> Vec<u8> {
163 let level = self.icon_cache_level();
164 match self
165 .icon_extractor
166 .get_icon(&self.icon_cache, &request, level)
167 .await
168 {
169 Ok(data) if !data.is_empty() => data,
170 _ => {
171 tracing::warn!("图标提取失败,使用默认图标: {:?}", request);
172 self.icon_extractor.load_default_icon(&request).await
173 }
174 }
175 }
176
177 pub async fn get_icon_and_update_cache(
182 &self,
183 request: IconRequest,
184 ) -> Result<Vec<u8>, HostApiError> {
185 let level = self.icon_cache_level();
186 self.icon_extractor
187 .get_icon_and_update_cache(&self.icon_cache, &request, level)
188 .await
189 }
190
191 pub async fn override_icon_cache(
196 &self,
197 original_request: &IconRequest,
198 custom_icon_path: &str,
199 ) -> Result<(), HostApiError> {
200 let hash_key = original_request.get_hash_string() + ".webp";
202
203 let custom_request = IconRequest::Path(custom_icon_path.to_string());
205 let data = self
206 .icon_extractor
207 .extract_and_process(&custom_request)
208 .await?;
209
210 self.icon_cache.set_l1(&hash_key, data.clone());
212 self.icon_cache.set_l2(&hash_key, data).await;
213
214 Ok(())
215 }
216
217 pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
223 self.shell_executor.shell_open(&target).await
224 }
225
226 pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
230 self.shell_executor.shell_open_folder(path).await
231 }
232
233 pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
237 self.shell_executor.shell_execute_elevation(path).await
238 }
239
240 pub async fn shell_execute_command(&self, command: &str) -> Result<(), HostApiError> {
244 self.shell_executor.shell_execute_command(command).await
245 }
246
247 pub async fn activate_window_by_process(
253 &self,
254 process_name: &str,
255 ) -> Result<bool, HostApiError> {
256 self.window_manager
257 .activate_window_by_process(process_name)
258 .await
259 }
260
261 pub async fn activate_window_by_title(&self, title: &str) -> Result<bool, HostApiError> {
265 self.window_manager.activate_window_by_title(title).await
266 }
267
268 pub async fn activate_window_by_pid(&self, pid: u32) -> Result<bool, HostApiError> {
272 self.window_manager.activate_window_by_pid(pid).await
273 }
274
275 pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
281 self.path_resolver.resolve_path(path)
282 }
283
284 pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
290 self.clipboard_manager.set_text(text)
291 }
292
293 pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
299 self.app_enumerator.enumerate_apps().await
300 }
301
302 pub async fn launch_app(
306 &self,
307 app_id: &str,
308 args: Option<&[String]>,
309 ) -> Result<u32, HostApiError> {
310 self.app_launcher.launch_app(app_id, args).await
311 }
312
313 pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
319 self.app_resource.get_icon_path(name)
320 }
321
322 pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
328 self.lnk_resolver.resolve_lnk_target(lnk_path)
329 }
330
331 pub fn parse_localized_names_from_dir(
335 &self,
336 dir_path: &std::path::Path,
337 ) -> std::collections::HashMap<String, String> {
338 self.resource_loader
339 .parse_localized_names_from_dir(dir_path)
340 }
341
342 pub fn update_config(&self, config: PluginSdkConfig) {
349 *self.config.write() = config;
350 }
351
352 pub fn capabilities(&self) -> &PlatformCapabilities {
358 &self.capabilities
359 }
360
361 pub fn get_theme(&self) -> Result<Theme, HostApiError> {
364 match self.theme_mode.read().as_str() {
365 "light" => Ok(Theme::Light),
366 "dark" => Ok(Theme::Dark),
367 _ => self.theme_provider.current_system_theme(),
368 }
369 }
370
371 pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
373 self.theme_provider.current_system_theme()
374 }
375
376 pub fn model_list(&self) -> Vec<ModelInfo> {
380 self.model_service.list_models()
381 }
382
383 pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
385 self.model_service.chat(req).await
386 }
387
388 pub async fn model_embedding(
390 &self,
391 req: ModelEmbeddingRequest,
392 ) -> Result<ModelEmbeddingResponse, ModelError> {
393 self.model_service.embedding(req).await
394 }
395
396 pub async fn model_similarity(
398 &self,
399 req: ModelSimilarityRequest,
400 ) -> Result<ModelSimilarityResponse, ModelError> {
401 self.model_service.similarity(req).await
402 }
403
404 pub async fn resolve_parameters(
415 &self,
416 template: &str,
417 user_args: &[String],
418 snapshot: &ParameterSnapshot,
419 ) -> Result<String, HostApiError> {
420 self.parameter_resolver
421 .resolve(template, user_args, snapshot)
422 .await
423 .map_err(|e| HostApiError::ParameterResolutionFailed {
424 reason: e.to_string(),
425 })
426 }
427
428 pub fn count_user_parameters(&self, template: &str) -> usize {
433 self.parameter_resolver.count_user_parameters(template)
434 }
435
436 pub fn has_system_parameters(&self, template: &str) -> bool {
441 self.parameter_resolver.has_system_parameters(template)
442 }
443
444 pub async fn set_timeout(
454 &self,
455 delay: std::time::Duration,
456 callback: TimerCallback,
457 ) -> Result<TimerId, HostApiError> {
458 self.timer_manager
459 .set_timer(delay, TimerMode::OneShot, callback)
460 .await
461 }
462
463 pub async fn set_interval(
471 &self,
472 interval: std::time::Duration,
473 callback: TimerCallback,
474 ) -> Result<TimerId, HostApiError> {
475 self.timer_manager
476 .set_timer(interval, TimerMode::Interval, callback)
477 .await
478 }
479
480 pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
484 self.timer_manager.cancel_timer(id).await
485 }
486
487 pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
489 self.timer_manager.cancel_all().await
490 }
491
492 pub async fn resource_upload(
495 &self,
496 resource_id: &str,
497 file_path: &str,
498 max_size: Option<u64>,
499 ) -> Result<String, HostApiError> {
500 let path = std::path::Path::new(file_path);
501
502 if let Some(limit) = max_size {
503 let metadata = tokio::fs::metadata(path).await.map_err(|e| {
504 HostApiError::StorageOperationFailed {
505 file: file_path.to_string(),
506 reason: format!("读取文件元数据失败: {}", e),
507 }
508 })?;
509 if metadata.len() > limit {
510 return Err(HostApiError::StorageOperationFailed {
511 file: file_path.to_string(),
512 reason: format!("文件大小 {} 超过限制 {} 字节", metadata.len(), limit),
513 });
514 }
515 }
516
517 let data =
518 tokio::fs::read(path)
519 .await
520 .map_err(|e| HostApiError::StorageOperationFailed {
521 file: file_path.to_string(),
522 reason: format!("读取文件失败: {}", e),
523 })?;
524
525 let storage_path = build_resource_path(&self.plugin_id, Some(resource_id))?;
527 let storage = self.storage.read().clone();
528 storage.upload(&storage_path, &data).await.map_err(|e| {
529 HostApiError::StorageOperationFailed {
530 file: storage_path,
531 reason: e.to_string(),
532 }
533 })?;
534 Ok(resource_id.to_string())
535 }
536
537 pub async fn resource_put(&self, resource_id: &str, data: &[u8]) -> Result<(), HostApiError> {
541 let storage_path = build_resource_path(&self.plugin_id, Some(resource_id))?;
542 let storage: Arc<dyn StorageService> = self.storage.read().clone();
543 storage.upload(&storage_path, data).await.map_err(|e| {
544 HostApiError::StorageOperationFailed {
545 file: storage_path,
546 reason: e.to_string(),
547 }
548 })
549 }
550
551 pub async fn resource_get(&self, resource_id: &str) -> Result<Vec<u8>, HostApiError> {
553 let path = build_resource_path(&self.plugin_id, Some(resource_id))?;
554 let storage = self.storage.read().clone();
555 storage
556 .download(&path)
557 .await
558 .map_err(|e| HostApiError::StorageOperationFailed {
559 file: path,
560 reason: e.to_string(),
561 })?
562 .ok_or_else(|| HostApiError::ResourceNotFound {
563 id: resource_id.to_string(),
564 })
565 }
566
567 pub async fn resource_delete(&self, resource_id: &str) -> Result<(), HostApiError> {
569 let path = build_resource_path(&self.plugin_id, Some(resource_id))?;
570 let storage = self.storage.read().clone();
571 storage
572 .delete(&path)
573 .await
574 .map_err(|e| HostApiError::StorageOperationFailed {
575 file: path,
576 reason: e.to_string(),
577 })
578 }
579
580 pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
582 let prefix = build_resource_path(&self.plugin_id, None)?;
583 let storage = self.storage.read().clone();
584 storage
585 .list(&prefix)
586 .await
587 .map_err(|e| HostApiError::StorageOperationFailed {
588 file: prefix,
589 reason: e.to_string(),
590 })
591 }
592
593 pub async fn cache_put(
602 &self,
603 domain: &str,
604 key: &str,
605 data: &[u8],
606 ) -> Result<(), HostApiError> {
607 let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
608 let path = build_cache_path(&cache_root, &self.plugin_id, domain, key)?;
609 if let Some(parent) = std::path::Path::new(&path).parent() {
610 tokio::fs::create_dir_all(parent).await.map_err(|e| {
611 HostApiError::StorageOperationFailed {
612 file: path.clone(),
613 reason: format!("创建缓存目录失败: {}", e),
614 }
615 })?;
616 }
617 tokio::fs::write(&path, data)
618 .await
619 .map_err(|e| HostApiError::StorageOperationFailed {
620 file: path,
621 reason: format!("写入缓存文件失败: {}", e),
622 })?;
623 Ok(())
624 }
625
626 pub async fn cache_get(
628 &self,
629 domain: &str,
630 key: &str,
631 ) -> Result<Option<Vec<u8>>, HostApiError> {
632 let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
633 let path = build_cache_path(&cache_root, &self.plugin_id, domain, key)?;
634 match tokio::fs::read(&path).await {
635 Ok(data) => Ok(Some(data)),
636 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
637 Err(e) => Err(HostApiError::StorageOperationFailed {
638 file: path,
639 reason: format!("读取缓存文件失败: {}", e),
640 }),
641 }
642 }
643
644 pub async fn cache_delete(&self, domain: &str, key: &str) -> Result<(), HostApiError> {
646 let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
647 let path = build_cache_path(&cache_root, &self.plugin_id, domain, key)?;
648 match tokio::fs::remove_file(&path).await {
649 Ok(()) => Ok(()),
650 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
651 Err(e) => Err(HostApiError::StorageOperationFailed {
652 file: path,
653 reason: format!("删除缓存文件失败: {}", e),
654 }),
655 }
656 }
657
658 pub async fn cache_cleanup(
663 &self,
664 domain: &str,
665 max_entries: usize,
666 ) -> Result<(), HostApiError> {
667 let cache_root = self.resolve_path(KnownPath::AppCacheDir)?;
668 let domain_dir = std::path::Path::new(&cache_root)
669 .join(&self.plugin_id)
670 .join(domain);
671 if !domain_dir.exists() {
672 return Ok(());
673 }
674
675 let mut entries: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
677 for sub in
678 std::fs::read_dir(&domain_dir).map_err(|e| HostApiError::StorageOperationFailed {
679 file: domain_dir.to_string_lossy().to_string(),
680 reason: format!("读取缓存目录失败: {}", e),
681 })?
682 {
683 let sub = sub.map_err(|e| HostApiError::StorageOperationFailed {
684 file: domain_dir.to_string_lossy().to_string(),
685 reason: format!("读取缓存子目录失败: {}", e),
686 })?;
687 if !sub.path().is_dir() {
688 continue;
689 }
690 for file in
691 std::fs::read_dir(sub.path()).map_err(|e| HostApiError::StorageOperationFailed {
692 file: sub.path().to_string_lossy().to_string(),
693 reason: format!("读取缓存分片目录失败: {}", e),
694 })?
695 {
696 let file = file.map_err(|e| HostApiError::StorageOperationFailed {
697 file: sub.path().to_string_lossy().to_string(),
698 reason: format!("读取缓存条目失败: {}", e),
699 })?;
700 let meta = file
701 .metadata()
702 .map_err(|e| HostApiError::StorageOperationFailed {
703 file: file.path().to_string_lossy().to_string(),
704 reason: format!("读取缓存条目元数据失败: {}", e),
705 })?;
706 if meta.is_file() {
707 entries.push((
708 meta.modified().unwrap_or(std::time::UNIX_EPOCH),
709 file.path(),
710 ));
711 }
712 }
713 }
714
715 if entries.len() <= max_entries {
716 return Ok(());
717 }
718 entries.sort_by_key(|(mtime, _)| *mtime);
719 let excess = entries.len() - max_entries;
720 for (_, path) in entries.into_iter().take(excess) {
721 let _ = std::fs::remove_file(&path);
722 }
723 Ok(())
724 }
725
726 fn prefix_callback_id(&self, id: &str) -> String {
730 format!("{}:{}", self.plugin_id, id)
731 }
732
733 pub fn register_hotkey_callback(
736 &self,
737 id: &str,
738 filter: HotkeyEventFilter,
739 callback: HotkeyCallback,
740 ) {
741 let prefixed = self.prefix_callback_id(id);
742 self.hotkey_manager
743 .register_callback(&prefixed, filter, callback);
744 }
745
746 pub fn unregister_hotkey_callback(&self, id: &str) {
749 let prefixed = self.prefix_callback_id(id);
750 self.hotkey_manager.unregister_callback(&prefixed);
751 }
752
753 pub fn register_installation_callback(&self, id: &str, callback: InstallationCallback) {
756 let prefixed = self.prefix_callback_id(id);
757 self.installation_monitor
758 .register_callback(&prefixed, callback);
759 }
760
761 pub fn unregister_installation_callback(&self, id: &str) {
764 let prefixed = self.prefix_callback_id(id);
765 self.installation_monitor.unregister_callback(&prefixed);
766 }
767
768 pub fn register_focus_callback(&self, id: &str, callback: FocusCallback) {
771 let prefixed = self.prefix_callback_id(id);
772 self.focus_monitor.register_callback(&prefixed, callback);
773 }
774
775 pub fn unregister_focus_callback(&self, id: &str) {
778 let prefixed = self.prefix_callback_id(id);
779 self.focus_monitor.unregister_callback(&prefixed);
780 }
781}
782
783fn build_resource_path(plugin_id: &str, filename: Option<&str>) -> Result<String, HostApiError> {
787 let base = std::path::PathBuf::from_iter(["resources", plugin_id]);
788 let base_normalized = normalize_path(&base);
789
790 let mut path = base.clone();
791 if let Some(name) = filename {
792 if name.is_empty() || name == "." || name == ".." {
794 return Err(HostApiError::PathTraversalRejected {
795 path: name.to_string(),
796 });
797 }
798 path.push(name);
799 let normalized = normalize_path(&path);
800 let is_valid = normalized == base_normalized || normalized.starts_with(&base_normalized);
803 if !is_valid {
804 return Err(HostApiError::PathTraversalRejected {
805 path: name.to_string(),
806 });
807 }
808 }
809 Ok(path.to_string_lossy().replace('\\', "/"))
810}
811
812fn normalize_path(path: &std::path::Path) -> std::path::PathBuf {
815 let mut result = std::path::PathBuf::new();
816 for component in path.components() {
817 match component {
818 std::path::Component::ParentDir => {
819 result.pop();
820 }
821 std::path::Component::CurDir => {
822 }
824 other => {
825 result.push(other);
826 }
827 }
828 }
829 result
830}
831
832fn build_cache_path(
836 cache_root: &str,
837 plugin_id: &str,
838 domain: &str,
839 key: &str,
840) -> Result<String, HostApiError> {
841 for segment in [domain, key] {
842 if segment.is_empty() || segment == "." || segment == ".." {
843 return Err(HostApiError::PathTraversalRejected {
844 path: segment.to_string(),
845 });
846 }
847 }
848 let base = std::path::Path::new(cache_root)
849 .join(plugin_id)
850 .join(domain);
851 let base_normalized = normalize_path(&base);
852 let mut path = base;
853 path.push(key);
854 let normalized = normalize_path(&path);
855 if normalized != base_normalized && !normalized.starts_with(&base_normalized) {
856 return Err(HostApiError::PathTraversalRejected {
857 path: key.to_string(),
858 });
859 }
860 Ok(path.to_string_lossy().replace('\\', "/"))
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866
867 #[test]
868 fn normalize_path_removes_cur_dir() {
869 let input = std::path::Path::new("a/./b/./c");
870 let result = normalize_path(input);
871 assert_eq!(result, std::path::PathBuf::from("a/b/c"));
872 }
873
874 #[test]
875 fn normalize_path_resolves_parent_dir() {
876 let input = std::path::Path::new("a/b/../c");
877 let result = normalize_path(input);
878 assert_eq!(result, std::path::PathBuf::from("a/c"));
879 }
880
881 #[test]
882 fn normalize_path_handles_leading_dotdot() {
883 let input = std::path::Path::new("../../../etc/passwd");
884 let result = normalize_path(input);
885 assert_eq!(result, std::path::PathBuf::from("etc/passwd"));
887 }
888
889 #[test]
890 fn build_resource_path_rejects_parent_dir_traversal() {
891 let result = build_resource_path("test-plugin", Some("../../../secret"));
892 assert!(result.is_err());
893 match result {
894 Err(HostApiError::PathTraversalRejected { path }) => {
895 assert!(path.contains(".."));
896 }
897 _ => panic!("expected PathTraversalRejected"),
898 }
899 }
900
901 #[test]
902 fn build_resource_path_rejects_cross_plugin_traversal() {
903 let result = build_resource_path("test", Some("../test_evil/secret.txt"));
905 assert!(matches!(
906 result,
907 Err(HostApiError::PathTraversalRejected { .. })
908 ));
909 }
910
911 #[test]
912 fn build_resource_path_rejects_dot_literal() {
913 let result = build_resource_path("test-plugin", Some("."));
914 assert!(matches!(
915 result,
916 Err(HostApiError::PathTraversalRejected { .. })
917 ));
918 }
919
920 #[test]
921 fn build_resource_path_rejects_dotdot_literal() {
922 let result = build_resource_path("test-plugin", Some(".."));
923 assert!(matches!(
924 result,
925 Err(HostApiError::PathTraversalRejected { .. })
926 ));
927 }
928
929 #[test]
930 fn build_resource_path_accepts_valid_filename() {
931 let result = build_resource_path("test-plugin", Some("icon.png"));
932 assert!(result.is_ok());
933 let path = result.unwrap();
934 assert!(path.starts_with("resources/test-plugin/"));
935 assert!(path.ends_with("icon.png"));
936 }
937
938 #[test]
939 fn build_cache_path_accepts_valid_segments() {
940 let result = build_cache_path(
941 "C:/mock/zl-cache",
942 "test-plugin",
943 "model-embedding",
944 "ab/abc123.bin",
945 );
946 assert!(result.is_ok());
947 let path = result.unwrap();
948 assert!(path.starts_with("C:/mock/zl-cache/test-plugin/model-embedding/"));
949 assert!(path.ends_with("abc123.bin"));
950 }
951
952 #[test]
953 fn build_cache_path_rejects_traversal() {
954 for (domain, key) in [
955 ("..", "a.bin"),
956 ("a", "../../x.bin"),
957 ("a", "../b/x.bin"),
958 ("a", ".."),
959 ("a", "."),
960 ("a", ""),
961 ] {
962 assert!(
963 build_cache_path("C:/mock/zl-cache", "test-plugin", domain, key).is_err(),
964 "应拒绝 domain={domain:?} key={key:?}"
965 );
966 }
967 }
968
969 #[test]
970 fn build_resource_path_accepts_none_filename() {
971 let result = build_resource_path("test-plugin", None);
972 assert!(result.is_ok());
973 let path = result.unwrap();
974 assert_eq!(path, "resources/test-plugin");
975 }
976
977 #[test]
978 fn build_resource_path_rejects_empty_filename() {
979 let result = build_resource_path("test-plugin", Some(""));
980 assert!(matches!(
981 result,
982 Err(HostApiError::PathTraversalRejected { .. })
983 ));
984 }
985
986 #[test]
989 fn starts_with_component_boundary_prevents_false_prefix_match() {
990 let base = std::path::Path::new("resources/test");
994 let evil = std::path::Path::new("resources/test_evil/secret.txt");
995 assert!(!evil.starts_with(base));
996 }
997
998 #[test]
999 fn build_resource_path_rejects_same_prefix_traversal() {
1000 let result = build_resource_path("test", Some("../test_evil/secret.txt"));
1004 assert!(matches!(
1005 result,
1006 Err(HostApiError::PathTraversalRejected { .. })
1007 ));
1008 }
1009
1010 #[test]
1011 fn build_resource_path_allows_subdirectory_with_same_prefix() {
1012 let result = build_resource_path("test", Some("test_data.txt"));
1015 assert!(result.is_ok());
1016 let path = result.unwrap();
1017 assert_eq!(path, "resources/test/test_data.txt");
1018 }
1019
1020 #[test]
1021 fn build_resource_path_allows_nested_subdir() {
1022 let result = build_resource_path("test", Some("subdir/file.png"));
1024 assert!(result.is_ok());
1025 let path = result.unwrap();
1026 assert_eq!(path, "resources/test/subdir/file.png");
1027 }
1028
1029 #[test]
1030 fn pathbuf_push_empty_is_functionally_noop() {
1031 let mut with_trailing = std::path::PathBuf::from("resources/test");
1034 with_trailing.push("");
1035 let without_trailing = std::path::PathBuf::from("resources/test");
1036
1037 assert_eq!(with_trailing, without_trailing);
1039
1040 let child = std::path::Path::new("resources/test/icon.png");
1042 assert!(child.starts_with(&with_trailing));
1043 assert!(child.starts_with(&without_trailing));
1044
1045 let unrelated = std::path::Path::new("resources/test_evil/secret.txt");
1046 assert!(!unrelated.starts_with(&with_trailing));
1047 assert!(!unrelated.starts_with(&without_trailing));
1048 }
1049}