1use crate::{
8 error::{Error, Result, io},
9 hash::{HashingReader, ensure_matches_plan},
10 plan::{BundlePlan, 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 let mut stack = vec![(source.to_path_buf(), destination.to_path_buf())];
224 let mut directories = Vec::new();
225
226 while let Some((source_dir, destination_dir)) = stack.pop() {
227 let source_metadata = std::fs::metadata(&source_dir).map_err(|e| io(&source_dir, e))?;
228 directories.push((destination_dir.clone(), source_metadata));
229
230 for entry in std::fs::read_dir(&source_dir).map_err(|e| io(&source_dir, e))? {
231 let entry = entry.map_err(|e| io(&source_dir, e))?;
232 let source_path = entry.path();
233 let destination_path = destination_dir.join(entry.file_name());
234 let metadata =
235 std::fs::symlink_metadata(&source_path).map_err(|e| io(&source_path, e))?;
236
237 if metadata.is_symlink() {
238 let target = std::fs::read_link(&source_path).map_err(|e| io(&source_path, e))?;
239 std::os::unix::fs::symlink(target, &destination_path)
240 .map_err(|e| io(&destination_path, e))?;
241 } else if metadata.is_dir() {
242 std::fs::create_dir(&destination_path).map_err(|e| io(&destination_path, e))?;
243 stack.push((source_path, destination_path));
244 } else if metadata.is_file() {
245 std::fs::copy(&source_path, &destination_path)
246 .map_err(|e| io(&destination_path, e))?;
247 set_permissions_from(&destination_path, &metadata)?;
248 } else {
249 return Err(Error::Config {
250 message: format!(
251 "existing output contains unsupported entry `{}`",
252 source_path.display()
253 ),
254 });
255 }
256 }
257 }
258
259 for (path, metadata) in directories.into_iter().rev() {
262 set_permissions_from(&path, &metadata)?;
263 set_times_from(&path, &metadata);
264 }
265 Ok(())
266}
267
268pub(crate) fn publish_directory(stage: tempfile::TempDir, output: &Path) -> Result<()> {
272 if path_exists(output) {
277 return publish_by_exchange(stage, output);
278 }
279
280 use rustix::fs::{CWD, RenameFlags, renameat_with};
281
282 let publish = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::NOREPLACE);
286 finish_noreplace(stage, output, publish)
287}
288
289fn finish_noreplace(
290 stage: tempfile::TempDir,
291 output: &Path,
292 publish: std::result::Result<(), rustix::io::Errno>,
293) -> Result<()> {
294 if let Err(error) = publish {
295 if matches!(
296 error,
297 rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
298 ) {
299 return publish_new_directory_legacy(stage, output);
300 }
301 return Err(io(output, error.into()));
302 }
303 Ok(())
304}
305
306fn publish_new_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
309 std::fs::create_dir(output).map_err(|error| io(output, error))?;
310 if let Err(error) = std::fs::rename(stage.path(), output) {
311 let _ = std::fs::remove_dir(output);
314 return Err(io(output, error));
315 }
316 Ok(())
317}
318
319fn publish_by_exchange(stage: tempfile::TempDir, output: &Path) -> Result<()> {
320 use rustix::fs::{CWD, RenameFlags, renameat_with};
321
322 let exchange = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::EXCHANGE);
323 finish_exchange(stage, output, exchange)
324}
325
326fn finish_exchange(
327 stage: tempfile::TempDir,
328 output: &Path,
329 exchange: std::result::Result<(), rustix::io::Errno>,
330) -> Result<()> {
331 if let Err(error) = exchange {
332 if matches!(
333 error,
334 rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
335 ) {
336 return publish_directory_legacy(stage, output);
337 }
338 return Err(io(output, error.into()));
339 }
340
341 let old_path = stage.path().to_path_buf();
342 stage.close().map_err(|e| io(&old_path, e))
343}
344
345fn publish_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
348 let backup = if path_exists(output) {
349 let reservation = tempfile::Builder::new()
350 .prefix(".elfpak-backup-")
351 .tempdir_in(output_parent(output))
352 .map_err(|e| io(output, e))?;
353 let path = reservation.path().to_path_buf();
354 reservation.close().map_err(|e| io(&path, e))?;
355 std::fs::rename(output, &path).map_err(|e| io(output, e))?;
356 Some(path)
357 } else {
358 None
359 };
360
361 if let Err(error) = std::fs::rename(stage.path(), output) {
362 if let Some(backup) = &backup
363 && let Err(rollback) = std::fs::rename(backup, output)
364 {
365 return Err(Error::Config {
366 message: format!(
367 "failed to publish `{}` ({error}) and failed to restore its backup `{}` ({rollback})",
368 output.display(),
369 backup.display()
370 ),
371 });
372 }
373 return Err(io(output, error));
374 }
375
376 if let Some(backup) = backup {
377 remove_existing(&backup)?;
378 }
379 Ok(())
380}
381
382fn write_directory(target: &Path, mode: u32) -> Result<()> {
385 let existing = std::fs::symlink_metadata(target).ok();
386 if !existing.is_some_and(|metadata| metadata.is_dir()) {
387 remove_existing(target)?;
388 std::fs::create_dir_all(target).map_err(|e| io(target, e))?;
389 }
390 set_mode(target, mode)
391}
392
393fn write_symlink(target: &Path, link_target: Option<&Path>) -> Result<()> {
395 let link_target = link_target.expect("validated symlinks have a target");
396 remove_existing(target)?;
397 std::os::unix::fs::symlink(link_target, target).map_err(|e| io(target, e))
398}
399
400fn write_file(target: &Path, file: &PlannedFile) -> Result<u64> {
405 match (&file.content, &file.source) {
406 (Some(content), None) => {
407 assert_eq!(content.len() as u64, file.size);
408 std::fs::write(target, content).map_err(|e| io(target, e))?;
409 Ok(content.len() as u64)
410 }
411 (None, Some(source)) => {
412 let input = std::fs::File::open(source).map_err(|e| io(source, e))?;
413 let mut input = HashingReader::new(std::io::BufReader::new(input));
414 let mut output = std::fs::File::create(target).map_err(|e| io(target, e))?;
415 let copy_result = std::io::copy(&mut input, &mut output).map_err(|e| io(source, e));
416 drop(output);
417 let (digest, size) = input.finish();
418
419 if let Err(error) = copy_result {
420 let _ = remove_existing(target);
421 return Err(error);
422 }
423 let expected = file
424 .sha256
425 .as_ref()
426 .expect("validated regular files have a digest");
427 if let Err(error) = ensure_matches_plan(source, expected, file.size, digest, size) {
428 let _ = remove_existing(target);
429 return Err(error);
430 }
431 Ok(size)
432 }
433 _ => unreachable!("validated regular files have exactly one content source"),
434 }
435}
436
437pub(crate) fn set_output_permissions(stage: &Path, destination: &Path) -> Result<()> {
441 let permissions = std::fs::metadata(destination)
442 .map(|metadata| metadata.permissions())
443 .unwrap_or_else(|_| std::fs::Permissions::from_mode(0o644));
444 std::fs::set_permissions(stage, permissions).map_err(|e| io(stage, e))
445}
446
447pub(crate) const STAGE_MODE: u32 = 0o700;
454
455pub(crate) fn guard_output(output: &Path) -> Result<()> {
459 let absolute = std::path::absolute(output).map_err(|e| io(output, e))?;
463 let lexical = crate::paths::normalize_absolute(&absolute);
464 let resolved = output.canonicalize().unwrap_or(lexical);
465 if resolved.parent().is_none() {
466 return Err(Error::Config {
467 message: format!(
468 "refusing to materialize a bundle at filesystem root `{}`",
469 resolved.display()
470 ),
471 });
472 }
473 Ok(())
474}
475
476fn guard_clean(output: &Path) -> Result<()> {
479 let resolved = output
480 .canonicalize()
481 .unwrap_or_else(|_| output.to_path_buf());
482 if resolved.parent().is_none() {
483 return Err(Error::Config {
484 message: format!("refusing to --clean `{}`", resolved.display()),
485 });
486 }
487 Ok(())
488}
489
490fn remove_existing(path: &Path) -> Result<()> {
492 match std::fs::symlink_metadata(path) {
493 Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path).map_err(|e| io(path, e)),
496 Ok(_) => std::fs::remove_file(path).map_err(|e| io(path, e)),
497 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
498 Err(e) => Err(io(path, e)),
499 }
500}
501
502#[derive(Debug, Default, Clone, Copy)]
504pub struct RootFsReport {
505 pub files: u32,
506 pub directories: u32,
507 pub symlinks: u32,
508 pub bytes: u64,
509}
510
511fn set_mode(path: &Path, mode: u32) -> Result<()> {
512 use std::os::unix::fs::PermissionsExt;
513
514 assert!(mode <= 0o7777);
515 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| io(path, e))
516}
517
518fn set_permissions_from(path: &Path, metadata: &std::fs::Metadata) -> Result<()> {
519 std::fs::set_permissions(path, metadata.permissions()).map_err(|e| io(path, e))
520}
521
522fn set_times_from(path: &Path, metadata: &std::fs::Metadata) {
523 let (Ok(accessed), Ok(modified), Ok(file)) = (
524 metadata.accessed(),
525 metadata.modified(),
526 std::fs::File::open(path),
527 ) else {
528 return;
529 };
530 let _ = file.set_times(
531 std::fs::FileTimes::new()
532 .set_accessed(accessed)
533 .set_modified(modified),
534 );
535}
536
537fn pin_times(path: &Path, time: std::time::SystemTime) {
541 let Ok(file) = std::fs::File::open(path) else {
542 return;
543 };
544 let _ = file.set_times(
545 std::fs::FileTimes::new()
546 .set_accessed(time)
547 .set_modified(time),
548 );
549}
550
551#[cfg(test)]
552mod tests {
553 use super::{
554 ensure_directory, finish_exchange, finish_noreplace, guard_clean, guard_output,
555 remove_existing,
556 };
557 use crate::paths::has_symlinked_ancestor;
558 use std::path::Path;
559
560 #[test]
561 fn detects_a_symlink_above_a_missing_parent() {
562 let temp = tempfile::tempdir().unwrap();
563 let output = temp.path().join("output");
564 let outside = temp.path().join("outside");
565 std::fs::create_dir_all(&output).unwrap();
566 std::fs::create_dir(&outside).unwrap();
567 std::os::unix::fs::symlink(&outside, output.join("nested")).unwrap();
568
569 assert!(has_symlinked_ancestor(
570 &output,
571 &output.join("nested/deeper")
572 ));
573 }
574
575 #[test]
576 fn removing_a_symlink_leaves_its_target_alone() {
577 let temp = tempfile::tempdir().unwrap();
578 let target = temp.path().join("target");
579 let link = temp.path().join("link");
580 std::fs::write(&target, b"keep me").unwrap();
581 std::os::unix::fs::symlink(&target, &link).unwrap();
582
583 remove_existing(&link).unwrap();
584 assert!(!link.exists());
585 assert_eq!(std::fs::read(&target).unwrap(), b"keep me");
586
587 remove_existing(&link).unwrap();
589 }
590
591 #[test]
592 fn clean_refuses_to_delete_a_filesystem_root() {
593 let err = guard_clean(Path::new("/")).unwrap_err();
594 assert_eq!(err.code(), "E4001");
595 let temp = tempfile::tempdir().unwrap();
596 guard_clean(&temp.path().join("rootfs")).unwrap();
597 }
598
599 #[test]
600 fn materialization_refuses_a_filesystem_root() {
601 let err = guard_output(Path::new("/")).unwrap_err();
602 assert_eq!(err.code(), "E4001");
603 let err = guard_output(Path::new("/new-rootfs/..")).unwrap_err();
604 assert_eq!(err.code(), "E4001");
605 let temp = tempfile::tempdir().unwrap();
606 guard_output(&temp.path().join("rootfs")).unwrap();
607 }
608
609 #[test]
610 fn an_output_root_symlink_is_rejected() {
611 let temp = tempfile::tempdir().unwrap();
612 let target = temp.path().join("target");
613 let output = temp.path().join("output");
614 std::fs::create_dir(&target).unwrap();
615 std::os::unix::fs::symlink(&target, &output).unwrap();
616
617 assert!(ensure_directory(&output).is_err());
618 }
619
620 #[test]
621 fn unsupported_atomic_exchange_falls_back_to_portable_publication() {
622 let temp = tempfile::tempdir().unwrap();
623 let output = temp.path().join("output");
624 std::fs::create_dir(&output).unwrap();
625 std::fs::write(output.join("old"), b"old").unwrap();
626
627 let stage = tempfile::Builder::new()
628 .prefix(".elfpak-rootfs-")
629 .tempdir_in(temp.path())
630 .unwrap();
631 std::fs::write(stage.path().join("new"), b"new").unwrap();
632
633 finish_exchange(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
634
635 assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
636 assert!(!output.join("old").exists());
637 }
638
639 #[test]
640 fn unsupported_noreplace_falls_back_to_portable_publication() {
641 let temp = tempfile::tempdir().unwrap();
642 let output = temp.path().join("output");
643 let stage = tempfile::Builder::new()
644 .prefix(".elfpak-rootfs-")
645 .tempdir_in(temp.path())
646 .unwrap();
647 std::fs::write(stage.path().join("new"), b"new").unwrap();
648
649 finish_noreplace(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
650
651 assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
652 }
653
654 #[test]
655 fn unrelated_exchange_errors_leave_the_existing_output_untouched() {
656 let temp = tempfile::tempdir().unwrap();
657 let output = temp.path().join("output");
658 std::fs::create_dir(&output).unwrap();
659 std::fs::write(output.join("old"), b"old").unwrap();
660
661 let stage = tempfile::Builder::new()
662 .prefix(".elfpak-rootfs-")
663 .tempdir_in(temp.path())
664 .unwrap();
665 std::fs::write(stage.path().join("new"), b"new").unwrap();
666
667 assert!(finish_exchange(stage, &output, Err(rustix::io::Errno::PERM)).is_err());
668
669 assert_eq!(std::fs::read(output.join("old")).unwrap(), b"old");
670 assert!(!output.join("new").exists());
671 }
672}