rtb_assets/source.rs
1//! The [`AssetSource`] trait and its three built-in implementations.
2//!
3//! Downstream tools compose sources via [`crate::AssetsBuilder`];
4//! implementing `AssetSource` manually is supported for exotic cases
5//! (in-process archives, HTTP overlays, etc.) but not required.
6
7use std::collections::HashMap;
8use std::fs;
9use std::marker::PhantomData;
10use std::path::{Path, PathBuf};
11
12use rust_embed::RustEmbed;
13
14/// A single layer of the overlay filesystem.
15///
16/// Implementations must be cheaply cloneable-by-`Arc`: [`crate::Assets`]
17/// shares sources behind `Arc<dyn AssetSource>` for zero-cost cloning.
18pub trait AssetSource: Send + Sync + 'static {
19 /// Return the bytes at `path` if this layer provides it.
20 fn read(&self, path: &str) -> Option<Vec<u8>>;
21
22 /// Return the immediate entries of `dir` (files and subdirectory
23 /// names, without the `dir` prefix). Empty if `dir` does not exist
24 /// on this layer.
25 fn list(&self, dir: &str) -> Vec<String>;
26
27 /// Diagnostic-only name (shown in parse errors). The empty string
28 /// is fine for anonymous / test sources.
29 fn name(&self) -> &str;
30}
31
32// -----------------------------------------------------------------
33// EmbeddedSource — adapts a `#[derive(RustEmbed)]` type.
34// -----------------------------------------------------------------
35
36/// Layer backed by a `rust-embed` type. Zero-sized — all storage lives
37/// in the embed's generated static tables.
38pub struct EmbeddedSource<E: RustEmbed + Send + Sync + 'static> {
39 name: &'static str,
40 _marker: PhantomData<fn() -> E>,
41}
42
43impl<E: RustEmbed + Send + Sync + 'static> EmbeddedSource<E> {
44 /// Construct a new embedded-source adapter.
45 ///
46 /// `name` is used only for diagnostics.
47 #[must_use]
48 pub const fn new(name: &'static str) -> Self {
49 Self { name, _marker: PhantomData }
50 }
51}
52
53impl<E: RustEmbed + Send + Sync + 'static> AssetSource for EmbeddedSource<E> {
54 fn read(&self, path: &str) -> Option<Vec<u8>> {
55 E::get(path).map(|file| file.data.to_vec())
56 }
57
58 fn list(&self, dir: &str) -> Vec<String> {
59 let prefix = if dir.is_empty() || dir == "." {
60 String::new()
61 } else if dir.ends_with('/') {
62 dir.to_string()
63 } else {
64 format!("{dir}/")
65 };
66
67 let mut seen = std::collections::BTreeSet::new();
68 for raw in E::iter() {
69 let Some(rest) = raw.strip_prefix(prefix.as_str()) else { continue };
70 if rest.is_empty() {
71 continue;
72 }
73 // Immediate child only — split on the first '/' and keep
74 // the leading segment.
75 let head = rest.find('/').map_or(rest, |idx| &rest[..idx]);
76 seen.insert(head.to_string());
77 }
78 seen.into_iter().collect()
79 }
80
81 fn name(&self) -> &str {
82 self.name
83 }
84}
85
86// -----------------------------------------------------------------
87// DirectorySource — wraps a PathBuf on the filesystem.
88// -----------------------------------------------------------------
89
90/// Layer backed by a directory on the host filesystem.
91///
92/// Relative paths passed to [`AssetSource::read`] are resolved against
93/// the directory root. Missing files — and a missing root — return
94/// `None` without error (the overlay semantics expect this).
95pub struct DirectorySource {
96 root: PathBuf,
97 name: String,
98}
99
100impl DirectorySource {
101 /// Construct a new directory layer. `name` is used only for
102 /// diagnostics; typically the directory's basename or a config-
103 /// supplied label.
104 #[must_use]
105 pub fn new(root: impl Into<PathBuf>, name: impl Into<String>) -> Self {
106 Self { root: root.into(), name: name.into() }
107 }
108
109 /// Resolve `path` against the root, rejecting any traversal that
110 /// would escape the root.
111 ///
112 /// Returns `None` if:
113 /// * `path` is absolute,
114 /// * any component is a prefix/`..`/`.` that could walk upward,
115 /// * the lexical resolution falls outside `self.root`.
116 ///
117 /// Relative paths without `..` components resolve to
118 /// `root.join(path)` as expected.
119 fn resolve(&self, path: &str) -> Option<PathBuf> {
120 safe_join(&self.root, path)
121 }
122}
123
124impl AssetSource for DirectorySource {
125 fn read(&self, path: &str) -> Option<Vec<u8>> {
126 let resolved = self.resolve(path)?;
127 fs::read(resolved).ok()
128 }
129
130 fn list(&self, dir: &str) -> Vec<String> {
131 if dir.is_empty() || dir == "." {
132 return list_owned(self.root.as_path());
133 }
134 // Reject any traversal attempt on list() too — silent empty
135 // matches the DirectorySource contract for missing entries.
136 let Some(resolved) = self.resolve(dir) else {
137 return Vec::new();
138 };
139 list_owned(&resolved)
140 }
141
142 fn name(&self) -> &str {
143 &self.name
144 }
145}
146
147/// Join `path` onto `root`, refusing any input that could escape
148/// the root via `..`, absolute paths, or Windows prefix components.
149///
150/// This is a lexical check — we do not call `canonicalize()` because
151/// the target may not exist yet (e.g. `list_dir` on an empty
152/// subdirectory) and because symlink-following is a caller concern
153/// not this layer's. The lexical check is sufficient to prevent the
154/// `"../../etc/passwd"` class of traversal, which is the documented
155/// threat model for `DirectorySource`.
156fn safe_join(root: &Path, rel: &str) -> Option<PathBuf> {
157 use std::path::Component;
158
159 let rel_path = Path::new(rel);
160 // Absolute paths are always rejected — the caller is a layer
161 // that operates under a fixed root.
162 if rel_path.is_absolute() {
163 return None;
164 }
165
166 let mut out = root.to_path_buf();
167 for component in rel_path.components() {
168 match component {
169 // Normal components extend the path.
170 Component::Normal(part) => out.push(part),
171 // `.` is a no-op.
172 Component::CurDir => {}
173 // `..`, root, or prefix components are all rejected.
174 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
175 }
176 }
177 Some(out)
178}
179
180fn list_owned(dir: &Path) -> Vec<String> {
181 let Ok(iter) = fs::read_dir(dir) else {
182 return Vec::new();
183 };
184 let mut out = Vec::new();
185 for entry in iter.flatten() {
186 if let Some(name) = entry.file_name().to_str() {
187 out.push(name.to_string());
188 }
189 }
190 out.sort();
191 out
192}
193
194// -----------------------------------------------------------------
195// MemorySource — HashMap-backed, useful for tests.
196// -----------------------------------------------------------------
197
198/// Layer backed by an in-memory map. Ideal for test fixtures and
199/// scaffolder scratch space.
200pub struct MemorySource {
201 name: String,
202 files: HashMap<String, Vec<u8>>,
203}
204
205impl MemorySource {
206 /// Construct a new in-memory layer.
207 #[must_use]
208 pub fn new(name: impl Into<String>, files: HashMap<String, Vec<u8>>) -> Self {
209 Self { name: name.into(), files }
210 }
211}
212
213impl AssetSource for MemorySource {
214 fn read(&self, path: &str) -> Option<Vec<u8>> {
215 self.files.get(path).cloned()
216 }
217
218 fn list(&self, dir: &str) -> Vec<String> {
219 let prefix = if dir.is_empty() || dir == "." {
220 String::new()
221 } else if dir.ends_with('/') {
222 dir.to_string()
223 } else {
224 format!("{dir}/")
225 };
226
227 let mut seen = std::collections::BTreeSet::new();
228 for key in self.files.keys() {
229 let Some(rest) = key.strip_prefix(prefix.as_str()) else { continue };
230 if rest.is_empty() {
231 continue;
232 }
233 let head = rest.find('/').map_or(rest, |idx| &rest[..idx]);
234 seen.insert(head.to_string());
235 }
236 seen.into_iter().collect()
237 }
238
239 fn name(&self) -> &str {
240 &self.name
241 }
242}