Skip to main content

orinium_browser/platform/io/
mod.rs

1//! ファイルI/O関連の機能を提供するモジュール
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[allow(dead_code)]
8pub fn load_local_file(path: &str) -> Result<Vec<u8>> {
9    fs::read(path).with_context(|| format!("Failed to read file: {path}"))
10}
11
12/// Candidate bundle directories, most specific first.
13///
14/// Build artefacts (test indexes etc.) take priority over the CWD source tree,
15/// so the `resource` directory next to the executable is tried first.
16fn resource_roots() -> Vec<PathBuf> {
17    let mut roots: Vec<PathBuf> = Vec::new();
18
19    // executable directory/resource
20    if let Ok(exe) = std::env::current_exe()
21        && let Some(dir) = exe.parent()
22    {
23        roots.push(dir.join("resource"));
24    }
25
26    // ./resource
27    roots.push(PathBuf::from("resource"));
28
29    // current_dir()/resource
30    if let Ok(cd) = std::env::current_dir() {
31        roots.push(cd.join("resource"));
32    }
33
34    roots
35}
36
37/// Loads `rel_path` relative to a single bundle `root`.
38///
39/// Returns `Ok(None)` when the file is absent under this root, and an error
40/// when `rel_path` is not bundle-relative or resolves (through symlinks) outside
41/// `root`.
42pub(crate) fn load_resource_from(root: &Path, rel_path: &str) -> Result<Option<Vec<u8>>> {
43    // A bundle-relative path may not be absolute nor climb out of the bundle
44    // directory. Without this, `resource:///../../etc/passwd` would read any
45    // file the process can.
46    let rel = Path::new(rel_path);
47    if rel.is_absolute()
48        || rel.components().any(|c| {
49            matches!(
50                c,
51                std::path::Component::ParentDir | std::path::Component::RootDir
52            )
53        })
54    {
55        anyhow::bail!("Refusing to load resource outside the bundle: {rel_path}");
56    }
57
58    let candidate = root.join(rel_path);
59    if !candidate.is_file() {
60        return Ok(None);
61    }
62
63    // Resolve symlinks before reading and confirm the final path still sits
64    // under `root`, so a symlink cannot escape either.
65    if let (Ok(resolved), Ok(root)) = (fs::canonicalize(&candidate), fs::canonicalize(root))
66        && !resolved.starts_with(&root)
67    {
68        anyhow::bail!("Resource escapes the bundle directory: {rel_path}");
69    }
70
71    Ok(Some(fs::read(&candidate).with_context(|| {
72        format!("Failed to read resource {:?}", candidate)
73    })?))
74}
75
76/// リソースファイルを探して読み込む。
77/// 順序は以下の通り:
78/// - 実行ファイルのあるディレクトリ/resource/<rel_path> (build.rs が同期・生成したリソース)
79/// - ./resource/<rel_path>
80/// - カレントディレクトリ/resource/<rel_path>
81///
82/// ビルド成果物 (test インデックス等) が CWD のソースツリーより優先されるよう、
83/// 実行ファイルの隣にある resource を最初に試す。
84pub fn load_resource(rel_path: &str) -> Result<Vec<u8>> {
85    let roots = resource_roots();
86    for root in &roots {
87        if let Some(bytes) = load_resource_from(root, rel_path)? {
88            return Ok(bytes);
89        }
90    }
91
92    // Surface the path validation error even when there are no roots to probe.
93    if roots.is_empty() {
94        load_resource_from(Path::new("resource"), rel_path)?;
95    }
96
97    anyhow::bail!("Resource not found: {}", rel_path)
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use std::sync::atomic::{AtomicUsize, Ordering};
104
105    #[test]
106    fn rejects_absolute_paths() {
107        assert!(load_resource("/etc/passwd").is_err());
108    }
109
110    #[test]
111    fn rejects_parent_directory_traversal() {
112        let err = load_resource("../../../../etc/passwd").unwrap_err();
113        assert!(
114            err.to_string().contains("outside the bundle"),
115            "unexpected error: {err}"
116        );
117    }
118
119    #[test]
120    fn loads_a_bundled_resource() {
121        let bytes = load_resource("user-agent.css").expect("user-agent.css is bundled");
122        assert!(!bytes.is_empty());
123    }
124
125    /// Creates an empty temporary directory that the caller must remove.
126    fn temp_dir() -> PathBuf {
127        static COUNTER: AtomicUsize = AtomicUsize::new(0);
128        let name = format!(
129            "orinium-io-{}-{}",
130            std::process::id(),
131            COUNTER.fetch_add(1, Ordering::Relaxed)
132        );
133        let dir = std::env::temp_dir().join(name);
134        fs::create_dir_all(&dir).unwrap();
135        dir
136    }
137
138    #[test]
139    fn loads_nested_path_inside_root() {
140        let root = temp_dir();
141        fs::create_dir_all(root.join("sub")).unwrap();
142        fs::write(root.join("sub/file.txt"), b"nested").unwrap();
143
144        let bytes = load_resource_from(&root, "sub/file.txt").unwrap();
145        assert_eq!(bytes.as_deref(), Some(&b"nested"[..]));
146        assert_eq!(load_resource_from(&root, "sub/missing.txt").unwrap(), None);
147
148        let _ = fs::remove_dir_all(&root);
149    }
150
151    #[test]
152    fn from_root_rejects_absolute_and_parent_paths() {
153        let root = temp_dir();
154        assert!(load_resource_from(&root, "/etc/passwd").is_err());
155        assert!(load_resource_from(&root, "../../etc/passwd").is_err());
156        let _ = fs::remove_dir_all(&root);
157    }
158
159    #[cfg(unix)]
160    #[test]
161    fn rejects_symlink_escaping_root() {
162        let root = temp_dir();
163        let outside = temp_dir();
164        fs::write(outside.join("secret.txt"), b"secret").unwrap();
165        std::os::unix::fs::symlink(outside.join("secret.txt"), root.join("link.txt")).unwrap();
166
167        let err = load_resource_from(&root, "link.txt").unwrap_err();
168        assert!(
169            err.to_string().contains("escapes the bundle"),
170            "unexpected error: {err}"
171        );
172
173        let _ = fs::remove_dir_all(&root);
174        let _ = fs::remove_dir_all(&outside);
175    }
176
177    #[cfg(unix)]
178    #[test]
179    fn allows_symlink_staying_inside_root() {
180        let root = temp_dir();
181        fs::write(root.join("real.txt"), b"inside").unwrap();
182        std::os::unix::fs::symlink(root.join("real.txt"), root.join("alias.txt")).unwrap();
183
184        let bytes = load_resource_from(&root, "alias.txt").unwrap();
185        assert_eq!(bytes.as_deref(), Some(&b"inside"[..]));
186
187        let _ = fs::remove_dir_all(&root);
188    }
189}