Skip to main content

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}