1use 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
16pub const LOCK_FILE: &str = ".msime-dictionary-access.lock";
18
19pub struct DictionaryAccess {
21 files: Vec<File>,
22 roots: Vec<Root>,
23}
24
25struct 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 for file in &self.files {
37 let _ = unlock(file);
38 }
39 }
40}
41
42impl DictionaryAccess {
43 pub fn try_session(user: &Path, dictionaries: &Path) -> io::Result<Option<Self>> {
45 Self::acquire(user, dictionaries, false)
46 }
47
48 pub fn try_maintenance(user: &Path, dictionaries: &Path) -> io::Result<Option<Self>> {
50 Self::acquire(user, dictionaries, true)
51 }
52
53 #[cfg(unix)]
55 pub fn directory(&self, path: &Path) -> io::Result<File> {
56 self.root(path)?.directory.try_clone()
57 }
58
59 #[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 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
112pub(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#[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#[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 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#[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#[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 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#[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#[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#[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 #[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}