1use crate::{
8 error::{Error, Result, io},
9 hash::{HashingReader, ensure_matches_plan},
10 plan::{BundlePlan, PLAN_ENTRIES_MAX, PlannedFile, PlannedFileKind},
11};
12use std::{
13 os::unix::fs::PermissionsExt,
14 path::{Path, PathBuf},
15};
16
17fn source_date_epoch() -> Result<Option<std::time::SystemTime>> {
19 let Some(seconds) = configured_source_date_epoch_secs()? else {
20 return Ok(None);
21 };
22 std::time::UNIX_EPOCH
23 .checked_add(std::time::Duration::from_secs(seconds))
24 .map(Some)
25 .ok_or_else(|| Error::Config {
26 message: "SOURCE_DATE_EPOCH is outside the supported system-time range".to_string(),
27 })
28}
29
30pub(crate) fn source_date_epoch_secs() -> Result<u64> {
31 Ok(configured_source_date_epoch_secs()?.unwrap_or(0))
32}
33
34fn configured_source_date_epoch_secs() -> Result<Option<u64>> {
35 match std::env::var("SOURCE_DATE_EPOCH") {
36 Ok(value) => value
37 .trim()
38 .parse::<u64>()
39 .map(Some)
40 .map_err(|_| Error::Config {
41 message: format!(
42 "invalid SOURCE_DATE_EPOCH `{value}` (expected an unsigned integer)"
43 ),
44 }),
45 Err(std::env::VarError::NotPresent) => Ok(None),
46 Err(std::env::VarError::NotUnicode(_)) => Err(Error::Config {
47 message: "SOURCE_DATE_EPOCH is not valid Unicode".to_string(),
48 }),
49 }
50}
51
52#[derive(Debug)]
53pub struct RootFsBuilder {
54 output: PathBuf,
55 clean: bool,
56}
57
58impl RootFsBuilder {
59 pub fn new(output: impl Into<PathBuf>) -> RootFsBuilder {
60 RootFsBuilder {
61 output: output.into(),
62 clean: false,
63 }
64 }
65
66 pub fn clean(mut self, clean: bool) -> RootFsBuilder {
68 self.clean = clean;
69 self
70 }
71
72 pub fn apply(&self, plan: &BundlePlan) -> Result<RootFsReport> {
76 let timestamp = source_date_epoch()?.unwrap_or_else(std::time::SystemTime::now);
79 guard_output(&self.output)?;
80 let parent = output_parent(&self.output);
81 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
82
83 let stage = tempfile::Builder::new()
84 .prefix(".elfpak-rootfs-")
85 .permissions(std::fs::Permissions::from_mode(STAGE_MODE))
86 .tempdir_in(parent)
87 .map_err(|e| io(parent, e))?;
88
89 if path_exists(&self.output) {
90 ensure_directory(&self.output)?;
91 }
92 if path_exists(&self.output) && !self.clean {
93 clone_tree(&self.output, stage.path())?;
94 } else {
95 set_mode(stage.path(), 0o755)?;
99 }
100 if self.clean && path_exists(&self.output) {
101 guard_clean(&self.output)?;
102 }
103
104 let report = self.apply_into(plan, stage.path(), timestamp)?;
105 publish_directory(stage, &self.output)?;
106 Ok(report)
107 }
108
109 fn apply_into(
111 &self,
112 plan: &BundlePlan,
113 output: &Path,
114 timestamp: std::time::SystemTime,
115 ) -> Result<RootFsReport> {
116 let output = output.canonicalize().map_err(|e| io(output, e))?;
117
118 let mut report = RootFsReport::default();
119 for file in &plan.files {
121 file.assert_well_formed();
122 let target = self.target_path(&output, file)?;
123 assert!(target.starts_with(&output));
124
125 match file.kind {
126 PlannedFileKind::Directory => {
127 write_directory(&target, file.mode)?;
128 report.directories += 1;
129 }
130 PlannedFileKind::Symlink => {
131 write_symlink(&target, file.link_target.as_deref())?;
132 report.symlinks += 1;
133 }
134 _ => {
135 remove_existing(&target)?;
138 report.bytes += write_file(&target, file)?;
139 set_mode(&target, file.mode)?;
140 pin_times(&target, timestamp);
141 report.files += 1;
142 }
143 }
144 }
145
146 for file in plan
149 .files
150 .iter()
151 .rev()
152 .filter(|f| f.kind == PlannedFileKind::Directory)
153 {
154 pin_times(
155 &crate::paths::join_under(&output, &file.destination),
156 timestamp,
157 );
158 }
159
160 let entries = report.files + report.directories + report.symlinks;
161 assert_eq!(entries as usize, plan.files.len());
162 Ok(report)
163 }
164
165 fn target_path(&self, output: &Path, file: &PlannedFile) -> Result<PathBuf> {
168 assert!(file.destination.is_absolute());
169
170 let target = crate::paths::join_under(output, &file.destination);
171 if !target.starts_with(output) {
172 return Err(Error::PathEscape {
173 path: file.destination.clone(),
174 kind: "output",
175 });
176 }
177 if let Some(parent) = target.parent() {
178 if crate::paths::has_symlinked_ancestor(output, parent) {
179 return Err(Error::PathEscape {
180 path: file.destination.clone(),
181 kind: "output",
182 });
183 }
184 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
185 }
186 Ok(target)
187 }
188}
189
190pub(crate) fn output_parent(output: &Path) -> &Path {
193 output
194 .parent()
195 .filter(|parent| !parent.as_os_str().is_empty())
196 .unwrap_or_else(|| Path::new("."))
197}
198
199pub(crate) fn path_exists(path: &Path) -> bool {
200 std::fs::symlink_metadata(path).is_ok()
201}
202
203pub(crate) fn ensure_directory(path: &Path) -> Result<()> {
204 let metadata = std::fs::symlink_metadata(path).map_err(|e| io(path, e))?;
205 if metadata.is_symlink() {
206 return Err(Error::Config {
207 message: format!("output `{}` must not be a symlink", path.display()),
208 });
209 }
210 if metadata.is_dir() {
211 return Ok(());
212 }
213 Err(Error::Config {
214 message: format!("output `{}` is not a directory", path.display()),
215 })
216}
217
218fn clone_tree(source: &Path, destination: &Path) -> Result<()> {
223 clone_tree_with_limit(source, destination, PLAN_ENTRIES_MAX)
224}
225
226fn clone_tree_with_limit(source: &Path, destination: &Path, limit: usize) -> Result<()> {
227 let mut stack = vec![(source.to_path_buf(), destination.to_path_buf())];
228 let mut directories = Vec::new();
229 let mut entries = 0usize;
230
231 while let Some((source_dir, destination_dir)) = stack.pop() {
232 let source_metadata = std::fs::metadata(&source_dir).map_err(|e| io(&source_dir, e))?;
233 directories.push((destination_dir.clone(), source_metadata));
234
235 for entry in std::fs::read_dir(&source_dir).map_err(|e| io(&source_dir, e))? {
236 if entries == limit {
237 return Err(Error::LimitExceeded {
238 resource: "existing output tree",
239 limit,
240 });
241 }
242 entries += 1;
243 let entry = entry.map_err(|e| io(&source_dir, e))?;
244 let source_path = entry.path();
245 let destination_path = destination_dir.join(entry.file_name());
246 let metadata =
247 std::fs::symlink_metadata(&source_path).map_err(|e| io(&source_path, e))?;
248
249 if metadata.is_symlink() {
250 let target = std::fs::read_link(&source_path).map_err(|e| io(&source_path, e))?;
251 std::os::unix::fs::symlink(target, &destination_path)
252 .map_err(|e| io(&destination_path, e))?;
253 } else if metadata.is_dir() {
254 std::fs::create_dir(&destination_path).map_err(|e| io(&destination_path, e))?;
255 stack.push((source_path, destination_path));
256 } else if metadata.is_file() {
257 std::fs::copy(&source_path, &destination_path)
258 .map_err(|e| io(&destination_path, e))?;
259 set_permissions_from(&destination_path, &metadata)?;
260 } else {
261 return Err(Error::Config {
262 message: format!(
263 "existing output contains unsupported entry `{}`",
264 source_path.display()
265 ),
266 });
267 }
268 }
269 }
270
271 for (path, metadata) in directories.into_iter().rev() {
274 set_permissions_from(&path, &metadata)?;
275 set_times_from(&path, &metadata);
276 }
277 Ok(())
278}
279
280pub(crate) fn publish_directory(stage: tempfile::TempDir, output: &Path) -> Result<()> {
284 if path_exists(output) {
289 return publish_by_exchange(stage, output);
290 }
291
292 use rustix::fs::{CWD, RenameFlags, renameat_with};
293
294 let publish = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::NOREPLACE);
298 finish_noreplace(stage, output, publish)
299}
300
301fn finish_noreplace(
302 stage: tempfile::TempDir,
303 output: &Path,
304 publish: std::result::Result<(), rustix::io::Errno>,
305) -> Result<()> {
306 if let Err(error) = publish {
307 if matches!(
308 error,
309 rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
310 ) {
311 return publish_new_directory_legacy(stage, output);
312 }
313 return Err(io(output, error.into()));
314 }
315 Ok(())
316}
317
318fn publish_new_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
321 std::fs::create_dir(output).map_err(|error| io(output, error))?;
322 if let Err(error) = std::fs::rename(stage.path(), output) {
323 let _ = std::fs::remove_dir(output);
326 return Err(io(output, error));
327 }
328 Ok(())
329}
330
331fn publish_by_exchange(stage: tempfile::TempDir, output: &Path) -> Result<()> {
332 use rustix::fs::{CWD, RenameFlags, renameat_with};
333
334 let exchange = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::EXCHANGE);
335 finish_exchange(stage, output, exchange)
336}
337
338fn finish_exchange(
339 stage: tempfile::TempDir,
340 output: &Path,
341 exchange: std::result::Result<(), rustix::io::Errno>,
342) -> Result<()> {
343 if let Err(error) = exchange {
344 if matches!(
345 error,
346 rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
347 ) {
348 return publish_directory_legacy(stage, output);
349 }
350 return Err(io(output, error.into()));
351 }
352
353 let old_path = stage.path().to_path_buf();
354 stage.close().map_err(|e| io(&old_path, e))
355}
356
357fn publish_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
360 let backup = if path_exists(output) {
361 let reservation = tempfile::Builder::new()
362 .prefix(".elfpak-backup-")
363 .tempdir_in(output_parent(output))
364 .map_err(|e| io(output, e))?;
365 let path = reservation.path().to_path_buf();
366 reservation.close().map_err(|e| io(&path, e))?;
367 std::fs::rename(output, &path).map_err(|e| io(output, e))?;
368 Some(path)
369 } else {
370 None
371 };
372
373 if let Err(error) = std::fs::rename(stage.path(), output) {
374 if let Some(backup) = &backup
375 && let Err(rollback) = std::fs::rename(backup, output)
376 {
377 return Err(Error::Config {
378 message: format!(
379 "failed to publish `{}` ({error}) and failed to restore its backup `{}` ({rollback})",
380 output.display(),
381 backup.display()
382 ),
383 });
384 }
385 return Err(io(output, error));
386 }
387
388 if let Some(backup) = backup {
389 remove_existing(&backup)?;
390 }
391 Ok(())
392}
393
394fn write_directory(target: &Path, mode: u32) -> Result<()> {
397 let existing = std::fs::symlink_metadata(target).ok();
398 if !existing.is_some_and(|metadata| metadata.is_dir()) {
399 remove_existing(target)?;
400 std::fs::create_dir_all(target).map_err(|e| io(target, e))?;
401 }
402 set_mode(target, mode)
403}
404
405fn write_symlink(target: &Path, link_target: Option<&Path>) -> Result<()> {
407 let link_target = link_target.expect("validated symlinks have a target");
408 remove_existing(target)?;
409 std::os::unix::fs::symlink(link_target, target).map_err(|e| io(target, e))
410}
411
412fn write_file(target: &Path, file: &PlannedFile) -> Result<u64> {
417 match (&file.content, &file.source) {
418 (Some(content), None) => {
419 assert_eq!(content.len() as u64, file.size);
420 std::fs::write(target, content).map_err(|e| io(target, e))?;
421 Ok(content.len() as u64)
422 }
423 (None, Some(source)) => {
424 let input = std::fs::File::open(source).map_err(|e| io(source, e))?;
425 let mut input = HashingReader::new(std::io::BufReader::new(input));
426 let mut output = std::fs::File::create(target).map_err(|e| io(target, e))?;
427 let copy_result = std::io::copy(&mut input, &mut output).map_err(|e| io(source, e));
428 drop(output);
429 let (digest, size) = input.finish();
430
431 if let Err(error) = copy_result {
432 let _ = remove_existing(target);
433 return Err(error);
434 }
435 let expected = file
436 .sha256
437 .as_ref()
438 .expect("validated regular files have a digest");
439 if let Err(error) = ensure_matches_plan(source, expected, file.size, digest, size) {
440 let _ = remove_existing(target);
441 return Err(error);
442 }
443 Ok(size)
444 }
445 _ => unreachable!("validated regular files have exactly one content source"),
446 }
447}
448
449pub(crate) fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
453 let permissions = std::fs::metadata(destination)
454 .map(|metadata| metadata.permissions())
455 .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
456 std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
457}
458
459pub(crate) const STAGE_MODE: u32 = 0o700;
466
467pub(crate) fn guard_output(output: &Path) -> Result<()> {
471 let absolute = std::path::absolute(output).map_err(|e| io(output, e))?;
475 let lexical = crate::paths::normalize_absolute(&absolute);
476 let resolved = output.canonicalize().unwrap_or(lexical);
477 if resolved.parent().is_none() {
478 return Err(Error::Config {
479 message: format!(
480 "refusing to materialize a bundle at filesystem root `{}`",
481 resolved.display()
482 ),
483 });
484 }
485 Ok(())
486}
487
488fn guard_clean(output: &Path) -> Result<()> {
491 let resolved = output
492 .canonicalize()
493 .unwrap_or_else(|_| output.to_path_buf());
494 if resolved.parent().is_none() {
495 return Err(Error::Config {
496 message: format!("refusing to --clean `{}`", resolved.display()),
497 });
498 }
499 Ok(())
500}
501
502fn remove_existing(path: &Path) -> Result<()> {
504 match std::fs::symlink_metadata(path) {
505 Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path).map_err(|e| io(path, e)),
508 Ok(_) => std::fs::remove_file(path).map_err(|e| io(path, e)),
509 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
510 Err(e) => Err(io(path, e)),
511 }
512}
513
514#[derive(Debug, Default, Clone, Copy)]
516pub struct RootFsReport {
517 pub files: u32,
518 pub directories: u32,
519 pub symlinks: u32,
520 pub bytes: u64,
521}
522
523fn set_mode(path: &Path, mode: u32) -> Result<()> {
524 use std::os::unix::fs::PermissionsExt;
525
526 assert!(mode <= 0o7777);
527 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| io(path, e))
528}
529
530fn set_permissions_from(path: &Path, metadata: &std::fs::Metadata) -> Result<()> {
531 std::fs::set_permissions(path, metadata.permissions()).map_err(|e| io(path, e))
532}
533
534fn set_times_from(path: &Path, metadata: &std::fs::Metadata) {
535 let (Ok(accessed), Ok(modified), Ok(file)) = (
536 metadata.accessed(),
537 metadata.modified(),
538 std::fs::File::open(path),
539 ) else {
540 return;
541 };
542 let _ = file.set_times(
543 std::fs::FileTimes::new()
544 .set_accessed(accessed)
545 .set_modified(modified),
546 );
547}
548
549fn pin_times(path: &Path, time: std::time::SystemTime) {
553 let Ok(file) = std::fs::File::open(path) else {
554 return;
555 };
556 let _ = file.set_times(
557 std::fs::FileTimes::new()
558 .set_accessed(time)
559 .set_modified(time),
560 );
561}
562
563#[cfg(test)]
564mod tests {
565 use super::{
566 clone_tree_with_limit, ensure_directory, finish_exchange, finish_noreplace, guard_clean,
567 guard_output, remove_existing,
568 };
569 use crate::paths::has_symlinked_ancestor;
570 use std::path::Path;
571
572 #[test]
573 fn detects_a_symlink_above_a_missing_parent() {
574 let temp = tempfile::tempdir().unwrap();
575 let output = temp.path().join("output");
576 let outside = temp.path().join("outside");
577 std::fs::create_dir_all(&output).unwrap();
578 std::fs::create_dir(&outside).unwrap();
579 std::os::unix::fs::symlink(&outside, output.join("nested")).unwrap();
580
581 assert!(has_symlinked_ancestor(
582 &output,
583 &output.join("nested/deeper")
584 ));
585 }
586
587 #[test]
588 fn removing_a_symlink_leaves_its_target_alone() {
589 let temp = tempfile::tempdir().unwrap();
590 let target = temp.path().join("target");
591 let link = temp.path().join("link");
592 std::fs::write(&target, b"keep me").unwrap();
593 std::os::unix::fs::symlink(&target, &link).unwrap();
594
595 remove_existing(&link).unwrap();
596 assert!(!link.exists());
597 assert_eq!(std::fs::read(&target).unwrap(), b"keep me");
598
599 remove_existing(&link).unwrap();
601 }
602
603 #[test]
604 fn clean_refuses_to_delete_a_filesystem_root() {
605 let err = guard_clean(Path::new("/")).unwrap_err();
606 assert_eq!(err.code(), "E4001");
607 let temp = tempfile::tempdir().unwrap();
608 guard_clean(&temp.path().join("rootfs")).unwrap();
609 }
610
611 #[test]
612 fn materialization_refuses_a_filesystem_root() {
613 let err = guard_output(Path::new("/")).unwrap_err();
614 assert_eq!(err.code(), "E4001");
615 let err = guard_output(Path::new("/new-rootfs/..")).unwrap_err();
616 assert_eq!(err.code(), "E4001");
617 let temp = tempfile::tempdir().unwrap();
618 guard_output(&temp.path().join("rootfs")).unwrap();
619 }
620
621 #[test]
622 fn an_output_root_symlink_is_rejected() {
623 let temp = tempfile::tempdir().unwrap();
624 let target = temp.path().join("target");
625 let output = temp.path().join("output");
626 std::fs::create_dir(&target).unwrap();
627 std::os::unix::fs::symlink(&target, &output).unwrap();
628
629 assert!(ensure_directory(&output).is_err());
630 }
631
632 #[test]
633 fn cloning_an_existing_output_stops_at_its_entry_limit() {
634 let temp = tempfile::tempdir().unwrap();
635 let source = temp.path().join("source");
636 let destination = temp.path().join("destination");
637 std::fs::create_dir(&source).unwrap();
638 std::fs::create_dir(&destination).unwrap();
639 std::fs::write(source.join("one"), b"one").unwrap();
640 std::fs::write(source.join("two"), b"two").unwrap();
641
642 let error = clone_tree_with_limit(&source, &destination, 1).unwrap_err();
643 assert!(matches!(
644 error,
645 crate::Error::LimitExceeded {
646 resource: "existing output tree",
647 limit: 1,
648 }
649 ));
650 }
651
652 #[test]
653 fn unsupported_atomic_exchange_falls_back_to_portable_publication() {
654 let temp = tempfile::tempdir().unwrap();
655 let output = temp.path().join("output");
656 std::fs::create_dir(&output).unwrap();
657 std::fs::write(output.join("old"), b"old").unwrap();
658
659 let stage = tempfile::Builder::new()
660 .prefix(".elfpak-rootfs-")
661 .tempdir_in(temp.path())
662 .unwrap();
663 std::fs::write(stage.path().join("new"), b"new").unwrap();
664
665 finish_exchange(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
666
667 assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
668 assert!(!output.join("old").exists());
669 }
670
671 #[test]
672 fn unsupported_noreplace_falls_back_to_portable_publication() {
673 let temp = tempfile::tempdir().unwrap();
674 let output = temp.path().join("output");
675 let stage = tempfile::Builder::new()
676 .prefix(".elfpak-rootfs-")
677 .tempdir_in(temp.path())
678 .unwrap();
679 std::fs::write(stage.path().join("new"), b"new").unwrap();
680
681 finish_noreplace(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
682
683 assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
684 }
685
686 #[test]
687 fn unrelated_exchange_errors_leave_the_existing_output_untouched() {
688 let temp = tempfile::tempdir().unwrap();
689 let output = temp.path().join("output");
690 std::fs::create_dir(&output).unwrap();
691 std::fs::write(output.join("old"), b"old").unwrap();
692
693 let stage = tempfile::Builder::new()
694 .prefix(".elfpak-rootfs-")
695 .tempdir_in(temp.path())
696 .unwrap();
697 std::fs::write(stage.path().join("new"), b"new").unwrap();
698
699 assert!(finish_exchange(stage, &output, Err(rustix::io::Errno::PERM)).is_err());
700
701 assert_eq!(std::fs::read(output.join("old")).unwrap(), b"old");
702 assert!(!output.join("new").exists());
703 }
704}