rust_silos/
lib.rs

1// Re-export phf_map macro for consumers of rust-silos
2pub use phf::phf_map;
3pub use phf;
4use std::hash::Hash;
5use std::io::Cursor;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use thiserror::Error;
9
10pub use rust_silos_macros::embed_silo;
11
12
13/// Error type for file and silo operations.
14#[derive(Debug, Error)]
15pub enum Error {
16    #[error("Failed to decode file contents: {source}")]
17    DecodeError {
18        #[from]
19        source: std::string::FromUtf8Error,
20    },
21    #[error("File not found")]
22    NotFound,
23    #[error("I/O error: {source}")]
24    IoError {
25        #[from]
26        source: std::io::Error,
27    },
28}
29
30
31/// Metadata and contents for an embedded file.
32#[derive(Debug)]
33pub struct EmbedEntry {
34    pub path: &'static str,
35    pub contents: &'static [u8],
36    pub size: usize,
37    pub modified: u64,
38}
39
40/// Metadata for a file.
41#[derive(Debug, Copy, Clone, Eq, PartialEq)]
42pub struct FileMeta {
43    pub size: usize,
44    /// Seconds since UNIX epoch.
45    pub modified: u64,
46}
47
48/// Handle to an embedded file entry.
49#[derive(Copy, Clone, Debug)]
50struct EmbedFile {
51    inner: &'static EmbedEntry,
52}
53
54impl EmbedFile {
55    /// Returns the relative path of the embedded file.
56    pub fn path(&self) -> &Path {
57        Path::new(self.inner.path)
58    }
59}
60
61/// Internal enum for file variants (embedded or dynamic).
62#[derive(Debug, Clone)]
63enum FileKind {
64    Embed(EmbedFile),
65    Dynamic(DynFile),
66}
67
68/// Represents a file, which may be embedded or dynamic.
69#[derive(Debug, Clone)]
70pub struct File {
71    inner: FileKind,
72}
73
74impl File {
75    /// Returns a reader for the file's contents. May return an error if the file cannot be opened.
76    pub fn reader(&self) -> Result<FileReader, Error> {
77        match &self.inner {
78            FileKind::Embed(embed) => Ok(FileReader::Embed(Cursor::new(embed.inner.contents))),
79            FileKind::Dynamic(dyn_file) => Ok(FileReader::Dynamic(std::fs::File::open(
80                dyn_file.absolute_path(),
81            )?)),
82        }
83    }
84
85    /// Returns the relative path of the file.
86    pub fn path(&self) -> &Path {
87        match &self.inner {
88            FileKind::Embed(embed) => embed.path(),
89            FileKind::Dynamic(dyn_file) => dyn_file.path(),
90        }
91    }
92
93    /// Returns true if the file is embedded in the binary.
94    pub fn is_embedded(&self) -> bool {
95        matches!(self.inner, FileKind::Embed(_))
96    }
97
98    /// Returns the absolute path if the file is dynamic, or None if embedded.
99    pub fn absolute_path(&self) -> Option<&Path> {
100        match &self.inner {
101            FileKind::Embed(_) => None,
102            FileKind::Dynamic(dyn_file) => Some(dyn_file.absolute_path()),
103        }
104    }
105
106    /// Returns the file extension, if any.
107    pub fn extension(&self) -> Option<&str> {
108        self.path().extension().and_then(|s| s.to_str())
109    }
110
111    /// Returns file metadata (size and modified time).
112    ///
113    /// For embedded files, this is compile-time metadata.
114    /// For dynamic files, this reads filesystem metadata.
115    pub fn meta(&self) -> Result<FileMeta, Error> {
116        match &self.inner {
117            FileKind::Embed(embed) => Ok(FileMeta {
118                size: embed.inner.size,
119                modified: embed.inner.modified,
120            }),
121            FileKind::Dynamic(dyn_file) => {
122                let metadata = std::fs::metadata(dyn_file.absolute_path())?;
123                let len = metadata.len();
124                let size = usize::try_from(len).map_err(|_| {
125                    std::io::Error::new(std::io::ErrorKind::InvalidData, "file size overflows usize")
126                })?;
127
128                let mtime = metadata.modified()?;
129                let dur = mtime.duration_since(std::time::UNIX_EPOCH).map_err(|_| {
130                    std::io::Error::new(
131                        std::io::ErrorKind::InvalidData,
132                        "file modified time is before UNIX epoch",
133                    )
134                })?;
135
136                Ok(FileMeta {
137                    size,
138                    modified: dur.as_secs(),
139                })
140            }
141        }
142    }
143}
144
145/// Files are equal if their relative paths are equal.
146impl PartialEq for File {
147    fn eq(&self, other: &Self) -> bool {
148        self.path() == other.path()
149    }
150}
151
152/// Hashes a file by its relative path.
153impl Hash for File {
154    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
155        self.path().hash(state);
156    }
157}
158
159impl Eq for File {}
160
161
162
163/// Represents a set of embedded files and their root.
164#[derive(Debug, Clone)]
165struct EmbedSilo {
166    map: &'static phf::Map<&'static str, EmbedEntry>,
167    root: &'static str,
168}
169
170impl EmbedSilo {
171    /// Create a new EmbedSilo from a PHF map and root path.
172    pub const fn new(map: &'static phf::Map<&'static str, EmbedEntry>, root: &'static str) -> Self {
173        Self { map, root }
174    }
175
176    /// Get an embedded file by its relative path.
177    /// Returns None if not found.
178    pub fn get_file(&self, path: &str) -> Option<EmbedFile> {
179        self.map.get(path).map(|entry| EmbedFile { inner: entry })
180    }
181
182    /// Iterate over all embedded files in this silo.
183    pub fn iter(&self) -> impl Iterator<Item = File> + '_ {
184        self.map.values().map(|entry| File {
185            inner: FileKind::Embed(EmbedFile { inner: entry }),
186        })
187    }
188}
189
190/// Represents a file from the filesystem (not embedded).
191#[derive(Debug, Clone)]
192struct DynFile {
193    rel_path: Arc<str>,
194    full_path: Arc<str>,
195}
196
197fn normalize_rel_path(path: &str) -> Arc<str> {
198    // Keep paths stable across platforms (Windows uses `\`).
199    Arc::from(path.replace('\\', "/"))
200}
201
202impl DynFile {
203    /// root is the base directory where the file is located, and path is the relative path to the file.
204    /// Create a new DynFile from absolute and relative paths.
205    /// Both must be valid UTF-8.
206    pub fn new<S: AsRef<str>>(full_path: S, rel_path: S) -> Self {
207        Self {
208            rel_path: Arc::from(rel_path.as_ref()),
209            full_path: Arc::from(full_path.as_ref()),
210        }
211    }
212
213    /// Returns the relative path of the file.
214    pub fn path(&self) -> &Path {
215        Path::new(&*self.rel_path)
216    }
217
218    /// Returns the absolute path of the file.
219    pub fn absolute_path(&self) -> &Path {
220        Path::new(&*self.full_path)
221    }
222}
223
224/// Get a dynamic file by its relative path. Returns None if not found or not a file.
225fn get_file_for_root(root: &str, path: &str) -> Option<DynFile> {
226    // Security note: this performs a canonicalized path-prefix check to prevent `..` traversal
227    // and symlink escapes when looking up filesystem-backed files.
228    let root_canon = Path::new(root).canonicalize().ok()?;
229    let normalized_rel = normalize_rel_path(path);
230    let joined = root_canon.join(normalized_rel.as_ref());
231
232    let candidate = joined.canonicalize().ok()?;
233    if !candidate.starts_with(&root_canon) {
234        return None;
235    }
236    if !candidate.is_file() {
237        return None;
238    }
239
240    let rel_path = candidate
241        .strip_prefix(&root_canon)
242        .ok()?
243        .to_str()?
244        .replace('\\', "/");
245    let full_path = candidate.to_str()?;
246    Some(DynFile::new(Arc::from(full_path), Arc::from(rel_path)))
247}
248
249/// Iterate over all files in the dynamic silo.
250fn iter_root(root: &str) -> impl Iterator<Item = File> {
251    let root_path = PathBuf::from(root);
252    walkdir::WalkDir::new(&root_path)
253        .into_iter()
254        .filter_map(move |entry| {
255            let entry = entry.ok()?;
256            if entry.file_type().is_file() {
257                let relative_path = entry.path().strip_prefix(&root_path).ok()?;
258                Some(File {
259                    inner: FileKind::Dynamic(DynFile::new(
260                        Arc::from(entry.path().to_str()?),
261                        normalize_rel_path(relative_path.to_str()?),
262                    )),
263                })
264            } else {
265                None
266            }
267        })
268}
269
270/// Represents a set of dynamic (filesystem) files rooted at a directory.
271#[derive(Debug, Clone)]
272struct DynamicSilo {
273    root: Arc<str>,
274}
275
276impl DynamicSilo {
277    /// Creates a new DynamicSilo from a dynamic root path.
278    /// The root path must be valid UTF-8.
279    pub fn new(root: &str) -> Self {
280        Self { root: Arc::from(root) }
281    }
282
283    /// Gets a dynamic file by its relative path.
284    /// Returns `None` if the file is not found or is not a valid file.
285    pub fn get_file(&self, path: &str) -> Option<DynFile> {
286        get_file_for_root(self.root.as_ref(), path)
287    }
288
289    /// Iterates over all files in the dynamic silo.
290    /// Returns an iterator of `File` objects representing the files.
291    pub fn iter(&self) -> impl Iterator<Item = File> {
292        iter_root(self.root.as_ref())
293    }
294}
295
296/// Represents a set of static (filesystem) files rooted at a directory.
297/// Static silos are backed by a fixed directory path.
298#[derive(Debug, Clone)]
299struct StaticSilo {
300    root: &'static str,
301}
302
303impl StaticSilo {
304    /// Creates a new StaticSilo from a static root path.
305    pub const fn new(root: &'static str) -> Self {
306        Self { root }
307    }
308
309    /// Gets a static file by its relative path.
310    /// Returns `None` if the file is not found or is not a valid file.
311    pub fn get_file(&self, path: &str) -> Option<DynFile> {
312        get_file_for_root(self.root, path)
313    }
314
315    /// Iterates over all files in the static silo.
316    /// Returns an iterator of `File` objects representing the files.
317    pub fn iter(&self) -> impl Iterator<Item = File> {
318        iter_root(self.root)
319    }
320}
321
322/// Internal enum for silo variants (embedded or dynamic).
323#[derive(Debug, Clone)]
324enum InnerSilo {
325    Embed(EmbedSilo),
326    Static(StaticSilo),
327    Dynamic(DynamicSilo),
328}
329
330/// Represents a root directory, which may be embedded or dynamic.
331#[derive(Debug, Clone)]
332pub struct Silo {
333    inner: InnerSilo,
334}
335
336impl Silo {
337
338    #[doc(hidden)]
339    /// Creates a Silo from an embedded PHF map and root path.
340    pub const fn from_embedded(phf_map: &'static phf::Map<&'static str, EmbedEntry>, root: &'static str) -> Self {
341        Self {
342            inner: InnerSilo::Embed(EmbedSilo::new(phf_map, root)),
343        }
344    }
345
346    #[doc(hidden)]
347    /// Creates a Silo from a static path (dynamic root).
348    pub const fn from_static(path: &'static str) -> Self {
349        Self {
350            inner: InnerSilo::Static(StaticSilo::new(path)),
351        }
352    }
353
354    /// Creates a Silo from a dynamic path (dynamic root).
355    pub fn new(path: &str) -> Self {
356        Self {
357            inner: InnerSilo::Dynamic(DynamicSilo::new(path)),
358        }
359    }
360
361    /// Converts the Silo to a dynamic Silo if it is currently embedded.
362    /// Returns `self` unchanged if the Silo is already dynamic or static.
363    pub fn into_dynamic(self) -> Self {
364        match self.inner {
365            InnerSilo::Embed(emb_silo) => Self::from_static(emb_silo.root),
366            InnerSilo::Static(_) => self,
367            InnerSilo::Dynamic(_) => self,
368        }
369    }
370
371    /// Automatically converts the Silo to a dynamic directory in debug mode (`cfg!(debug_assertions)`).
372    /// In release mode, returns `self` unchanged.
373    /// This is a no-op if the Silo is not embedded.
374    pub fn auto_dynamic(self) -> Self {
375        if cfg!(debug_assertions) {
376            self.into_dynamic()
377        } else {
378            self
379        }
380    }
381
382    /// Returns `true` if this Silo is dynamic (filesystem-backed).
383    pub fn is_dynamic(&self) -> bool {
384        matches!(self.inner, InnerSilo::Static(_) | InnerSilo::Dynamic(_))
385    }
386
387    /// Returns `true` if this Silo is embedded in the binary.
388    pub fn is_embedded(&self) -> bool {
389        matches!(self.inner, InnerSilo::Embed(_))
390    }
391
392    /// Gets a file by its relative path from this Silo.
393    /// Returns `None` if the file is not found.
394    pub fn get_file(&self, path: &str) -> Option<File> {
395        match &self.inner {
396            InnerSilo::Embed(embed) => embed.get_file(path).map(|f| File {
397                inner: FileKind::Embed(f),
398            }),
399            InnerSilo::Static(dyn_silo) => dyn_silo.get_file(path).map(|f| File {
400                inner: FileKind::Dynamic(f),
401            }),
402            InnerSilo::Dynamic(dyn_silo) => dyn_silo.get_file(path).map(|f| File {
403                inner: FileKind::Dynamic(f),
404            }),
405        }
406    }
407
408    /// Iterates over all files in this Silo.
409    /// Returns a boxed iterator of `File` objects representing the files.
410    pub fn iter(&self) -> Box<dyn Iterator<Item = File> + '_> {
411        match &self.inner {
412            InnerSilo::Embed(embd) => Box::new(embd.iter()),
413            InnerSilo::Static(dynm) => Box::new(dynm.iter()),
414            InnerSilo::Dynamic(dynm) => Box::new(dynm.iter()),
415        }
416    }
417}
418
419
420
421/// Represents a set of root directories, supporting overlay and override semantics.
422/// Later directories in the set can override files from earlier ones with the same relative path.
423#[derive(Debug, Clone)]
424pub struct SiloSet {
425    /// The list of root directories, in order of increasing precedence.
426    pub silos: Vec<Silo>,
427}
428
429impl SiloSet {
430    /// Creates a new SiloSet from the given list of directories.
431    /// The order of directories determines override precedence.
432    /// Create a new SiloSet from a list of Silos. Order determines override precedence.
433    pub fn new(dirs: Vec<Silo>) -> Self {
434        Self { silos: dirs }
435    }
436
437
438    /// Returns the file with the given name, searching roots in reverse order.
439    /// Files in later roots override those in earlier roots if the relative path matches.
440    /// Get a file by name, searching Silos in reverse order (highest precedence first).
441    pub fn get_file(&self, name: &str) -> Option<File> {
442        for silo in self.silos.iter().rev() {
443            if let Some(file) = silo.get_file(name) {
444                return Some(file);
445            }
446        }
447        None
448    }
449
450    /// Recursively walks all files in all root directories.
451    /// Files with the same relative path from different roots are all included.
452    /// Iterate all files in all Silos, including duplicates.
453    pub fn iter(&self) -> impl Iterator<Item = File> + '_ {
454        self.silos.iter().rev().flat_map(|silo| silo.iter())
455    }
456
457    /// Recursively walks all files, yielding only the highest-precedence file for each relative path.
458    /// This implements the override behaviour: later roots take precedence over earlier ones.
459    /// Iterate all files, yielding only the highest-precedence file for each path.
460    pub fn iter_override(&self) -> impl Iterator<Item = File> + '_ {
461        let mut history = std::collections::HashSet::new();
462        self.iter().filter(move |file| history.insert(file.clone()))
463    }
464}
465
466
467/// Reader for file contents, either embedded or dynamic.
468pub enum FileReader {
469    Embed(std::io::Cursor<&'static [u8]>),
470    Dynamic(std::fs::File),
471}
472
473/// Implements std::io::Read for FileReader.
474impl std::io::Read for FileReader {
475    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
476        match self {
477            FileReader::Embed(c) => c.read(buf),
478            FileReader::Dynamic(f) => f.read(buf),
479        }
480    }
481}