Skip to main content

rust_silos/
lib.rs

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