1#![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#[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#[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 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#[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
93fn 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#[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#[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#[derive(Clone)]
144pub struct Metadata {
145 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
165struct 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 pub fn hash(&self) -> [u8; 32] {
196 self.hash
197 }
198
199 pub fn last_modified(&self) -> Option<u64> {
205 self.last_modified
206 }
207
208 pub fn created(&self) -> Option<u64> {
213 self.created
214 }
215
216 #[cfg(feature = "guess-media-type")]
218 pub fn mimetype(&self) -> &str {
219 &self.mimetype
220 }
221}
222
223#[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#[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#[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
264fn 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
276fn parse_epoch(value: &str) -> Option<u64> {
278 value.parse::<u64>().ok()
279}
280
281#[derive(Clone, Copy)]
284struct CachedHash {
285 len: u64,
286 modified: Option<SystemTime>,
287 hash: [u8; 32],
288}
289
290fn 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
297pub 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 #[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 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 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 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 symlink(dir.join("inside.txt"), dir.join("inside_link.txt")).unwrap();
560 symlink(external.join("outside.txt"), dir.join("outside_link.txt")).unwrap();
562 symlink(&external, dir.join("outside_dir")).unwrap();
564 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}