1use 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 pub resources: PathBuf,
17 pub user_data: PathBuf,
19 pub cache: PathBuf,
20 pub dictionaries: PathBuf,
22}
23
24impl RuntimePaths {
25 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 pub fn dictionary(&self, name: &str) -> PathBuf {
36 join_current_or_legacy(&self.dictionaries, name)
37 }
38
39 pub fn validate(&self) -> Result<()> {
41 for root in [
42 &self.resources,
43 &self.user_data,
44 &self.cache,
45 &self.dictionaries,
46 ] {
47 #[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
62fn 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
70fn 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(¤t).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
84pub(crate) use msime_path_trust::is_trusted_system_alias;
86
87pub(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 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#[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#[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#[cfg(not(any(unix, windows)))]
135pub(crate) fn has_single_link(_: &File) -> io::Result<bool> {
136 Ok(true)
137}
138
139#[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
166pub(crate) fn sqlite_path_no_follow(path: &Path) -> io::Result<PathBuf> {
171 sqlite_path_no_follow_with_parent_policy(path, true, false)
172}
173
174pub(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
179pub(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#[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 #[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 #[cfg(windows)]
282 fn open_to_others(_: &Path) {}
283
284 #[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 #[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 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 #[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 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}