Skip to main content

pfx_load/
source.rs

1use std::path::{Component, Path, PathBuf};
2
3#[derive(Clone, Debug)]
4pub enum Source {
5    Embedded(&'static [(&'static str, &'static [u8])]),
6    Folder(PathBuf),
7}
8
9impl Source {
10    pub fn embedded(files: &'static [(&'static str, &'static [u8])]) -> Self {
11        Source::Embedded(files)
12    }
13
14    pub fn folder(path: impl Into<PathBuf>) -> Self {
15        Source::Folder(path.into())
16    }
17
18    pub fn beside_executable(relative: impl AsRef<Path>) -> Result<Self, String> {
19        let relative = relative.as_ref();
20        if relative.is_absolute() || relative.has_root() {
21            return Err("the asset folder must be relative to the executable".into());
22        }
23        let exe = std::env::current_exe().map_err(|e| format!("executable: {e}"))?;
24        let dir = exe
25            .parent()
26            .ok_or("the executable has no directory")?
27            .join(relative);
28        Ok(Source::Folder(dir))
29    }
30
31    pub fn directory(&self) -> Option<&Path> {
32        match self {
33            Source::Folder(path) => Some(path),
34            Source::Embedded(_) => None,
35        }
36    }
37
38    pub fn read(&self, name: &str) -> Result<Vec<u8>, String> {
39        let key = normalize(name)?;
40        match self {
41            Source::Embedded(files) => files
42                .iter()
43                .find(|(name, _)| *name == key)
44                .map(|(_, bytes)| bytes.to_vec())
45                .ok_or_else(|| format!("{key}: not in the binary")),
46            Source::Folder(root) => {
47                let path = root.join(&key);
48                std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))
49            }
50        }
51    }
52
53    pub fn read_beside(&self, file: &str, uri: &str) -> Result<Vec<u8>, String> {
54        let file = normalize(file)?;
55        let side = normalize(uri)?;
56        let joined = match file.rsplit_once('/') {
57            Some((dir, _)) => format!("{dir}/{side}"),
58            None => side,
59        };
60        self.read(&joined)
61    }
62}
63
64fn normalize(name: &str) -> Result<String, String> {
65    if name.is_empty() {
66        return Err("asset name is empty".into());
67    }
68    let name = name.replace('\\', "/");
69    let path = Path::new(&name);
70    if path.is_absolute() {
71        return Err(format!("{name} is outside the asset folder"));
72    }
73    let mut parts = Vec::new();
74    for component in path.components() {
75        match component {
76            Component::Normal(part) => {
77                let part = part
78                    .to_str()
79                    .ok_or_else(|| format!("{name} is outside the asset folder"))?;
80                parts.push(part);
81            }
82            Component::CurDir => {}
83            _ => return Err(format!("{name} is outside the asset folder")),
84        }
85    }
86    if parts.is_empty() {
87        return Err("asset name is empty".into());
88    }
89    Ok(parts.join("/"))
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    const FILES: &[(&str, &[u8])] = &[
97        ("triangle.glb", include_bytes!("../tests/data/triangle.glb")),
98        (
99            "triangle.gltf",
100            include_bytes!("../tests/data/triangle.gltf"),
101        ),
102        ("triangle.bin", include_bytes!("../tests/data/triangle.bin")),
103        ("swatch8.png", include_bytes!("../tests/data/swatch8.png")),
104        ("swatch16.png", include_bytes!("../tests/data/swatch16.png")),
105        (
106            "swatch.rgba16",
107            include_bytes!("../tests/data/swatch.rgba16"),
108        ),
109        ("sky.hdr", include_bytes!("../tests/data/sky.hdr")),
110        ("flat.hdr", include_bytes!("../tests/data/flat.hdr")),
111        ("family.ttf", include_bytes!("../tests/data/family.ttf")),
112    ];
113
114    #[test]
115    fn embedded_and_folder_sources_return_the_same_bytes() {
116        let embedded = Source::embedded(FILES);
117        let folder = Source::folder(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/data"));
118        for (name, bytes) in FILES {
119            assert_eq!(embedded.read(name).unwrap(), *bytes);
120            assert_eq!(folder.read(name).unwrap(), *bytes);
121            assert_eq!(folder.read_beside("scene.gltf", name).unwrap(), *bytes);
122        }
123        assert!(folder.read("../Cargo.toml").is_err());
124        assert!(folder.read("/etc/passwd").is_err());
125        assert!(embedded.read("missing.png").is_err());
126    }
127
128    #[test]
129    fn the_installed_folder_is_beside_the_executable() {
130        let source = Source::beside_executable("assets").unwrap();
131        let exe = std::env::current_exe().unwrap();
132        let expected = exe.parent().unwrap().join("assets");
133        assert_eq!(source.directory(), Some(expected.as_path()));
134        let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets");
135        assert_ne!(source.directory(), Some(manifest.as_path()));
136        assert!(Source::beside_executable("/assets").is_err());
137    }
138}