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