Skip to main content

embed_utils/
embed_utils.rs

1//! Shared utilities used by the `embed` crate and by `embed_impl`.
2//!
3//! This crate is an implementation detail and should not be used directly.
4#![forbid(unsafe_code)]
5
6use std::{
7    borrow::Cow,
8    collections::HashMap,
9    fmt, fs, io,
10    path::{Path, PathBuf},
11    sync::{OnceLock, RwLock},
12    time::SystemTime,
13};
14
15use crypto::{Hasher, blake3::Blake3};
16
17/// A file discovered while walking the assets folder.
18///
19/// `rel_path` is the path of the file relative to the folder root, always using
20/// `/` as the separator and always satisfying [`is_valid_key`].
21///
22/// `full_canonical_path` is the absolute, symlink-resolved path of the file and
23/// is guaranteed to live inside the canonical folder root. Entries whose target
24/// escapes the folder (for example a symlink pointing at `/etc/passwd`) are
25/// skipped, so callers never embed or serve files outside the folder.
26#[cfg_attr(all(debug_assertions, not(feature = "debug-embed")), allow(unused))]
27pub struct FileEntry {
28    pub rel_path: String,
29    pub full_canonical_path: PathBuf,
30}
31
32/// Walks `folder_path` and yields every file that belongs to the folder.
33///
34/// Files are visited in a deterministic order (sorted by file name at each
35/// level). Entries are skipped, never panicking, when they cannot be used:
36///
37/// - names that are not valid UTF-8;
38/// - names that are not valid lookup keys (see [`is_valid_key`]), including
39///   names containing a backslash on non-Windows platforms;
40/// - paths that cannot be canonicalized (for example a symlink that was removed
41///   while the folder was being walked);
42/// - files whose canonical path leaves `folder_path` (symbolic links pointing
43///   outside the folder).
44#[cfg_attr(all(debug_assertions, not(feature = "debug-embed")), allow(unused))]
45pub fn get_files(folder_path: &Path, matcher: &PathMatcher) -> impl Iterator<Item = FileEntry> {
46    // Resolve the folder once; entries are checked against it so that symlinks
47    // cannot escape the folder.
48    let canonical_root = folder_path.canonicalize().ok();
49
50    walkdir::WalkDir::new(folder_path)
51        .follow_links(true)
52        .sort_by_file_name()
53        .into_iter()
54        .filter_map(|entry| entry.ok())
55        .filter(|entry| entry.file_type().is_file())
56        .filter_map(move |entry| {
57            let rel_path = entry.path().strip_prefix(folder_path).ok()?;
58            let rel_path = rel_path_key(rel_path)?;
59            if !matcher.is_path_included(&rel_path) {
60                return None;
61            }
62
63            let full_canonical_path = entry.path().canonicalize().ok()?;
64            if !full_canonical_path.starts_with(canonical_root.as_ref()?) {
65                return None;
66            }
67
68            Some(FileEntry {
69                rel_path: rel_path.into_owned(),
70                full_canonical_path,
71            })
72        })
73}
74
75/// On Windows, rewrites the platform separator (`\`) to `/` so that lookup keys
76/// are slash-separated on every platform. On other platforms the name is left
77/// untouched, so a file whose name literally contains a backslash is rejected
78/// by [`is_valid_key`] rather than shadowing a nested file.
79#[cfg(windows)]
80fn normalize_rel_path(path: &str) -> Cow<'_, str> {
81    if path.contains('\\') {
82        Cow::Owned(path.replace('\\', "/"))
83    } else {
84        Cow::Borrowed(path)
85    }
86}
87
88#[cfg(not(windows))]
89fn normalize_rel_path(path: &str) -> Cow<'_, str> {
90    Cow::Borrowed(path)
91}
92
93/// Converts a path relative to the folder root into a lookup key, or `None`
94/// when the path cannot be used as one.
95///
96/// Names that are not valid UTF-8 and names rejected by [`is_valid_key`] (for
97/// example those containing a backslash on non-Windows platforms) yield `None`,
98/// and callers skip them.
99fn rel_path_key(rel_path: &Path) -> Option<Cow<'_, str>> {
100    let rel_path = rel_path.to_str()?;
101    let rel_path = normalize_rel_path(rel_path);
102    if !is_valid_key(&rel_path) {
103        return None;
104    }
105    Some(rel_path)
106}
107
108/// Returns `true` when `key` is a valid relative path that may be passed to
109/// `get`.
110///
111/// Keys use `/` as the separator on every platform. A valid key is non-empty,
112/// relative, contains no NUL byte, no backslash and no empty, `.` or `..`
113/// segment. In other words it matches exactly the kind of path returned by
114/// `iter`, and any other input is rejected instead of being normalized or
115/// resolved against the file system.
116#[doc(hidden)]
117pub fn is_valid_key(key: &str) -> bool {
118    if key.is_empty() || key.contains('\\') || key.contains('\0') || Path::new(key).is_absolute() {
119        return false;
120    }
121
122    key.split('/')
123        .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
124}
125
126/// A file embedded into the binary
127#[derive(Clone)]
128pub struct EmbeddedFile {
129    pub data: Cow<'static, [u8]>,
130    pub metadata: Metadata,
131}
132
133impl fmt::Debug for EmbeddedFile {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        f.debug_struct("EmbeddedFile")
136            .field("data", &format_args!("{} bytes", self.data.len()))
137            .field("metadata", &self.metadata)
138            .finish()
139    }
140}
141
142/// Metadata about an embedded file
143#[derive(Clone)]
144pub struct Metadata {
145    /// the BLAKE3 hash of the file
146    hash: [u8; 32],
147    last_modified: Option<u64>,
148    created: Option<u64>,
149    #[cfg(feature = "guess-media-type")]
150    mimetype: Cow<'static, str>,
151}
152
153impl fmt::Debug for Metadata {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        let mut debug = f.debug_struct("Metadata");
156        debug.field("hash", &Hex(&self.hash));
157        debug.field("last_modified", &self.last_modified);
158        debug.field("created", &self.created);
159        #[cfg(feature = "guess-media-type")]
160        debug.field("mimetype", &self.mimetype);
161        debug.finish()
162    }
163}
164
165/// Renders bytes as lowercase hexadecimal, used by `Metadata`'s [`fmt::Debug`].
166struct Hex<'a>(&'a [u8]);
167
168impl fmt::Debug for Hex<'_> {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        for byte in self.0 {
171            write!(f, "{byte:02x}")?;
172        }
173        Ok(())
174    }
175}
176
177impl Metadata {
178    #[doc(hidden)]
179    pub const fn __embed_new(
180        hash: [u8; 32],
181        last_modified: Option<u64>,
182        created: Option<u64>,
183        #[cfg(feature = "guess-media-type")] mimetype: &'static str,
184    ) -> Self {
185        Self {
186            hash,
187            last_modified,
188            created,
189            #[cfg(feature = "guess-media-type")]
190            mimetype: Cow::Borrowed(mimetype),
191        }
192    }
193
194    /// The BLAKE3 hash of the file
195    pub fn hash(&self) -> [u8; 32] {
196        self.hash
197    }
198
199    /// The last modified date in seconds since the UNIX epoch. If the
200    /// underlying platform/file-system does not support this, `None` is
201    /// returned. When the `SOURCE_DATE_EPOCH` environment variable was set when
202    /// the file was first read, its value is returned instead, for reproducible
203    /// builds.
204    pub fn last_modified(&self) -> Option<u64> {
205        self.last_modified
206    }
207
208    /// The created date in seconds since the UNIX epoch. If the underlying
209    /// platform/file-system does not support this, `None` is returned. When the
210    /// `SOURCE_DATE_EPOCH` environment variable was set when the file was first
211    /// read, its value is returned instead, for reproducible builds.
212    pub fn created(&self) -> Option<u64> {
213        self.created
214    }
215
216    /// The mime type of the file
217    #[cfg(feature = "guess-media-type")]
218    pub fn mimetype(&self) -> &str {
219        &self.mimetype
220    }
221}
222
223/// Builds a [`Metadata`] from the tokens emitted by the derive macro.
224///
225/// The derive macro is compiled for the host and cannot reliably branch on its
226/// own features, so it always passes a mimetype. Whether the mimetype is kept
227/// is decided here, by the crate compiled for the target.
228#[cfg(feature = "guess-media-type")]
229#[doc(hidden)]
230#[macro_export]
231macro_rules! __embed_metadata {
232    ($hash:expr, $last_modified:expr, $created:expr, $mimetype:expr $(,)?) => {
233        $crate::Metadata::__embed_new($hash, $last_modified, $created, $mimetype)
234    };
235}
236
237/// See the `guess-media-type` variant above; this one drops the mimetype.
238#[cfg(not(feature = "guess-media-type"))]
239#[doc(hidden)]
240#[macro_export]
241macro_rules! __embed_metadata {
242    ($hash:expr, $last_modified:expr, $created:expr, $mimetype:expr $(,)?) => {
243        $crate::Metadata::__embed_new($hash, $last_modified, $created)
244    };
245}
246
247/// Guesses the media type of `path` at build time.
248///
249/// Always available, regardless of the `guess-media-type` feature, so that the
250/// derive macro can emit a mimetype unconditionally; the value is only used
251/// when the target enables the feature. Returns an empty string otherwise.
252#[doc(hidden)]
253pub fn mimetype_of(_path: &Path) -> String {
254    #[cfg(feature = "guess-media-type")]
255    {
256        media_type::from_path(_path).first_or_octet_stream().to_string()
257    }
258    #[cfg(not(feature = "guess-media-type"))]
259    {
260        String::new()
261    }
262}
263
264/// The `SOURCE_DATE_EPOCH` value, read at most once per process.
265///
266/// Used to override `last_modified` and `created` for reproducible builds.
267fn source_date_epoch() -> Option<u64> {
268    static SOURCE_DATE_EPOCH: OnceLock<Option<u64>> = OnceLock::new();
269    *SOURCE_DATE_EPOCH.get_or_init(|| {
270        std::env::var("SOURCE_DATE_EPOCH")
271            .ok()
272            .and_then(|value| parse_epoch(&value))
273    })
274}
275
276/// Parses the `SOURCE_DATE_EPOCH` environment variable value.
277fn parse_epoch(value: &str) -> Option<u64> {
278    value.parse::<u64>().ok()
279}
280
281/// A cached hash, valid only while the file length and modification time are
282/// unchanged.
283#[derive(Clone, Copy)]
284struct CachedHash {
285    len: u64,
286    modified: Option<SystemTime>,
287    hash: [u8; 32],
288}
289
290/// Caches file hashes across calls so that repeatedly serving a file does not
291/// re-hash its contents. Only used by the debug (file system) path.
292fn hash_cache() -> &'static RwLock<HashMap<PathBuf, CachedHash>> {
293    static HASH_CACHE: OnceLock<RwLock<HashMap<PathBuf, CachedHash>>> = OnceLock::new();
294    HASH_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
295}
296
297/// Reads a file from the file system and computes its metadata.
298///
299/// `file_path` is expected to be an absolute, canonical path inside the assets
300/// folder. The hash of the file is cached and only recomputed when the file's
301/// length or modification time changed. Returns an [`io::Error`] when the file
302/// cannot be opened or read.
303pub fn read_file_from_fs(file_path: &Path) -> io::Result<EmbeddedFile> {
304    let mut file = fs::File::open(file_path)?;
305    let metadata = file.metadata()?;
306
307    let mut data = Vec::with_capacity(metadata.len() as usize);
308    io::Read::read_to_end(&mut file, &mut data)?;
309    let data = Cow::from(data);
310
311    let len = metadata.len();
312    let modified = metadata.modified().ok();
313
314    let cached = hash_cache()
315        .read()
316        .ok()
317        .and_then(|cache| cache.get(file_path).copied())
318        .filter(|entry| entry.len == len && entry.modified == modified);
319
320    let hash = match cached {
321        Some(entry) => entry.hash,
322        None => {
323            let hash: [u8; 32] = Blake3::hash(&data)
324                .as_ref()
325                .try_into()
326                .expect("BLAKE3 always produces a 32 byte hash");
327            let cache = CachedHash {
328                len,
329                modified,
330                hash,
331            };
332            if let Ok(mut cache_map) = hash_cache().write() {
333                cache_map.insert(file_path.to_path_buf(), cache);
334            }
335            hash
336        }
337    };
338
339    let last_modified = modified
340        .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
341        .map(|secs| secs.as_secs());
342
343    let created = metadata
344        .created()
345        .ok()
346        .and_then(|created| created.duration_since(SystemTime::UNIX_EPOCH).ok())
347        .map(|secs| secs.as_secs());
348
349    #[cfg(feature = "guess-media-type")]
350    let mimetype = media_type::from_path(file_path).first_or_octet_stream().to_string();
351
352    let source_date_epoch = source_date_epoch();
353    Ok(EmbeddedFile {
354        data,
355        metadata: Metadata {
356            hash,
357            last_modified: source_date_epoch.or(last_modified),
358            created: source_date_epoch.or(created),
359            #[cfg(feature = "guess-media-type")]
360            mimetype: mimetype.into(),
361        },
362    })
363}
364
365#[derive(Clone)]
366pub struct PathMatcher {
367    #[cfg(feature = "include-exclude")]
368    include_matcher: globset::GlobSet,
369    #[cfg(feature = "include-exclude")]
370    exclude_matcher: globset::GlobSet,
371}
372
373#[cfg(feature = "include-exclude")]
374impl PathMatcher {
375    pub fn new(includes: &[&str], excludes: &[&str]) -> Self {
376        let mut include_matcher = globset::GlobSetBuilder::new();
377        for include in includes {
378            include_matcher
379                .add(globset::Glob::new(include).unwrap_or_else(|_| panic!("invalid include pattern '{}'", include)));
380        }
381        let include_matcher = include_matcher
382            .build()
383            .unwrap_or_else(|_| panic!("Could not compile included patterns matcher"));
384
385        let mut exclude_matcher = globset::GlobSetBuilder::new();
386        for exclude in excludes {
387            exclude_matcher
388                .add(globset::Glob::new(exclude).unwrap_or_else(|_| panic!("invalid exclude pattern '{}'", exclude)));
389        }
390        let exclude_matcher = exclude_matcher
391            .build()
392            .unwrap_or_else(|_| panic!("Could not compile excluded patterns matcher"));
393
394        Self {
395            include_matcher,
396            exclude_matcher,
397        }
398    }
399    pub fn is_path_included(&self, path: &str) -> bool {
400        !self.exclude_matcher.is_match(path) && (self.include_matcher.is_empty() || self.include_matcher.is_match(path))
401    }
402}
403
404#[cfg(not(feature = "include-exclude"))]
405impl PathMatcher {
406    pub fn new(_includes: &[&str], _excludes: &[&str]) -> Self {
407        Self {}
408    }
409    pub fn is_path_included(&self, _path: &str) -> bool {
410        true
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn valid_keys_are_accepted() {
420        for key in ["a", "a.txt", "a/b/c.txt", "a-b_c.1", "…/file.txt", "a b"] {
421            assert!(is_valid_key(key), "{key:?} should be valid");
422        }
423    }
424
425    #[test]
426    fn invalid_keys_are_rejected() {
427        for key in [
428            "", "/", "/abs", "a/", "a//b", "./a", "a/./b", "..", "../a", "a/..", "a\\b", "\\a", "a\0b",
429        ] {
430            assert!(!is_valid_key(key), "{key:?} should be invalid");
431        }
432
433        // A drive prefix is absolute on Windows and an ordinary name elsewhere.
434        #[cfg(windows)]
435        assert!(!is_valid_key("C:/x"));
436        #[cfg(not(windows))]
437        assert!(is_valid_key("C:/x"));
438    }
439
440    #[test]
441    fn source_date_epoch_is_parsed() {
442        assert_eq!(parse_epoch("0"), Some(0));
443        assert_eq!(parse_epoch("1717171717"), Some(1_717_171_717));
444        assert_eq!(parse_epoch("-1"), None);
445        assert_eq!(parse_epoch("not a number"), None);
446        assert_eq!(parse_epoch(""), None);
447    }
448
449    #[test]
450    fn hash_is_cached_until_the_file_changes() {
451        let dir = test_dir("hash_cache");
452        let path = dir.join("file.txt");
453        fs::write(&path, b"first").unwrap();
454
455        let first = read_file_from_fs(&path).unwrap();
456        let second = read_file_from_fs(&path).unwrap();
457        assert_eq!(first.metadata.hash(), second.metadata.hash());
458        assert_eq!(Blake3::hash(b"first").as_ref(), first.metadata.hash().as_slice());
459
460        // Changing the contents (and length) must invalidate the cache.
461        fs::write(&path, b"changed!").unwrap();
462        let third = read_file_from_fs(&path).unwrap();
463        assert_eq!(third.metadata.hash(), Blake3::hash(b"changed!").as_ref());
464
465        fs::remove_dir_all(&dir).unwrap();
466    }
467
468    fn walk(dir: &Path, matcher: &PathMatcher) -> Vec<String> {
469        let mut names: Vec<String> = get_files(dir, matcher).map(|entry| entry.rel_path).collect();
470        names.sort();
471        names
472    }
473
474    fn test_dir(name: &str) -> PathBuf {
475        // `env::temp_dir` is unsupported on wasm32-wasip1, where the preopened
476        // directory is the current directory. A UUID keeps the directory unique
477        // across parallel test processes without relying on the process id,
478        // which is also unsupported on wasm.
479        let root = if cfg!(target_arch = "wasm32") {
480            PathBuf::from(".")
481        } else {
482            std::env::temp_dir()
483        };
484        let dir = root.join(format!("embed_utils_{}_{}", name, uuid::Uuid::new_v4()));
485        let _ = fs::remove_dir_all(&dir);
486        fs::create_dir_all(&dir).unwrap();
487        dir
488    }
489
490    #[test]
491    fn get_files_lists_regular_files() {
492        let dir = test_dir("regular");
493        fs::create_dir_all(dir.join("sub")).unwrap();
494        fs::write(dir.join("a.txt"), b"a").unwrap();
495        fs::write(dir.join("sub/b.txt"), b"b").unwrap();
496
497        assert_eq!(walk(&dir, &PathMatcher::new(&[], &[])), ["a.txt", "sub/b.txt"]);
498
499        fs::remove_dir_all(&dir).unwrap();
500    }
501
502    #[test]
503    fn get_files_skips_non_utf8_names() {
504        let dir = test_dir("non_utf8");
505        fs::write(dir.join("ok.txt"), b"ok").unwrap();
506
507        #[cfg(unix)]
508        {
509            use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
510            let bad = dir.join(OsStr::from_bytes(b"bad_\xff\xfe.txt"));
511            // Some file systems (macOS APFS/HFS+) reject non-UTF-8 names with
512            // EILSEQ, so the entry may simply not exist to be skipped. The
513            // decision itself is covered by `non_utf8_names_are_rejected_as_keys`.
514            let _ = fs::write(&bad, b"bad");
515        }
516
517        assert_eq!(walk(&dir, &PathMatcher::new(&[], &[])), ["ok.txt"]);
518        fs::remove_dir_all(&dir).unwrap();
519    }
520
521    #[cfg(unix)]
522    #[test]
523    fn non_utf8_names_are_rejected_as_keys() {
524        use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
525
526        assert_eq!(rel_path_key(Path::new(OsStr::from_bytes(b"bad_\xff\xfe.txt"))), None);
527        assert_eq!(rel_path_key(Path::new("good.txt")).as_deref(), Some("good.txt"));
528    }
529
530    #[cfg(unix)]
531    #[test]
532    fn get_files_skips_backslash_names_including_symlinks() {
533        use std::os::unix::fs::symlink;
534
535        let dir = test_dir("backslash");
536        fs::write(dir.join("ok.txt"), b"ok").unwrap();
537        fs::write(dir.join("target.txt"), b"target").unwrap();
538        fs::write(dir.join("back\\slash.txt"), b"weird").unwrap();
539        symlink(dir.join("target.txt"), dir.join("link\\name.txt")).unwrap();
540
541        assert_eq!(walk(&dir, &PathMatcher::new(&[], &[])), ["ok.txt", "target.txt"]);
542        fs::remove_dir_all(&dir).unwrap();
543    }
544
545    #[cfg(unix)]
546    #[test]
547    fn get_files_keeps_in_folder_symlinks_and_drops_escaping_ones() {
548        use std::os::unix::fs::symlink;
549
550        let parent = test_dir("symlinks");
551        let dir = parent.join("assets");
552        let external = parent.join("external");
553        fs::create_dir_all(&dir).unwrap();
554        fs::create_dir_all(&external).unwrap();
555        fs::write(dir.join("inside.txt"), b"inside").unwrap();
556        fs::write(external.join("outside.txt"), b"outside").unwrap();
557
558        // Target inside the folder: kept, with the canonical path as target.
559        symlink(dir.join("inside.txt"), dir.join("inside_link.txt")).unwrap();
560        // Target outside the folder: dropped.
561        symlink(external.join("outside.txt"), dir.join("outside_link.txt")).unwrap();
562        // Directory symlink whose target is outside: dropped with its contents.
563        symlink(&external, dir.join("outside_dir")).unwrap();
564        // Broken symlink: dropped.
565        symlink(external.join("missing.txt"), dir.join("broken.txt")).unwrap();
566
567        let entries: Vec<FileEntry> = get_files(&dir, &PathMatcher::new(&[], &[])).collect();
568        let mut names: Vec<&str> = entries.iter().map(|entry| entry.rel_path.as_str()).collect();
569        names.sort();
570        assert_eq!(names, ["inside.txt", "inside_link.txt"]);
571
572        let link = entries
573            .iter()
574            .find(|entry| entry.rel_path == "inside_link.txt")
575            .unwrap();
576        assert_eq!(link.full_canonical_path, dir.join("inside.txt").canonicalize().unwrap());
577
578        fs::remove_dir_all(&parent).unwrap();
579    }
580}