use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Component, Path, PathBuf};
use crate::assets;
use crate::diagnostics;
use crate::error::{EngineError, Result};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimePaths {
pub resources: PathBuf,
pub user_data: PathBuf,
pub cache: PathBuf,
pub dictionaries: PathBuf,
}
impl RuntimePaths {
pub fn resource(&self, name: &str) -> PathBuf {
join_current_or_legacy(&self.resources, name)
}
pub fn user(&self, name: &str) -> PathBuf {
join(&self.user_data, name)
}
pub fn dictionary(&self, name: &str) -> PathBuf {
join_current_or_legacy(&self.dictionaries, name)
}
pub fn validate(&self) -> Result<()> {
for root in [
&self.resources,
&self.user_data,
&self.cache,
&self.dictionaries,
] {
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
let absolute = root.has_root();
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
let absolute = root.is_absolute();
if !absolute {
return Err(EngineError::invalid(
diagnostics::RUNTIME_DIRECTORIES_MUST_BE_ABSOLUTE,
));
}
}
Ok(())
}
}
fn join(root: &Path, name: &str) -> PathBuf {
if root.as_os_str().is_empty() || name.is_empty() || Path::new(name).is_absolute() {
return PathBuf::new();
}
root.join(name)
}
fn join_current_or_legacy(root: &Path, name: &str) -> PathBuf {
let current = join(root, name);
if current.as_os_str().is_empty() || std::fs::symlink_metadata(¤t).is_ok() {
return current;
}
assets::LEGACY_NAMES
.iter()
.find(|(present, _)| *present == name)
.map(|(_, legacy)| root.join(legacy))
.filter(|legacy| std::fs::symlink_metadata(legacy).is_ok())
.unwrap_or(current)
}
pub(crate) use msime_path_trust::is_trusted_system_alias;
pub(crate) fn open_file_no_follow(path: &Path) -> io::Result<File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file()
|| !(has_single_link(&file)? || msime_path_trust::multi_link_is_trusted(&file, path)?)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"engine asset is not a regular file with a trusted link count",
));
}
Ok(file)
}
#[cfg(unix)]
pub(crate) fn has_single_link(file: &File) -> io::Result<bool> {
use std::os::unix::fs::MetadataExt;
Ok(file.metadata()?.nlink() == 1)
}
#[cfg(windows)]
pub(crate) fn has_single_link(file: &File) -> io::Result<bool> {
Ok(winapi_util::file::information(file)?.number_of_links() == 1)
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn has_single_link(_: &File) -> io::Result<bool> {
Ok(true)
}
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
fn path_has_single_link(path: &Path, metadata: &std::fs::Metadata) -> io::Result<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let _ = path;
Ok(metadata.nlink() == 1)
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
let _ = metadata;
let file = OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
has_single_link(&file)
}
#[cfg(not(any(unix, windows)))]
{
let _ = (path, metadata);
Ok(true)
}
}
pub(crate) fn sqlite_path_no_follow(path: &Path) -> io::Result<PathBuf> {
sqlite_path_no_follow_with_parent_policy(path, true, false)
}
pub(crate) fn sqlite_read_only_path_no_follow(path: &Path) -> io::Result<PathBuf> {
sqlite_path_no_follow_with_parent_policy(path, true, true)
}
pub(crate) fn sqlite_path_no_follow_allow_parent_symlinks(path: &Path) -> io::Result<PathBuf> {
sqlite_path_no_follow_with_parent_policy(path, false, true)
}
fn sqlite_path_no_follow_with_parent_policy(
path: &Path,
reject_parent_symlinks: bool,
read_only: bool,
) -> io::Result<PathBuf> {
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
{
let _ = (reject_parent_symlinks, read_only);
Ok(path.to_owned())
}
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
{
if reject_parent_symlinks {
msime_path_trust::reject_symlinked_components(path)?;
}
let parent = path.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "database path has no parent")
})?;
let name = path.file_name().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "database path has no filename")
})?;
let resolved = std::fs::canonicalize(parent)?.join(name);
if let Ok(metadata) = std::fs::symlink_metadata(&resolved) {
if metadata.file_type().is_symlink()
|| !(path_has_single_link(&resolved, &metadata)?
|| read_only && msime_path_trust::multi_link_path_is_trusted(&resolved)?)
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"database is not a regular file with a trusted link count",
));
}
}
Ok(resolved)
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
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"
)
)]
pub fn join_checked(root: &Path, name: &str) -> Result<PathBuf> {
if Path::new(name)
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return Err(EngineError::invalid(
diagnostics::RUNTIME_ASSET_PATH_ESCAPES,
));
}
Ok(join(root, name))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn opening_an_asset_does_not_follow_a_leaf_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("asset.bin");
std::fs::write(&target, b"synthetic engine asset").unwrap();
let linked = root.path().join("asset.bin");
symlink(&target, &linked).unwrap();
assert!(open_file_no_follow(&linked).is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"synthetic engine asset");
}
#[cfg(unix)]
#[test]
fn opening_an_asset_rejects_a_directory() {
let root = tempfile::tempdir().unwrap();
assert!(open_file_no_follow(root.path()).is_err());
}
#[cfg(unix)]
fn open_to_others(directory: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o777)).unwrap();
}
#[cfg(windows)]
fn open_to_others(_: &Path) {}
#[cfg(unix)]
fn close_to_others(directory: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[cfg(any(unix, windows))]
#[test]
fn opening_an_asset_rejects_a_hard_link() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = outside.path().join("asset.bin");
std::fs::write(&target, b"synthetic engine asset").unwrap();
let linked = root.path().join("asset.bin");
std::fs::hard_link(&target, &linked).unwrap();
open_to_others(root.path());
assert!(open_file_no_follow(&linked).is_err());
assert_eq!(std::fs::read(&target).unwrap(), b"synthetic engine asset");
}
#[cfg(unix)]
#[test]
fn opening_an_asset_accepts_a_hard_link_in_a_closed_directory() {
let root = tempfile::tempdir().unwrap();
close_to_others(root.path());
let original = root.path().join("handwriting.model");
std::fs::write(&original, b"synthetic engine asset").unwrap();
let deduplicated = root.path().join("other-package.model");
std::fs::hard_link(&original, &deduplicated).unwrap();
let mut file = open_file_no_follow(&original).unwrap();
let mut contents = Vec::new();
std::io::Read::read_to_end(&mut file, &mut contents).unwrap();
assert_eq!(contents, b"synthetic engine asset");
assert!(sqlite_read_only_path_no_follow(&deduplicated).is_ok());
assert!(sqlite_path_no_follow(&deduplicated).is_err());
}
#[test]
fn joins_like_the_reference() {
let paths = RuntimePaths {
resources: PathBuf::from("/r"),
user_data: PathBuf::from("/u"),
cache: PathBuf::from("/c"),
dictionaries: PathBuf::new(),
};
assert_eq!(
paths.resource("msime-pinyin.db"),
PathBuf::from("/r/msime-pinyin.db")
);
assert_eq!(paths.dictionary("msime-pinyin.db"), PathBuf::new());
assert_eq!(paths.user("/abs"), PathBuf::new());
assert!(join_checked(Path::new("/r"), "helpcodes/../x").is_err());
assert!(paths.validate().is_err());
}
#[test]
fn a_missing_current_name_falls_back_to_the_legacy_file() {
let root = tempfile::tempdir().unwrap();
let paths = RuntimePaths {
resources: root.path().join("resources"),
user_data: root.path().join("user"),
cache: root.path().join("cache"),
dictionaries: root.path().join("generation"),
};
std::fs::create_dir_all(&paths.resources).unwrap();
std::fs::create_dir_all(&paths.dictionaries).unwrap();
assert_eq!(
paths.dictionary(assets::MAIN_DICTIONARY),
paths.dictionaries.join("msime-pinyin.db")
);
std::fs::write(paths.dictionaries.join("msime.db"), b"old").unwrap();
std::fs::write(paths.resources.join("others.db"), b"old").unwrap();
assert_eq!(
paths.dictionary(assets::MAIN_DICTIONARY),
paths.dictionaries.join("msime.db")
);
assert_eq!(
paths.resource(assets::OTHER_DICTIONARY),
paths.resources.join("others.db")
);
assert_eq!(
paths.resource(assets::WUBI_DICTIONARY),
paths.resources.join("msime-wubi.db")
);
std::fs::write(paths.dictionaries.join("msime-pinyin.db"), b"new").unwrap();
assert_eq!(
paths.dictionary(assets::MAIN_DICTIONARY),
paths.dictionaries.join("msime-pinyin.db")
);
}
#[cfg(any(unix, windows))]
#[test]
fn sqlite_paths_reject_hard_linked_databases() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let target = root.path().join("database.db");
let external = outside.path().join("database.db");
std::fs::write(&external, b"synthetic database").unwrap();
std::fs::hard_link(&external, &target).unwrap();
assert!(sqlite_path_no_follow(&target).is_err());
open_to_others(root.path());
assert!(sqlite_read_only_path_no_follow(&target).is_err());
assert!(sqlite_path_no_follow_allow_parent_symlinks(&target).is_err());
assert_eq!(std::fs::read(&external).unwrap(), b"synthetic database");
}
}