Skip to main content

msime_engine/
paths.rs

1//! The four runtime roots a session reads and writes. Paths are captured at construction, so changing the process environment cannot redirect a live session. The working dictionaries under `dictionaries` are durable user data, never a disposable cache.
2//!
3//! `prepare_runtime_paths`, which stages a generation directory, lives in `user_dictionary::generation` because it replays the journal.
4
5use std::fs::{File, OpenOptions};
6use std::io;
7use std::path::{Component, Path, PathBuf};
8
9use crate::assets;
10use crate::diagnostics;
11use crate::error::{EngineError, Result};
12
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
14pub struct RuntimePaths {
15    /// The immutable packaged bundle.
16    pub resources: PathBuf,
17    /// The journal, learned glosses and hand-written translations.
18    pub user_data: PathBuf,
19    pub cache: PathBuf,
20    /// The generation directory holding the mutable working copies (`user_data/dictionaries/<content id>`).
21    pub dictionaries: PathBuf,
22}
23
24impl RuntimePaths {
25    /// 资源包里的文件。名字是 `assets` 里的常量,或已被 `helpcode::custom_schema_stem` 化成单个普通路径成分的自定义辅助码名。现名文件缺席、`assets::LEGACY_NAMES` 里的旧名文件在场时返回旧名路径。
26    pub fn resource(&self, name: &str) -> PathBuf {
27        join_current_or_legacy(&self.resources, name)
28    }
29
30    pub fn user(&self, name: &str) -> PathBuf {
31        join(&self.user_data, name)
32    }
33
34    /// 与 `resource` 一样,现名缺席而旧名文件在场时返回旧名。
35    pub fn dictionary(&self, name: &str) -> PathBuf {
36        join_current_or_legacy(&self.dictionaries, name)
37    }
38
39    /// Every root must be absolute; a relative root would resolve against whatever the host's working directory happens to be.
40    pub fn validate(&self) -> Result<()> {
41        for root in [
42            &self.resources,
43            &self.user_data,
44            &self.cache,
45            &self.dictionaries,
46        ] {
47            // wasm32-unknown-unknown 上 std 的 `is_absolute` 恒为 false(它还要求路径前缀),所以那里以有根路径为绝对路径。
48            #[cfg(all(target_family = "wasm", target_os = "unknown"))]
49            let absolute = root.has_root();
50            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
51            let absolute = root.is_absolute();
52            if !absolute {
53                return Err(EngineError::invalid(
54                    diagnostics::RUNTIME_DIRECTORIES_MUST_BE_ABSOLUTE,
55                ));
56            }
57        }
58        Ok(())
59    }
60}
61
62/// An empty root, an empty name or an absolute name yields an empty path, which every reader treats as a missing file, as the C++ did.
63fn join(root: &Path, name: &str) -> PathBuf {
64    if root.as_os_str().is_empty() || name.is_empty() || Path::new(name).is_absolute() {
65        return PathBuf::new();
66    }
67    root.join(name)
68}
69
70/// `join`,但现名不存在而 `assets::LEGACY_NAMES` 中对应的旧名存在时返回旧名路径。两者都不存在时返回现名,读者报告的仍是缺少现名文件。
71fn join_current_or_legacy(root: &Path, name: &str) -> PathBuf {
72    let current = join(root, name);
73    if current.as_os_str().is_empty() || std::fs::symlink_metadata(&current).is_ok() {
74        return current;
75    }
76    assets::LEGACY_NAMES
77        .iter()
78        .find(|(present, _)| *present == name)
79        .map(|(_, legacy)| root.join(legacy))
80        .filter(|legacy| std::fs::symlink_metadata(legacy).is_ok())
81        .unwrap_or(current)
82}
83
84/// 存储路径可以经过的系统链接只在 `msime-path-trust` 里列一次。
85pub(crate) use msime_path_trust::is_trusted_system_alias;
86
87/// Open an Engine asset without following a leaf symlink. Callers still
88/// validate the file format and size through their own loaders; this closes
89/// the check-then-open race between those checks and the read or mapping.
90///
91/// 只读打开,所以多链接文件在所在目录只有 root 或当前用户能写时也放行(`msime_path_trust::multi_link_is_trusted`):Nix 的 store 去重、ostree 部署会把安装目录里的资源合并成硬链接(#6386)。
92pub(crate) fn open_file_no_follow(path: &Path) -> io::Result<File> {
93    let mut options = OpenOptions::new();
94    options.read(true);
95    #[cfg(unix)]
96    {
97        use std::os::unix::fs::OpenOptionsExt;
98        // A replaced asset may be a FIFO; opening it on the engine thread must not block.
99        options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
100    }
101    #[cfg(windows)]
102    {
103        use std::os::windows::fs::OpenOptionsExt;
104        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
105        options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
106    }
107    let file = options.open(path)?;
108    let metadata = file.metadata()?;
109    if !metadata.is_file()
110        || !(has_single_link(&file)? || msime_path_trust::multi_link_is_trusted(&file, path)?)
111    {
112        return Err(io::Error::new(
113            io::ErrorKind::InvalidInput,
114            "engine asset is not a regular file with a trusted link count",
115        ));
116    }
117    Ok(file)
118}
119
120/// 已打开的文件是否只有一个硬链接。engine 里拒绝硬链接的地方都用这一份:Windows 上标准库的 `MetadataExt::number_of_links` 还是不稳定特性(`windows_by_handle`),各处自己写就会在 Windows 上编译失败;这里经 `winapi-util` 读硬链接数,与 `msime_client_core::file_lock::has_single_link` 相同(engine 不依赖 client-core,用不了那一份)。
121#[cfg(unix)]
122pub(crate) fn has_single_link(file: &File) -> io::Result<bool> {
123    use std::os::unix::fs::MetadataExt;
124    Ok(file.metadata()?.nlink() == 1)
125}
126
127/// 已打开的文件是否只有一个硬链接;见 Unix 版的说明。
128#[cfg(windows)]
129pub(crate) fn has_single_link(file: &File) -> io::Result<bool> {
130    Ok(winapi_util::file::information(file)?.number_of_links() == 1)
131}
132
133/// 没有硬链接计数可读的平台一律按单链接处理。
134#[cfg(not(any(unix, windows)))]
135pub(crate) fn has_single_link(_: &File) -> io::Result<bool> {
136    Ok(true)
137}
138
139/// 路径上的数据库文件是否只有一个硬链接。Unix 直接读 `metadata` 的 `nlink`;Windows 的硬链接数只能经句柄读,所以先不跟随重解析点地打开它。
140#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
141fn path_has_single_link(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
142    #[cfg(unix)]
143    {
144        use std::os::unix::fs::MetadataExt;
145        let _ = path;
146        Ok(metadata.nlink() == 1)
147    }
148    #[cfg(windows)]
149    {
150        use std::os::windows::fs::OpenOptionsExt;
151        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
152        let _ = metadata;
153        let file = OpenOptions::new()
154            .read(true)
155            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
156            .open(path)?;
157        has_single_link(&file)
158    }
159    #[cfg(not(any(unix, windows)))]
160    {
161        let _ = (path, metadata);
162        Ok(true)
163    }
164}
165
166/// SQLite's `SQLITE_OPEN_NOFOLLOW` rejects trusted system aliases such as
167/// macOS `/var` when they remain in the path. Reject untrusted components first,
168/// then canonicalize only the parent so the SQLite flag protects the leaf while
169/// normal platform storage roots continue to work.
170pub(crate) fn sqlite_path_no_follow(path: &Path) -> io::Result<PathBuf> {
171    sqlite_path_no_follow_with_parent_policy(path, true, false)
172}
173
174/// [`sqlite_path_no_follow`] for a database opened with `SQLITE_OPEN_READ_ONLY`. A read never changes the other end of a hard link, so a multi-link file is accepted where its directory only root or the current user can write (`msime_path_trust::multi_link_path_is_trusted`): Nix store dedup and ostree deployments hard-link bundled dictionaries (#6386). A database that is written keeps requiring a single link.
175pub(crate) fn sqlite_read_only_path_no_follow(path: &Path) -> io::Result<PathBuf> {
176    sqlite_path_no_follow_with_parent_policy(path, true, true)
177}
178
179/// Resolve a database's parent directory while protecting only the final path
180/// component with SQLite's `SQLITE_OPEN_NOFOLLOW` flag. This is used by
181/// packaged language dictionaries, whose established contract permits an
182/// application supplied directory alias. They are opened read-only, so the
183/// link count follows [`sqlite_read_only_path_no_follow`].
184pub(crate) fn sqlite_path_no_follow_allow_parent_symlinks(path: &Path) -> io::Result<PathBuf> {
185    sqlite_path_no_follow_with_parent_policy(path, false, true)
186}
187
188fn sqlite_path_no_follow_with_parent_policy(
189    path: &Path,
190    reject_parent_symlinks: bool,
191    read_only: bool,
192) -> io::Result<PathBuf> {
193    #[cfg(all(target_family = "wasm", target_os = "unknown"))]
194    {
195        let _ = (reject_parent_symlinks, read_only);
196        Ok(path.to_owned())
197    }
198    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
199    {
200        if reject_parent_symlinks {
201            msime_path_trust::reject_symlinked_components(path)?;
202        }
203        let parent = path.parent().ok_or_else(|| {
204            io::Error::new(io::ErrorKind::InvalidInput, "database path has no parent")
205        })?;
206        let name = path.file_name().ok_or_else(|| {
207            io::Error::new(io::ErrorKind::InvalidInput, "database path has no filename")
208        })?;
209        let resolved = std::fs::canonicalize(parent)?.join(name);
210        if let Ok(metadata) = std::fs::symlink_metadata(&resolved) {
211            if metadata.file_type().is_symlink()
212                || !(path_has_single_link(&resolved, &metadata)?
213                    || read_only && msime_path_trust::multi_link_path_is_trusted(&resolved)?)
214            {
215                return Err(io::Error::new(
216                    io::ErrorKind::InvalidInput,
217                    "database is not a regular file with a trusted link count",
218                ));
219            }
220        }
221        Ok(resolved)
222    }
223}
224
225/// `join` for a name that came from outside the crate: a `..` component is refused rather than allowed to escape the root (`runtime_paths.cpp:14-23`).
226#[cfg_attr(
227    not(test),
228    expect(
229        dead_code,
230        reason = "no caller joins a user-derived name: the custom helpcode schema is guarded by custom_schema_stem, which refuses separators and a leading dot, matching helpcode_utils.cpp:37-40"
231    )
232)]
233pub fn join_checked(root: &Path, name: &str) -> Result<PathBuf> {
234    if Path::new(name)
235        .components()
236        .any(|part| matches!(part, Component::ParentDir))
237    {
238        return Err(EngineError::invalid(
239            diagnostics::RUNTIME_ASSET_PATH_ESCAPES,
240        ));
241    }
242    Ok(join(root, name))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[cfg(unix)]
250    #[test]
251    fn opening_an_asset_does_not_follow_a_leaf_symlink() {
252        use std::os::unix::fs::symlink;
253
254        let root = tempfile::tempdir().unwrap();
255        let outside = tempfile::tempdir().unwrap();
256        let target = outside.path().join("asset.bin");
257        std::fs::write(&target, b"synthetic engine asset").unwrap();
258        let linked = root.path().join("asset.bin");
259        symlink(&target, &linked).unwrap();
260
261        assert!(open_file_no_follow(&linked).is_err());
262        assert_eq!(std::fs::read(&target).unwrap(), b"synthetic engine asset");
263    }
264
265    #[cfg(unix)]
266    #[test]
267    fn opening_an_asset_rejects_a_directory() {
268        let root = tempfile::tempdir().unwrap();
269
270        assert!(open_file_no_follow(root.path()).is_err());
271    }
272
273    /// 让别的用户也能写这个目录:多链接文件在这样的目录里不受信任,可能是别人放进来的。
274    #[cfg(unix)]
275    fn open_to_others(directory: &Path) {
276        use std::os::unix::fs::PermissionsExt;
277        std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o777)).unwrap();
278    }
279
280    /// Windows 上多链接文件一律拒绝,无须改目录权限。
281    #[cfg(windows)]
282    fn open_to_others(_: &Path) {}
283
284    /// 只有属主能写这个目录。临时目录的权限取决于 umask,umask 为 002 时它是组可写的。
285    #[cfg(unix)]
286    fn close_to_others(directory: &Path) {
287        use std::os::unix::fs::PermissionsExt;
288        std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap();
289    }
290
291    #[cfg(any(unix, windows))]
292    #[test]
293    fn opening_an_asset_rejects_a_hard_link() {
294        let root = tempfile::tempdir().unwrap();
295        let outside = tempfile::tempdir().unwrap();
296        let target = outside.path().join("asset.bin");
297        std::fs::write(&target, b"synthetic engine asset").unwrap();
298        let linked = root.path().join("asset.bin");
299        std::fs::hard_link(&target, &linked).unwrap();
300        open_to_others(root.path());
301
302        assert!(open_file_no_follow(&linked).is_err());
303        assert_eq!(std::fs::read(&target).unwrap(), b"synthetic engine asset");
304    }
305
306    /// Nix 的 store 去重把安装目录里内容相同的文件合并成硬链接(#6386);目录只有属主能写时照常读。
307    #[cfg(unix)]
308    #[test]
309    fn opening_an_asset_accepts_a_hard_link_in_a_closed_directory() {
310        let root = tempfile::tempdir().unwrap();
311        close_to_others(root.path());
312        let original = root.path().join("handwriting.model");
313        std::fs::write(&original, b"synthetic engine asset").unwrap();
314        let deduplicated = root.path().join("other-package.model");
315        std::fs::hard_link(&original, &deduplicated).unwrap();
316
317        let mut file = open_file_no_follow(&original).unwrap();
318        let mut contents = Vec::new();
319        std::io::Read::read_to_end(&mut file, &mut contents).unwrap();
320        assert_eq!(contents, b"synthetic engine asset");
321        assert!(sqlite_read_only_path_no_follow(&deduplicated).is_ok());
322        // 会写入的数据库仍然只接受单链接:写它会改到链接另一端的文件。
323        assert!(sqlite_path_no_follow(&deduplicated).is_err());
324    }
325
326    #[test]
327    fn joins_like_the_reference() {
328        let paths = RuntimePaths {
329            resources: PathBuf::from("/r"),
330            user_data: PathBuf::from("/u"),
331            cache: PathBuf::from("/c"),
332            dictionaries: PathBuf::new(),
333        };
334        assert_eq!(
335            paths.resource("msime-pinyin.db"),
336            PathBuf::from("/r/msime-pinyin.db")
337        );
338        assert_eq!(paths.dictionary("msime-pinyin.db"), PathBuf::new());
339        assert_eq!(paths.user("/abs"), PathBuf::new());
340        assert!(join_checked(Path::new("/r"), "helpcodes/../x").is_err());
341        assert!(paths.validate().is_err());
342    }
343
344    /// 升级后还没换新的目录里只有旧名文件:现名缺席时读旧名,两者都在时只读现名。
345    #[test]
346    fn a_missing_current_name_falls_back_to_the_legacy_file() {
347        let root = tempfile::tempdir().unwrap();
348        let paths = RuntimePaths {
349            resources: root.path().join("resources"),
350            user_data: root.path().join("user"),
351            cache: root.path().join("cache"),
352            dictionaries: root.path().join("generation"),
353        };
354        std::fs::create_dir_all(&paths.resources).unwrap();
355        std::fs::create_dir_all(&paths.dictionaries).unwrap();
356        assert_eq!(
357            paths.dictionary(assets::MAIN_DICTIONARY),
358            paths.dictionaries.join("msime-pinyin.db")
359        );
360        std::fs::write(paths.dictionaries.join("msime.db"), b"old").unwrap();
361        std::fs::write(paths.resources.join("others.db"), b"old").unwrap();
362        assert_eq!(
363            paths.dictionary(assets::MAIN_DICTIONARY),
364            paths.dictionaries.join("msime.db")
365        );
366        assert_eq!(
367            paths.resource(assets::OTHER_DICTIONARY),
368            paths.resources.join("others.db")
369        );
370        // 没有旧名的文件照常拼接。
371        assert_eq!(
372            paths.resource(assets::WUBI_DICTIONARY),
373            paths.resources.join("msime-wubi.db")
374        );
375        std::fs::write(paths.dictionaries.join("msime-pinyin.db"), b"new").unwrap();
376        assert_eq!(
377            paths.dictionary(assets::MAIN_DICTIONARY),
378            paths.dictionaries.join("msime-pinyin.db")
379        );
380    }
381
382    #[cfg(any(unix, windows))]
383    #[test]
384    fn sqlite_paths_reject_hard_linked_databases() {
385        let root = tempfile::tempdir().unwrap();
386        let outside = tempfile::tempdir().unwrap();
387        let target = root.path().join("database.db");
388        let external = outside.path().join("database.db");
389        std::fs::write(&external, b"synthetic database").unwrap();
390        std::fs::hard_link(&external, &target).unwrap();
391
392        assert!(sqlite_path_no_follow(&target).is_err());
393        open_to_others(root.path());
394        assert!(sqlite_read_only_path_no_follow(&target).is_err());
395        assert!(sqlite_path_no_follow_allow_parent_symlinks(&target).is_err());
396        assert_eq!(std::fs::read(&external).unwrap(), b"synthetic database");
397    }
398}