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}