Skip to main content

zerolaunch_plugin_api/host/
plugin_handle.rs

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
26/// 插件服务句柄(插件接口层)。
27/// 绑定插件身份(plugin_id)、配置(PluginSdkConfig)与能力集,并持有宿主操作契约
28/// `Arc<dyn PluginHost>`;所有服务操作委托宿主执行,宿主内部再决定直接处理或
29/// 转发平台实现。插件通过 HostApi::register() 获取此句柄。
30pub struct PluginHandle {
31    plugin_id: String,
32    config: RwLock<PluginSdkConfig>,
33    capabilities: PlatformCapabilities,
34    /// 宿主操作契约实现(Arc 共享,所有委托操作的执行节点)。
35    host: Arc<dyn PluginHost>,
36}
37
38impl PluginHandle {
39    /// 绑定插件身份与宿主契约构造句柄。
40    /// 参数:plugin_id - 插件唯一标识;config - 插件 SDK 配置;
41    ///       capabilities - 平台能力集;host - 宿主操作契约实现。
42    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    /// 返回当前插件 ID(宿主模型缓存等按插件隔离目录需要)。
57    pub fn plugin_id(&self) -> &str {
58        &self.plugin_id
59    }
60
61    /// 获取当前图标缓存等级,None 时返回默认值 Full。
62    fn icon_cache_level(&self) -> CacheLevel {
63        self.config.read().icon_cache_level.unwrap_or_default()
64    }
65
66    /// 更新插件的 SDK 配置,立即生效,影响后续所有服务调用。
67    pub fn update_config(&self, config: PluginSdkConfig) {
68        *self.config.write() = config;
69    }
70
71    /// 查询当前平台支持的能力集合。
72    pub fn capabilities(&self) -> &PlatformCapabilities {
73        &self.capabilities
74    }
75
76    // ===== 图标服务(委托宿主) =====
77
78    /// 根据图标请求提取图标数据,行为由注册时的缓存等级决定。
79    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    /// 提取图标数据,失败时回退到默认图标(永不返回错误)。
85    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    /// 强制从磁盘提取图标数据并根据缓存等级更新缓存。
91    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    /// 覆盖指定 IconRequest 的缓存图标为自定义图标文件。
100    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    // ===== Shell 服务(委托宿主) =====
111
112    /// 使用系统默认方式打开目标(文件/网址/文件夹)。
113    pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
114        self.host.shell_open(target).await
115    }
116
117    /// 在文件资源管理器中打开指定路径的父目录并选中该文件。
118    pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
119        self.host.shell_open_folder(path).await
120    }
121
122    /// 以管理员权限启动程序。
123    pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
124        self.host.shell_execute_elevation(path).await
125    }
126
127    /// 执行命令字符串。
128    /// 参数:command - 要执行的命令字符串;mode - 执行方式(后台静默 / 可见控制台窗口)。
129    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    // ===== 窗口服务(委托宿主) =====
138
139    /// 根据进程名(如 "chrome.exe")激活已存在的窗口。
140    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    /// 根据窗口标题的部分内容激活已存在的窗口。
148    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    /// 根据进程 PID 激活已存在的窗口。
153    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    // ===== 路径服务(委托宿主) =====
158
159    /// 根据已知路径类型解析实际文件系统路径。
160    pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
161        self.host.resolve_path(path)
162    }
163
164    // ===== 剪贴板服务(委托宿主) =====
165
166    /// 将文本写入系统剪贴板。
167    pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
168        self.host.set_clipboard_text(text)
169    }
170
171    // ===== 应用服务(委托宿主) =====
172
173    /// 枚举当前平台已安装的应用。
174    pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
175        self.host.enumerate_apps().await
176    }
177
178    /// 启动指定应用。
179    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    // ===== 应用资源服务(委托宿主) =====
188
189    /// 根据名称获取内置图标资源的文件系统路径。
190    pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
191        self.host.get_app_icon_path(name)
192    }
193
194    // ===== 快捷方式解析(委托宿主) =====
195
196    /// 解析 .lnk 快捷方式文件的目标路径。
197    pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
198        self.host.resolve_lnk_target(lnk_path)
199    }
200
201    /// 解析指定目录下的 desktop.ini 文件,提取 [LocalizedFileNames] 部分。
202    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    // ===== 主题服务(委托宿主) =====
207
208    /// 查询宿主当前实际生效的界面主题(system 模式由宿主解析)。
209    pub fn get_theme(&self) -> Result<Theme, HostApiError> {
210        self.host.get_theme()
211    }
212
213    /// 查询系统主题(未应用宿主显式 light/dark 配置)。
214    pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
215        self.host.get_system_theme()
216    }
217
218    // ===== 模型服务(委托宿主) =====
219
220    /// 全网模型清单(聚合缓存,含所有已注册提供方)。
221    pub fn model_list(&self) -> Vec<ModelInfo> {
222        self.host.model_list()
223    }
224
225    /// 按 model_id 调用文本生成。
226    pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
227        self.host.model_chat(req).await
228    }
229
230    /// 按 model_id 调用文本向量化(task_type 必填)。
231    pub async fn model_embedding(
232        &self,
233        req: ModelEmbeddingRequest,
234    ) -> Result<ModelEmbeddingResponse, ModelError> {
235        self.host.model_embedding(req).await
236    }
237
238    /// 按 model_id 计算查询向量与多个目标向量的相似度。
239    pub async fn model_similarity(
240        &self,
241        req: ModelSimilarityRequest,
242    ) -> Result<ModelSimilarityResponse, ModelError> {
243        self.host.model_similarity(req).await
244    }
245
246    // ===== 参数解析服务(委托宿主) =====
247
248    /// 解析参数模板。
249    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    /// 统计模板中需要用户输入的参数数量。
261    pub fn count_user_parameters(&self, template: &str) -> usize {
262        self.host.count_user_parameters(template)
263    }
264
265    /// 检查模板是否包含系统参数。
266    pub fn has_system_parameters(&self, template: &str) -> bool {
267        self.host.has_system_parameters(template)
268    }
269
270    // ===== 定时器服务(委托宿主,插件作用域) =====
271
272    /// 创建一个一次性定时器,在指定延迟后触发回调。
273    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    /// 创建一个重复定时器,每隔指定间隔触发回调。
284    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    /// 取消本插件的指定定时器。
295    pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
296        self.host.cancel_timer(&self.plugin_id, id).await
297    }
298
299    /// 取消本插件的全部定时器。
300    pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
301        self.host.cancel_all_timers(&self.plugin_id).await
302    }
303
304    // ===== 资源管理(委托宿主,插件作用域) =====
305
306    /// 上传资源文件到本插件的资源空间。
307    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    /// 直接写入资源字节数据,无需先创建临时文件或提供本地路径。
319    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    /// 获取资源文件内容。
326    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    /// 删除资源文件。
331    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    /// 列出本插件的所有资源。
338    pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
339        self.host.resource_list(&self.plugin_id).await
340    }
341
342    // ===== 本地缓存(委托宿主,插件作用域) =====
343
344    /// 写入插件本地缓存。
345    /// 与 resource_* 的区别:缓存存放可再生的本地数据(如模型向量),
346    /// 不经过 StorageService,仅落在本地缓存目录。
347    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    /// 读取插件本地缓存;缓存不存在时返回 Ok(None)。
359    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    /// 删除插件本地缓存条目;缓存不存在时视为成功。
368    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    /// 容量控制:缓存域条目超过 max_entries 时按修改时间删除最旧条目。
373    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    // ===== 推送式回调注册(委托宿主,ID 由宿主按插件前缀化) =====
384
385    /// 注册按键事件回调。ID 自动前缀化为 "{plugin_id}:{id}"。
386    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    /// 注销按键事件回调。
397    pub fn unregister_hotkey_callback(&self, id: &str) {
398        self.host.unregister_hotkey_callback(&self.plugin_id, id);
399    }
400
401    /// 注册安装事件回调。ID 自动前缀化为 "{plugin_id}:{id}"。
402    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    /// 注销安装事件回调。
408    pub fn unregister_installation_callback(&self, id: &str) {
409        self.host
410            .unregister_installation_callback(&self.plugin_id, id);
411    }
412
413    /// 注册焦点事件回调。ID 自动前缀化为 "{plugin_id}:{id}"。
414    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    /// 注销焦点事件回调。
420    pub fn unregister_focus_callback(&self, id: &str) {
421        self.host.unregister_focus_callback(&self.plugin_id, id);
422    }
423}
424
425/// 构建资源存储路径,校验文件名防止路径遍历攻击。
426/// 使用 PathBuf 确保路径构建的安全性。
427/// 返回 Unix 风格路径(存储后端约定)。
428/// 供宿主实现 PluginHost 资源操作时使用。
429pub 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        // 拒绝空字符串以及 "." / ".." 字面量
439        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        // Path::starts_with 按组件边界匹配。因此 "resources/test" 不会错误地
447        // 作为 "resources/test_evil/..." 的前缀,无需追加尾部分隔符。
448        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
458/// 标准化路径,解析 `.` 和 `..` 组件。
459/// 纯内存操作,不访问文件系统。
460fn 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                // 跳过
469            }
470            other => {
471                result.push(other);
472            }
473        }
474    }
475    result
476}
477
478/// 构建本地缓存文件路径:`<cache_root>/<plugin_id>/<domain>/<key>`。
479/// 校验 domain 与 key(拒绝空串、"."、".." 与路径穿越),策略同 build_resource_path。
480/// 缓存根目录为宿主 app data 下的 plugin-cache/,不经 StorageService。
481/// 供宿主实现 PluginHost 缓存操作时使用。
482pub 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        // 前导 .. pop 空栈,最终只剩下 etc/passwd
533        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        // 插件 "test" 尝试通过 .. 访问插件 "test_evil" 的资源
551        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    // ── starts_with 组件级匹配验证 ────────────────────────────
634
635    #[test]
636    fn starts_with_component_boundary_prevents_false_prefix_match() {
637        // 验证 Path::starts_with 按组件边界匹配:
638        // "resources/test" 不是 "resources/test_evil/..." 的前缀。
639        // 这意味着 build_resource_path 不需要尾部分隔符来防止误匹配。
640        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        // 插件 "test" 尝试写 path = "test_evil/secret.txt",该路径落在
648        // resources/test/test_evil/secret.txt → 应被允许(在自己的空间内)。
649        // 但尝试通过 ../ 逃逸到 test_evil 才被拒绝。
650        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        // 资源名 "test_data.txt" 在插件 "test" 下 → resources/test/test_data.txt
660        // 这不是路径遍历,应被允许。
661        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        // 允许 plugin_id/test/subdir/file.png 这种深层嵌套
670        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        // 验证 PathBuf::push("") 在 Eq 和 starts_with 语义上是空操作。
679        // 这确认了 build_resource_path 不需要它来提高安全性。
680        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        // Eq: 认为相等
685        assert_eq!(with_trailing, without_trailing);
686
687        // starts_with: 行为一致
688        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}