Skip to main content

msime_engine/
access.rs

1//! 跨进程的词库访问租约:已准备的代次目录(`RuntimePaths::dictionaries`)和用户数据目录(`user_data`,日志在这里)各放一个锁文件 [`LOCK_FILE`]。用这些数据的会话持共享租约,改个人词库、重置学习这类维护持独占租约,两者互斥,只试不等。
2//!
3//! 锁文件是稳定的协调对象,各版本、各进程都按同一个名字和同一套规则来取,不能改名,也不能删掉。规则与搬进引擎之前 `msime-client-services` 的 `dictionary::access` 相同:两个根目录先规范化、排序、去重,按顺序逐个打开并取锁,任何一个取不到就放掉已经取到的,返回 `None`;Unix 上目录和锁文件都经目录句柄打开、不跟随符号链接,结束时显式解锁,复制出去的描述符不能把租约延长到守卫之后。
4//!
5//! 引擎层的维护接口([`crate::edit_personal_dictionary`]、[`crate::edit_bundled_dictionary_entry`]、[`crate::reset_learned_data`])自己取独占租约;`host` 门面的同名接口假定调用方(host-api)已经拿着租约,不再取。
6
7use std::ffi::OsStr;
8use std::fs::File;
9use std::io;
10use std::path::{Path, PathBuf};
11
12use crate::diagnostics;
13use crate::error::{EngineError, Result};
14use crate::paths::RuntimePaths;
15
16/// 租约锁文件的名字,放在每个根目录里。
17pub const LOCK_FILE: &str = ".msime-dictionary-access.lock";
18
19/// 一份词库访问租约,持有期间别的进程(和本进程里的其他租约)按 [`DictionaryAccess::try_session`]、[`DictionaryAccess::try_maintenance`] 的规则互斥。
20pub struct DictionaryAccess {
21    files: Vec<File>,
22    roots: Vec<Root>,
23}
24
25/// 租约里的一个根目录:规范化后的路径,Unix 上还有取锁时打开的目录句柄。
26struct Root {
27    path: PathBuf,
28    #[cfg(unix)]
29    directory: File,
30}
31
32#[cfg(unix)]
33impl Drop for DictionaryAccess {
34    fn drop(&mut self) {
35        // 继承或复制的描述符不能把租约延长到守卫结束之后。
36        for file in &self.files {
37            let _ = unlock(file);
38        }
39    }
40}
41
42impl DictionaryAccess {
43    /// 不等待地取共享租约(会话用)。`None` 表示正在维护。
44    pub fn try_session(user: &Path, dictionaries: &Path) -> io::Result<Option<Self>> {
45        Self::acquire(user, dictionaries, false)
46    }
47
48    /// 不等待地取独占的维护租约。`None` 表示还有会话或别的维护持着租约。
49    pub fn try_maintenance(user: &Path, dictionaries: &Path) -> io::Result<Option<Self>> {
50        Self::acquire(user, dictionaries, true)
51    }
52
53    /// 取租约时打开的那个根目录的句柄(复制一份)。之后这个路径被换成别的目录,句柄仍指向取锁时的那个。`path` 不是租约的根目录时报 `NotFound`。
54    #[cfg(unix)]
55    pub fn directory(&self, path: &Path) -> io::Result<File> {
56        self.root(path)?.directory.try_clone()
57    }
58
59    /// 非 Unix 平台没有目录句柄,返回租约里规范化后的根目录路径。`path` 不是租约的根目录时报 `NotFound`。
60    #[cfg(not(unix))]
61    pub fn directory(&self, path: &Path) -> io::Result<PathBuf> {
62        Ok(self.root(path)?.path.clone())
63    }
64
65    fn root(&self, path: &Path) -> io::Result<&Root> {
66        let canonical = path.canonicalize()?;
67        self.roots
68            .iter()
69            .find(|root| root.path == canonical)
70            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "directory is not leased"))
71    }
72
73    fn acquire(user: &Path, dictionaries: &Path, exclusive: bool) -> io::Result<Option<Self>> {
74        if !user.is_absolute() || !dictionaries.is_absolute() {
75            return Err(io::Error::new(
76                io::ErrorKind::InvalidInput,
77                "absolute dictionary paths required",
78            ));
79        }
80        msime_path_trust::reject_symlinked_components(user)?;
81        msime_path_trust::reject_symlinked_components(dictionaries)?;
82        let mut roots = vec![user.canonicalize()?, dictionaries.canonicalize()?];
83        roots.sort();
84        roots.dedup();
85        let mut access = Self {
86            files: Vec::with_capacity(roots.len()),
87            roots: Vec::with_capacity(roots.len()),
88        };
89        for path in roots {
90            let directory = open_root(&path)?;
91            let file = open_lock_file(&directory, OsStr::new(LOCK_FILE))?;
92            let acquired = if exclusive {
93                try_exclusive(&file)?
94            } else {
95                try_shared(&file)?
96            };
97            if !acquired {
98                // 已经取到的前几把锁随 `access` 一起放掉。
99                return Ok(None);
100            }
101            access.files.push(file);
102            access.roots.push(Root {
103                path,
104                #[cfg(unix)]
105                directory,
106            });
107        }
108        Ok(Some(access))
109    }
110}
111
112/// 引擎层维护接口取独占租约:取不到是 `Busy`(`DICTIONARY_MAINTENANCE_BUSY`),打不开目录或锁文件是存储错误。
113pub(crate) fn maintenance(paths: &RuntimePaths) -> Result<DictionaryAccess> {
114    DictionaryAccess::try_maintenance(&paths.user_data, &paths.dictionaries)?
115        .ok_or_else(|| EngineError::busy(diagnostics::DICTIONARY_MAINTENANCE_BUSY))
116}
117
118/// 不跟随符号链接地打开根目录,规则与 `msime-client-base` 的 `storage::open_private_directory` 相同。Apple 平台不逐级打开:iOS 沙盒不让应用以读方式打开容器的祖先目录,`O_NOFOLLOW_ANY` 一次解析就拒绝路径上任何一级符号链接;路径经过 `msime-path-trust` 列出的系统别名(`/var`、`/tmp`)时先换成它唯一受信任的目标。
119#[cfg(target_vendor = "apple")]
120fn open_root(path: &Path) -> io::Result<File> {
121    let flags = rustix::fs::OFlags::RDONLY
122        | rustix::fs::OFlags::DIRECTORY
123        | rustix::fs::OFlags::NOFOLLOW_ANY
124        | rustix::fs::OFlags::CLOEXEC
125        | rustix::fs::OFlags::NONBLOCK;
126    match rustix::fs::open(path, flags, rustix::fs::Mode::empty()) {
127        Err(rustix::io::Errno::LOOP) => {}
128        result => return Ok(result?.into()),
129    }
130    let mut resolved = PathBuf::new();
131    for component in path.components() {
132        resolved.push(component);
133        if msime_path_trust::is_trusted_system_alias(&resolved) {
134            let target = std::fs::read_link(&resolved)?;
135            resolved = resolved
136                .parent()
137                .unwrap_or_else(|| Path::new("/"))
138                .join(target);
139        }
140    }
141    if resolved == path {
142        return Err(rustix::io::Errno::LOOP.into());
143    }
144    Ok(rustix::fs::open(&resolved, flags, rustix::fs::Mode::empty())?.into())
145}
146
147/// 其余 Unix 逐级经目录句柄打开,每一级都不跟随符号链接(系统别名除外)。Linux 和 Android 用 `O_PATH` 只凭搜索权限走过祖先目录(Android 不给应用 `/data` 的读权限),走完再按读方式打开根目录本身。
148#[cfg(all(unix, not(target_vendor = "apple")))]
149fn open_root(path: &Path) -> io::Result<File> {
150    let flags = rustix::fs::OFlags::RDONLY
151        | rustix::fs::OFlags::DIRECTORY
152        | rustix::fs::OFlags::NOFOLLOW
153        | rustix::fs::OFlags::CLOEXEC
154        | rustix::fs::OFlags::NONBLOCK;
155    #[cfg(any(target_os = "linux", target_os = "android"))]
156    let search = rustix::fs::OFlags::PATH
157        | rustix::fs::OFlags::DIRECTORY
158        | rustix::fs::OFlags::NOFOLLOW
159        | rustix::fs::OFlags::CLOEXEC;
160    #[cfg(not(any(target_os = "linux", target_os = "android")))]
161    let search = flags;
162    // 根目录在 `acquire` 里已经确认是绝对路径。
163    let mut directory = rustix::fs::open(Path::new("/"), search, rustix::fs::Mode::empty())?;
164    let mut logical = PathBuf::from("/");
165    for component in path.components() {
166        match component {
167            std::path::Component::Prefix(_) => {
168                return Err(io::Error::new(
169                    io::ErrorKind::InvalidInput,
170                    "directory path has an unsupported prefix",
171                ));
172            }
173            std::path::Component::RootDir | std::path::Component::CurDir => {}
174            std::path::Component::ParentDir => {
175                directory =
176                    rustix::fs::openat(&directory, "..", search, rustix::fs::Mode::empty())?;
177                logical.pop();
178            }
179            std::path::Component::Normal(name) => {
180                logical.push(name);
181                directory =
182                    match rustix::fs::openat(&directory, name, search, rustix::fs::Mode::empty()) {
183                        Ok(directory) => directory,
184                        Err(error)
185                            if (error == rustix::io::Errno::LOOP
186                                || error == rustix::io::Errno::NOTDIR)
187                                && msime_path_trust::is_trusted_system_alias(&logical) =>
188                        {
189                            rustix::fs::openat(
190                                &directory,
191                                name,
192                                search & !rustix::fs::OFlags::NOFOLLOW,
193                                rustix::fs::Mode::empty(),
194                            )?
195                        }
196                        Err(error) => return Err(error.into()),
197                    };
198            }
199        }
200    }
201    Ok(rustix::fs::openat(&directory, ".", flags, rustix::fs::Mode::empty())?.into())
202}
203
204/// 非 Unix 平台没有 openat 这组调用:只确认根目录是真实目录(不是符号链接),锁文件再按路径打开。
205#[cfg(not(unix))]
206fn open_root(path: &Path) -> io::Result<PathBuf> {
207    if !std::fs::symlink_metadata(path)?.is_dir() {
208        return Err(io::Error::new(
209            io::ErrorKind::InvalidInput,
210            "private directory is not a real directory",
211        ));
212    }
213    Ok(path.to_path_buf())
214}
215
216/// 在根目录里打开(没有就建)锁文件:Unix 经目录句柄、不跟随符号链接、权限 0600;打开后要求是只有一个硬链接的普通文件。
217#[cfg(unix)]
218fn open_lock_file(directory: &File, name: &OsStr) -> io::Result<File> {
219    let open = || {
220        rustix::fs::openat(
221            directory,
222            name,
223            rustix::fs::OFlags::RDWR
224                | rustix::fs::OFlags::CREATE
225                | rustix::fs::OFlags::NOFOLLOW
226                | rustix::fs::OFlags::CLOEXEC
227                | rustix::fs::OFlags::NONBLOCK,
228            rustix::fs::Mode::from_raw_mode(0o600),
229        )
230    };
231    #[cfg(target_vendor = "apple")]
232    let descriptor = match open() {
233        // 并发首次创建锁文件时,Darwin 的 openat 偶尔返回 ENOENT;目录句柄仍有效,重试同一入口即可。
234        Err(rustix::io::Errno::NOENT) => {
235            std::thread::yield_now();
236            open()?
237        }
238        result => result?,
239    };
240    #[cfg(not(target_vendor = "apple"))]
241    let descriptor = open()?;
242    ensure_regular(descriptor.into())
243}
244
245/// 非 Unix 平台按路径打开锁文件,先拒绝路径上的符号链接;Windows 上不跟随重解析点。
246#[cfg(not(unix))]
247fn open_lock_file(directory: &Path, name: &OsStr) -> io::Result<File> {
248    let path = directory.join(name);
249    msime_path_trust::reject_symlinked_components(&path)?;
250    let mut options = File::options();
251    options.read(true).write(true).create(true).truncate(false);
252    #[cfg(windows)]
253    {
254        use std::os::windows::fs::OpenOptionsExt;
255        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
256        options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
257    }
258    ensure_regular(options.open(path)?)
259}
260
261fn ensure_regular(file: File) -> io::Result<File> {
262    if !file.metadata()?.is_file() || !crate::paths::has_single_link(&file)? {
263        return Err(io::Error::new(
264            io::ErrorKind::InvalidInput,
265            "lock file is not a single-link regular file",
266        ));
267    }
268    Ok(file)
269}
270
271/// 假是被占用,不是出错;从不等待。Android 上标准库的文件锁不可用(直接失败而不是等待),那里用下面 rustix 的 `flock` 版本;按函数而不是按语句分开,`scripts/test-file-locking-goes-through-client-core.py` 才认得出这是 Android 不编译的标准库调用。
272#[cfg(not(target_os = "android"))]
273fn try_shared(file: &File) -> io::Result<bool> {
274    match file.try_lock_shared() {
275        Ok(()) => Ok(true),
276        Err(std::fs::TryLockError::WouldBlock) => Ok(false),
277        Err(std::fs::TryLockError::Error(error)) => Err(error),
278    }
279}
280
281#[cfg(target_os = "android")]
282fn try_shared(file: &File) -> io::Result<bool> {
283    flock(file, rustix::fs::FlockOperation::NonBlockingLockShared)
284}
285
286/// 假是被占用,不是出错;从不等待。
287#[cfg(not(target_os = "android"))]
288fn try_exclusive(file: &File) -> io::Result<bool> {
289    match file.try_lock() {
290        Ok(()) => Ok(true),
291        Err(std::fs::TryLockError::WouldBlock) => Ok(false),
292        Err(std::fs::TryLockError::Error(error)) => Err(error),
293    }
294}
295
296#[cfg(target_os = "android")]
297fn try_exclusive(file: &File) -> io::Result<bool> {
298    flock(file, rustix::fs::FlockOperation::NonBlockingLockExclusive)
299}
300
301#[cfg(target_os = "android")]
302fn flock(file: &File, operation: rustix::fs::FlockOperation) -> io::Result<bool> {
303    match rustix::fs::flock(file, operation) {
304        Ok(()) => Ok(true),
305        Err(rustix::io::Errno::WOULDBLOCK | rustix::io::Errno::INTR) => Ok(false),
306        Err(error) => Err(error.into()),
307    }
308}
309
310#[cfg(all(unix, not(target_os = "android")))]
311fn unlock(file: &File) -> io::Result<()> {
312    file.unlock()
313}
314
315#[cfg(target_os = "android")]
316fn unlock(file: &File) -> io::Result<()> {
317    rustix::fs::flock(file, rustix::fs::FlockOperation::Unlock).map_err(Into::into)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn shared_access_excludes_maintenance_and_releases_cleanly() {
326        let user = tempfile::tempdir().unwrap();
327        let dictionaries = tempfile::tempdir().unwrap();
328        let first = DictionaryAccess::try_session(user.path(), dictionaries.path())
329            .unwrap()
330            .unwrap();
331        let second = DictionaryAccess::try_session(user.path(), dictionaries.path())
332            .unwrap()
333            .unwrap();
334        assert!(
335            DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
336                .unwrap()
337                .is_none()
338        );
339        drop(first);
340        assert!(
341            DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
342                .unwrap()
343                .is_none()
344        );
345        drop(second);
346        let writer = DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
347            .unwrap()
348            .unwrap();
349        assert!(
350            DictionaryAccess::try_session(user.path(), dictionaries.path())
351                .unwrap()
352                .is_none()
353        );
354        drop(writer);
355        assert!(
356            DictionaryAccess::try_session(user.path(), dictionaries.path())
357                .unwrap()
358                .is_some()
359        );
360    }
361
362    /// 旧版本的宿主直接对两个根目录里的 `.msime-dictionary-access.lock` 取文件锁;搬进引擎后的租约必须和它互斥,文件名和锁的种类都不能变。旧锁在这里用标准库来取,Android 上标准库的文件锁不可用,这条只在其他平台跑;Android 上新旧两边本来就是同一个 `flock`。
363    #[cfg(not(target_os = "android"))]
364    #[test]
365    fn interoperates_with_a_lock_taken_on_the_same_file_names() {
366        let user = tempfile::tempdir().unwrap();
367        let dictionaries = tempfile::tempdir().unwrap();
368        let open = |root: &Path| {
369            File::options()
370                .read(true)
371                .write(true)
372                .create(true)
373                .truncate(false)
374                .open(root.join(".msime-dictionary-access.lock"))
375                .unwrap()
376        };
377        let older_session = open(dictionaries.path());
378        older_session.try_lock_shared().unwrap();
379        assert!(
380            DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
381                .unwrap()
382                .is_none()
383        );
384        assert!(
385            DictionaryAccess::try_session(user.path(), dictionaries.path())
386                .unwrap()
387                .is_some()
388        );
389        drop(older_session);
390
391        let maintenance = DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
392            .unwrap()
393            .unwrap();
394        let older_writer = open(user.path());
395        assert!(matches!(
396            older_writer.try_lock(),
397            Err(std::fs::TryLockError::WouldBlock)
398        ));
399        assert!(matches!(
400            older_writer.try_lock_shared(),
401            Err(std::fs::TryLockError::WouldBlock)
402        ));
403        drop(maintenance);
404        older_writer.try_lock().unwrap();
405    }
406
407    #[cfg(unix)]
408    fn assert_access_releases_with_duplicate_descriptors(exclusive: bool) {
409        let user = tempfile::tempdir().unwrap();
410        let dictionaries = tempfile::tempdir().unwrap();
411        let access = DictionaryAccess::acquire(user.path(), dictionaries.path(), exclusive)
412            .unwrap()
413            .unwrap();
414        let duplicates: Vec<_> = access
415            .files
416            .iter()
417            .map(|file| file.try_clone().unwrap())
418            .collect();
419        assert!(
420            DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
421                .unwrap()
422                .is_none()
423        );
424        drop(access);
425
426        let next = DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
427            .unwrap()
428            .expect("结束的访问租约不能被描述符副本继续持有");
429        drop(duplicates);
430        assert!(
431            DictionaryAccess::try_session(user.path(), dictionaries.path())
432                .unwrap()
433                .is_none()
434        );
435        assert!(
436            DictionaryAccess::try_maintenance(user.path(), dictionaries.path())
437                .unwrap()
438                .is_none()
439        );
440        drop(next);
441        assert!(
442            DictionaryAccess::try_session(user.path(), dictionaries.path())
443                .unwrap()
444                .is_some()
445        );
446    }
447
448    #[cfg(unix)]
449    #[test]
450    fn session_access_releases_with_duplicate_descriptors() {
451        assert_access_releases_with_duplicate_descriptors(false);
452    }
453
454    #[cfg(unix)]
455    #[test]
456    fn maintenance_access_releases_with_duplicate_descriptors() {
457        assert_access_releases_with_duplicate_descriptors(true);
458    }
459
460    #[test]
461    fn a_busy_second_root_releases_the_first_without_releasing_the_holder() {
462        let root = tempfile::tempdir().unwrap();
463        let first = root.path().join("a");
464        let second = root.path().join("z");
465        std::fs::create_dir(&first).unwrap();
466        std::fs::create_dir(&second).unwrap();
467        let holder = DictionaryAccess::try_maintenance(&second, &second)
468            .unwrap()
469            .unwrap();
470
471        assert!(DictionaryAccess::try_maintenance(&first, &second)
472            .unwrap()
473            .is_none());
474        let first_only = DictionaryAccess::try_maintenance(&first, &first)
475            .unwrap()
476            .expect("部分获取失败应归还第一把锁");
477        assert!(DictionaryAccess::try_session(&second, &second)
478            .unwrap()
479            .is_none());
480        drop(first_only);
481        drop(holder);
482        assert!(DictionaryAccess::try_maintenance(&first, &second)
483            .unwrap()
484            .is_some());
485    }
486
487    #[test]
488    fn an_invalid_second_lock_releases_the_first_root() {
489        let root = tempfile::tempdir().unwrap();
490        let first = root.path().join("a");
491        let second = root.path().join("z");
492        std::fs::create_dir(&first).unwrap();
493        std::fs::create_dir(&second).unwrap();
494        std::fs::create_dir(second.join(LOCK_FILE)).unwrap();
495
496        assert!(DictionaryAccess::try_maintenance(&first, &second).is_err());
497        assert!(DictionaryAccess::try_maintenance(&first, &first)
498            .unwrap()
499            .is_some());
500    }
501
502    #[test]
503    fn relative_roots_are_refused() {
504        let root = tempfile::tempdir().unwrap();
505        let error = DictionaryAccess::try_maintenance(Path::new("user"), root.path())
506            .err()
507            .unwrap();
508        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
509    }
510
511    #[cfg(unix)]
512    #[test]
513    fn rejects_a_symlinked_root_before_creating_an_external_lock() {
514        use std::os::unix::fs::symlink;
515
516        let parent = tempfile::tempdir().unwrap();
517        let outside = tempfile::tempdir().unwrap();
518        let user = parent.path().join("user");
519        symlink(outside.path(), &user).unwrap();
520        let dictionaries = tempfile::tempdir().unwrap();
521
522        assert!(DictionaryAccess::try_session(&user, dictionaries.path()).is_err());
523        assert!(!outside.path().join(LOCK_FILE).exists());
524    }
525
526    #[cfg(unix)]
527    #[test]
528    fn refuses_a_hard_linked_lock_file() {
529        let outside = tempfile::tempdir().unwrap();
530        let root = tempfile::tempdir().unwrap();
531        let target = outside.path().join("outside.lock");
532        std::fs::write(&target, b"synthetic-lock-target").unwrap();
533        std::fs::hard_link(&target, root.path().join(LOCK_FILE)).unwrap();
534
535        assert!(DictionaryAccess::try_maintenance(root.path(), root.path()).is_err());
536    }
537
538    #[cfg(unix)]
539    #[test]
540    fn the_directory_handle_survives_root_replacement() {
541        let root = tempfile::tempdir().unwrap();
542        let user = root.path().join("user");
543        let dictionaries = root.path().join("dictionaries");
544        std::fs::create_dir(&user).unwrap();
545        std::fs::create_dir(&dictionaries).unwrap();
546        let access = DictionaryAccess::try_maintenance(&user, &dictionaries)
547            .unwrap()
548            .unwrap();
549        let bound = access.directory(&user).unwrap();
550        assert_eq!(
551            access
552                .directory(&root.path().join("elsewhere"))
553                .err()
554                .map(|error| error.kind()),
555            Some(io::ErrorKind::NotFound)
556        );
557
558        let moved = root.path().join("moved-user");
559        std::fs::rename(&user, &moved).unwrap();
560        std::fs::create_dir(&user).unwrap();
561        let marker = rustix::fs::openat(
562            &bound,
563            "marker",
564            rustix::fs::OFlags::WRONLY | rustix::fs::OFlags::CREATE | rustix::fs::OFlags::CLOEXEC,
565            rustix::fs::Mode::from_raw_mode(0o600),
566        )
567        .unwrap();
568        drop(marker);
569
570        assert!(moved.join("marker").exists());
571        assert!(!user.join("marker").exists());
572    }
573}