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::theme::Theme;
22use crate::services::timer::types::{TimerCallback, TimerId};
23use crate::services::IconRequest;
24
25/// 插件服务句柄(插件接口层)。
26/// 绑定插件身份(plugin_id)、配置(PluginSdkConfig)与能力集,并持有宿主操作契约
27/// `Arc<dyn PluginHost>`;所有服务操作委托宿主执行,宿主内部再决定直接处理或
28/// 转发平台实现。插件通过 HostApi::register() 获取此句柄。
29pub struct PluginHandle {
30    plugin_id: String,
31    config: RwLock<PluginSdkConfig>,
32    capabilities: PlatformCapabilities,
33    /// 宿主操作契约实现(Arc 共享,所有委托操作的执行节点)。
34    host: Arc<dyn PluginHost>,
35}
36
37impl PluginHandle {
38    /// 绑定插件身份与宿主契约构造句柄。
39    /// 参数:plugin_id - 插件唯一标识;config - 插件 SDK 配置;
40    ///       capabilities - 平台能力集;host - 宿主操作契约实现。
41    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    /// 返回当前插件 ID(宿主模型缓存等按插件隔离目录需要)。
56    pub fn plugin_id(&self) -> &str {
57        &self.plugin_id
58    }
59
60    /// 获取当前图标缓存等级,None 时返回默认值 Full。
61    fn icon_cache_level(&self) -> CacheLevel {
62        self.config.read().icon_cache_level.unwrap_or_default()
63    }
64
65    /// 更新插件的 SDK 配置,立即生效,影响后续所有服务调用。
66    pub fn update_config(&self, config: PluginSdkConfig) {
67        *self.config.write() = config;
68    }
69
70    /// 查询当前平台支持的能力集合。
71    pub fn capabilities(&self) -> &PlatformCapabilities {
72        &self.capabilities
73    }
74
75    // ===== 图标服务(委托宿主) =====
76
77    /// 根据图标请求提取图标数据,行为由注册时的缓存等级决定。
78    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    /// 提取图标数据,失败时回退到默认图标(永不返回错误)。
84    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    /// 强制从磁盘提取图标数据并根据缓存等级更新缓存。
90    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    /// 覆盖指定 IconRequest 的缓存图标为自定义图标文件。
99    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    // ===== Shell 服务(委托宿主) =====
110
111    /// 使用系统默认方式打开目标(文件/网址/文件夹)。
112    pub async fn shell_open(&self, target: OpenTarget) -> Result<(), HostApiError> {
113        self.host.shell_open(target).await
114    }
115
116    /// 在文件资源管理器中打开指定路径的父目录并选中该文件。
117    pub async fn shell_open_folder(&self, path: &str) -> Result<(), HostApiError> {
118        self.host.shell_open_folder(path).await
119    }
120
121    /// 以管理员权限启动程序。
122    pub async fn shell_execute_elevation(&self, path: &str) -> Result<(), HostApiError> {
123        self.host.shell_execute_elevation(path).await
124    }
125
126    /// 执行命令字符串(后台运行,无窗口)。
127    pub async fn shell_execute_command(&self, command: &str) -> Result<(), HostApiError> {
128        self.host.shell_execute_command(command).await
129    }
130
131    // ===== 窗口服务(委托宿主) =====
132
133    /// 根据进程名(如 "chrome.exe")激活已存在的窗口。
134    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    /// 根据窗口标题的部分内容激活已存在的窗口。
142    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    /// 根据进程 PID 激活已存在的窗口。
147    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    // ===== 路径服务(委托宿主) =====
152
153    /// 根据已知路径类型解析实际文件系统路径。
154    pub fn resolve_path(&self, path: KnownPath) -> Result<String, HostApiError> {
155        self.host.resolve_path(path)
156    }
157
158    // ===== 剪贴板服务(委托宿主) =====
159
160    /// 将文本写入系统剪贴板。
161    pub fn set_clipboard_text(&self, text: &str) -> Result<(), HostApiError> {
162        self.host.set_clipboard_text(text)
163    }
164
165    // ===== 应用服务(委托宿主) =====
166
167    /// 枚举当前平台已安装的应用。
168    pub async fn enumerate_apps(&self) -> Vec<AppInfo> {
169        self.host.enumerate_apps().await
170    }
171
172    /// 启动指定应用。
173    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    // ===== 应用资源服务(委托宿主) =====
182
183    /// 根据名称获取内置图标资源的文件系统路径。
184    pub fn get_app_icon_path(&self, name: &str) -> Option<String> {
185        self.host.get_app_icon_path(name)
186    }
187
188    // ===== 快捷方式解析(委托宿主) =====
189
190    /// 解析 .lnk 快捷方式文件的目标路径。
191    pub fn resolve_lnk_target(&self, lnk_path: &str) -> Option<String> {
192        self.host.resolve_lnk_target(lnk_path)
193    }
194
195    /// 解析指定目录下的 desktop.ini 文件,提取 [LocalizedFileNames] 部分。
196    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    // ===== 主题服务(委托宿主) =====
201
202    /// 查询宿主当前实际生效的界面主题(system 模式由宿主解析)。
203    pub fn get_theme(&self) -> Result<Theme, HostApiError> {
204        self.host.get_theme()
205    }
206
207    /// 查询系统主题(未应用宿主显式 light/dark 配置)。
208    pub fn get_system_theme(&self) -> Result<Theme, HostApiError> {
209        self.host.get_system_theme()
210    }
211
212    // ===== 模型服务(委托宿主) =====
213
214    /// 全网模型清单(聚合缓存,含所有已注册提供方)。
215    pub fn model_list(&self) -> Vec<ModelInfo> {
216        self.host.model_list()
217    }
218
219    /// 按 model_id 调用文本生成。
220    pub async fn model_chat(&self, req: ModelChatRequest) -> Result<ModelChatResponse, ModelError> {
221        self.host.model_chat(req).await
222    }
223
224    /// 按 model_id 调用文本向量化(task_type 必填)。
225    pub async fn model_embedding(
226        &self,
227        req: ModelEmbeddingRequest,
228    ) -> Result<ModelEmbeddingResponse, ModelError> {
229        self.host.model_embedding(req).await
230    }
231
232    /// 按 model_id 计算查询向量与多个目标向量的相似度。
233    pub async fn model_similarity(
234        &self,
235        req: ModelSimilarityRequest,
236    ) -> Result<ModelSimilarityResponse, ModelError> {
237        self.host.model_similarity(req).await
238    }
239
240    // ===== 参数解析服务(委托宿主) =====
241
242    /// 解析参数模板。
243    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    /// 统计模板中需要用户输入的参数数量。
255    pub fn count_user_parameters(&self, template: &str) -> usize {
256        self.host.count_user_parameters(template)
257    }
258
259    /// 检查模板是否包含系统参数。
260    pub fn has_system_parameters(&self, template: &str) -> bool {
261        self.host.has_system_parameters(template)
262    }
263
264    // ===== 定时器服务(委托宿主) =====
265
266    /// 创建一个一次性定时器,在指定延迟后触发回调。
267    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    /// 创建一个重复定时器,每隔指定间隔触发回调。
276    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    /// 取消指定 ID 的定时器。
285    pub async fn cancel_timer(&self, id: TimerId) -> Result<(), HostApiError> {
286        self.host.cancel_timer(id).await
287    }
288
289    /// 取消所有定时器。
290    pub async fn cancel_all_timers(&self) -> Result<(), HostApiError> {
291        self.host.cancel_all_timers().await
292    }
293
294    // ===== 资源管理(委托宿主,插件作用域) =====
295
296    /// 上传资源文件到本插件的资源空间。
297    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    /// 直接写入资源字节数据,无需先创建临时文件或提供本地路径。
309    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    /// 获取资源文件内容。
316    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    /// 删除资源文件。
321    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    /// 列出本插件的所有资源。
328    pub async fn resource_list(&self) -> Result<Vec<String>, HostApiError> {
329        self.host.resource_list(&self.plugin_id).await
330    }
331
332    // ===== 本地缓存(委托宿主,插件作用域) =====
333
334    /// 写入插件本地缓存。
335    /// 与 resource_* 的区别:缓存存放可再生的本地数据(如模型向量),
336    /// 不经过 StorageService,仅落在本地缓存目录。
337    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    /// 读取插件本地缓存;缓存不存在时返回 Ok(None)。
349    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    /// 删除插件本地缓存条目;缓存不存在时视为成功。
358    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    /// 容量控制:缓存域条目超过 max_entries 时按修改时间删除最旧条目。
363    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    // ===== 推送式回调注册(委托宿主,ID 由宿主按插件前缀化) =====
374
375    /// 注册按键事件回调。ID 自动前缀化为 "{plugin_id}:{id}"。
376    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    /// 注销按键事件回调。
387    pub fn unregister_hotkey_callback(&self, id: &str) {
388        self.host.unregister_hotkey_callback(&self.plugin_id, id);
389    }
390
391    /// 注册安装事件回调。ID 自动前缀化为 "{plugin_id}:{id}"。
392    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    /// 注销安装事件回调。
398    pub fn unregister_installation_callback(&self, id: &str) {
399        self.host
400            .unregister_installation_callback(&self.plugin_id, id);
401    }
402
403    /// 注册焦点事件回调。ID 自动前缀化为 "{plugin_id}:{id}"。
404    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    /// 注销焦点事件回调。
410    pub fn unregister_focus_callback(&self, id: &str) {
411        self.host.unregister_focus_callback(&self.plugin_id, id);
412    }
413}
414
415/// 构建资源存储路径,校验文件名防止路径遍历攻击。
416/// 使用 PathBuf 确保路径构建的安全性。
417/// 返回 Unix 风格路径(存储后端约定)。
418/// 供宿主实现 PluginHost 资源操作时使用。
419pub 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        // 拒绝空字符串以及 "." / ".." 字面量
429        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        // Path::starts_with 按组件边界匹配。因此 "resources/test" 不会错误地
437        // 作为 "resources/test_evil/..." 的前缀,无需追加尾部分隔符。
438        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
448/// 标准化路径,解析 `.` 和 `..` 组件。
449/// 纯内存操作,不访问文件系统。
450fn 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                // 跳过
459            }
460            other => {
461                result.push(other);
462            }
463        }
464    }
465    result
466}
467
468/// 构建本地缓存文件路径:`<cache_root>/<plugin_id>/<domain>/<key>`。
469/// 校验 domain 与 key(拒绝空串、"."、".." 与路径穿越),策略同 build_resource_path。
470/// 缓存根目录为宿主 app data 下的 plugin-cache/,不经 StorageService。
471/// 供宿主实现 PluginHost 缓存操作时使用。
472pub 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        // 前导 .. pop 空栈,最终只剩下 etc/passwd
523        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        // 插件 "test" 尝试通过 .. 访问插件 "test_evil" 的资源
541        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    // ── starts_with 组件级匹配验证 ────────────────────────────
624
625    #[test]
626    fn starts_with_component_boundary_prevents_false_prefix_match() {
627        // 验证 Path::starts_with 按组件边界匹配:
628        // "resources/test" 不是 "resources/test_evil/..." 的前缀。
629        // 这意味着 build_resource_path 不需要尾部分隔符来防止误匹配。
630        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        // 插件 "test" 尝试写 path = "test_evil/secret.txt",该路径落在
638        // resources/test/test_evil/secret.txt → 应被允许(在自己的空间内)。
639        // 但尝试通过 ../ 逃逸到 test_evil 才被拒绝。
640        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        // 资源名 "test_data.txt" 在插件 "test" 下 → resources/test/test_data.txt
650        // 这不是路径遍历,应被允许。
651        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        // 允许 plugin_id/test/subdir/file.png 这种深层嵌套
660        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        // 验证 PathBuf::push("") 在 Eq 和 starts_with 语义上是空操作。
669        // 这确认了 build_resource_path 不需要它来提高安全性。
670        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        // Eq: 认为相等
675        assert_eq!(with_trailing, without_trailing);
676
677        // starts_with: 行为一致
678        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}