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<std::time::SystemTime> {
17 std::time::UNIX_EPOCH
18 .checked_add(std::time::Duration::from_secs(source_date_epoch_secs()?))
19 .ok_or_else(|| Error::Config {
20 message: "SOURCE_DATE_EPOCH is outside the supported system-time range".to_string(),
21 })
22}
23
24pub(crate) fn source_date_epoch_secs() -> Result<u64> {
25 match std::env::var("SOURCE_DATE_EPOCH") {
26 Ok(value) => value.trim().parse::<u64>().map_err(|_| Error::Config {
27 message: format!("invalid SOURCE_DATE_EPOCH `{value}` (expected an unsigned integer)"),
28 }),
29 Err(std::env::VarError::NotPresent) => Ok(0),
30 Err(std::env::VarError::NotUnicode(_)) => Err(Error::Config {
31 message: "SOURCE_DATE_EPOCH is not valid Unicode".to_string(),
32 }),
33 }
34}
35
36#[derive(Debug)]
37pub struct RootFsBuilder {
38 output: PathBuf,
39 clean: bool,
40}
41
42impl RootFsBuilder {
43 pub fn new(output: impl Into<PathBuf>) -> RootFsBuilder {
44 RootFsBuilder {
45 output: output.into(),
46 clean: false,
47 }
48 }
49
50 pub fn clean(mut self, clean: bool) -> RootFsBuilder {
52 self.clean = clean;
53 self
54 }
55
56 pub fn apply(&self, plan: &BundlePlan) -> Result<RootFsReport> {
60 let _ = source_date_epoch()?;
62 guard_output(&self.output)?;
63 let parent = output_parent(&self.output);
64 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
65
66 let stage = tempfile::Builder::new()
67 .prefix(".elfpak-rootfs-")
68 .tempdir_in(parent)
69 .map_err(|e| io(parent, e))?;
70
71 if path_exists(&self.output) {
72 ensure_directory(&self.output)?;
73 }
74 if path_exists(&self.output) && !self.clean {
75 clone_tree(&self.output, stage.path())?;
76 } else {
77 set_mode(stage.path(), 0o755)?;
81 }
82 if self.clean && path_exists(&self.output) {
83 guard_clean(&self.output)?;
84 }
85
86 let report = self.apply_into(plan, stage.path())?;
87 publish_directory(stage, &self.output)?;
88 Ok(report)
89 }
90
91 fn apply_into(&self, plan: &BundlePlan, output: &Path) -> Result<RootFsReport> {
93 let output = output.canonicalize().map_err(|e| io(output, e))?;
94
95 let mut report = RootFsReport::default();
96 for file in &plan.files {
98 file.assert_well_formed();
99 let target = self.target_path(&output, file)?;
100 assert!(target.starts_with(&output));
101
102 match file.kind {
103 PlannedFileKind::Directory => {
104 write_directory(&target, file.mode)?;
105 report.directories += 1;
106 }
107 PlannedFileKind::Symlink => {
108 write_symlink(&target, file.link_target.as_deref())?;
109 report.symlinks += 1;
110 }
111 _ => {
112 remove_existing(&target)?;
115 report.bytes += write_file(&target, file)?;
116 set_mode(&target, file.mode)?;
117 pin_times(&target);
118 report.files += 1;
119 }
120 }
121 }
122
123 for file in plan
126 .files
127 .iter()
128 .rev()
129 .filter(|f| f.kind == PlannedFileKind::Directory)
130 {
131 pin_times(&crate::paths::join_under(&output, &file.destination));
132 }
133
134 let entries = report.files + report.directories + report.symlinks;
135 assert_eq!(entries as usize, plan.files.len());
136 Ok(report)
137 }
138
139 fn target_path(&self, output: &Path, file: &PlannedFile) -> Result<PathBuf> {
142 assert!(file.destination.is_absolute());
143
144 let target = crate::paths::join_under(output, &file.destination);
145 if !target.starts_with(output) {
146 return Err(Error::PathEscape {
147 path: file.destination.clone(),
148 kind: "output",
149 });
150 }
151 if let Some(parent) = target.parent() {
152 if has_symlinked_ancestor(output, parent) {
153 return Err(Error::PathEscape {
154 path: file.destination.clone(),
155 kind: "output",
156 });
157 }
158 std::fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
159 }
160 Ok(target)
161 }
162}
163
164fn output_parent(output: &Path) -> &Path {
167 output
168 .parent()
169 .filter(|parent| !parent.as_os_str().is_empty())
170 .unwrap_or_else(|| Path::new("."))
171}
172
173fn path_exists(path: &Path) -> bool {
174 std::fs::symlink_metadata(path).is_ok()
175}
176
177fn ensure_directory(path: &Path) -> Result<()> {
178 let metadata = std::fs::symlink_metadata(path).map_err(|e| io(path, e))?;
179 if metadata.is_symlink() {
180 return Err(Error::Config {
181 message: format!("output `{}` must not be a symlink", path.display()),
182 });
183 }
184 if metadata.is_dir() {
185 return Ok(());
186 }
187 Err(Error::Config {
188 message: format!("output `{}` is not a directory", path.display()),
189 })
190}
191
192fn clone_tree(source: &Path, destination: &Path) -> Result<()> {
197 let mut stack = vec![(source.to_path_buf(), destination.to_path_buf())];
198 let mut directories = Vec::new();
199
200 while let Some((source_dir, destination_dir)) = stack.pop() {
201 let source_metadata = std::fs::metadata(&source_dir).map_err(|e| io(&source_dir, e))?;
202 directories.push((destination_dir.clone(), source_metadata));
203
204 for entry in std::fs::read_dir(&source_dir).map_err(|e| io(&source_dir, e))? {
205 let entry = entry.map_err(|e| io(&source_dir, e))?;
206 let source_path = entry.path();
207 let destination_path = destination_dir.join(entry.file_name());
208 let metadata =
209 std::fs::symlink_metadata(&source_path).map_err(|e| io(&source_path, e))?;
210
211 if metadata.is_symlink() {
212 let target = std::fs::read_link(&source_path).map_err(|e| io(&source_path, e))?;
213 std::os::unix::fs::symlink(target, &destination_path)
214 .map_err(|e| io(&destination_path, e))?;
215 } else if metadata.is_dir() {
216 std::fs::create_dir(&destination_path).map_err(|e| io(&destination_path, e))?;
217 stack.push((source_path, destination_path));
218 } else if metadata.is_file() {
219 std::fs::copy(&source_path, &destination_path)
220 .map_err(|e| io(&destination_path, e))?;
221 set_permissions_from(&destination_path, &metadata)?;
222 } else {
223 return Err(Error::Config {
224 message: format!(
225 "existing output contains unsupported entry `{}`",
226 source_path.display()
227 ),
228 });
229 }
230 }
231 }
232
233 for (path, metadata) in directories.into_iter().rev() {
236 set_permissions_from(&path, &metadata)?;
237 set_times_from(&path, &metadata);
238 }
239 Ok(())
240}
241
242fn publish_directory(stage: tempfile::TempDir, output: &Path) -> Result<()> {
246 if path_exists(output) {
251 return publish_by_exchange(stage, output);
252 }
253
254 use rustix::fs::{CWD, RenameFlags, renameat_with};
255
256 renameat_with(CWD, stage.path(), CWD, output, RenameFlags::NOREPLACE)
260 .map_err(|e| io(output, e.into()))
261}
262
263fn publish_by_exchange(stage: tempfile::TempDir, output: &Path) -> Result<()> {
264 use rustix::fs::{CWD, RenameFlags, renameat_with};
265
266 let exchange = renameat_with(CWD, stage.path(), CWD, output, RenameFlags::EXCHANGE);
267 finish_exchange(stage, output, exchange)
268}
269
270fn finish_exchange(
271 stage: tempfile::TempDir,
272 output: &Path,
273 exchange: std::result::Result<(), rustix::io::Errno>,
274) -> Result<()> {
275 if let Err(error) = exchange {
276 if matches!(
277 error,
278 rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP
279 ) {
280 return publish_directory_legacy(stage, output);
281 }
282 return Err(io(output, error.into()));
283 }
284
285 let old_path = stage.path().to_path_buf();
286 stage.close().map_err(|e| io(&old_path, e))
287}
288
289fn publish_directory_legacy(stage: tempfile::TempDir, output: &Path) -> Result<()> {
292 let backup = if path_exists(output) {
293 let reservation = tempfile::Builder::new()
294 .prefix(".elfpak-backup-")
295 .tempdir_in(output_parent(output))
296 .map_err(|e| io(output, e))?;
297 let path = reservation.path().to_path_buf();
298 reservation.close().map_err(|e| io(&path, e))?;
299 std::fs::rename(output, &path).map_err(|e| io(output, e))?;
300 Some(path)
301 } else {
302 None
303 };
304
305 if let Err(error) = std::fs::rename(stage.path(), output) {
306 if let Some(backup) = &backup
307 && let Err(rollback) = std::fs::rename(backup, output)
308 {
309 return Err(Error::Config {
310 message: format!(
311 "failed to publish `{}` ({error}) and failed to restore its backup `{}` ({rollback})",
312 output.display(),
313 backup.display()
314 ),
315 });
316 }
317 return Err(io(output, error));
318 }
319
320 if let Some(backup) = backup {
321 remove_existing(&backup)?;
322 }
323 Ok(())
324}
325
326fn write_directory(target: &Path, mode: u32) -> Result<()> {
329 let existing = std::fs::symlink_metadata(target).ok();
330 if !existing.is_some_and(|metadata| metadata.is_dir()) {
331 remove_existing(target)?;
332 std::fs::create_dir_all(target).map_err(|e| io(target, e))?;
333 }
334 set_mode(target, mode)
335}
336
337fn write_symlink(target: &Path, link_target: Option<&Path>) -> Result<()> {
339 let link_target = link_target.expect("validated symlinks have a target");
340 remove_existing(target)?;
341 std::os::unix::fs::symlink(link_target, target).map_err(|e| io(target, e))
342}
343
344fn write_file(target: &Path, file: &PlannedFile) -> Result<u64> {
349 match (&file.content, &file.source) {
350 (Some(content), None) => {
351 assert_eq!(content.len() as u64, file.size);
352 std::fs::write(target, content).map_err(|e| io(target, e))?;
353 Ok(content.len() as u64)
354 }
355 (None, Some(source)) => {
356 let input = std::fs::File::open(source).map_err(|e| io(source, e))?;
357 let mut input = HashingReader::new(std::io::BufReader::new(input));
358 let mut output = std::fs::File::create(target).map_err(|e| io(target, e))?;
359 let copy_result = std::io::copy(&mut input, &mut output).map_err(|e| io(source, e));
360 drop(output);
361 let (digest, size) = input.finish();
362
363 if let Err(error) = copy_result {
364 let _ = remove_existing(target);
365 return Err(error);
366 }
367 let expected = file
368 .sha256
369 .as_ref()
370 .expect("validated regular files have a digest");
371 if let Err(error) = ensure_matches_plan(source, expected, file.size, digest, size) {
372 let _ = remove_existing(target);
373 return Err(error);
374 }
375 Ok(size)
376 }
377 _ => unreachable!("validated regular files have exactly one content source"),
378 }
379}
380
381fn guard_output(output: &Path) -> Result<()> {
385 let absolute = std::path::absolute(output).map_err(|e| io(output, e))?;
389 let lexical = crate::paths::normalize_absolute(&absolute);
390 let resolved = output.canonicalize().unwrap_or(lexical);
391 if resolved.parent().is_none() {
392 return Err(Error::Config {
393 message: format!(
394 "refusing to materialize a bundle at filesystem root `{}`",
395 resolved.display()
396 ),
397 });
398 }
399 Ok(())
400}
401
402fn guard_clean(output: &Path) -> Result<()> {
405 let resolved = output
406 .canonicalize()
407 .unwrap_or_else(|_| output.to_path_buf());
408 if resolved.parent().is_none() {
409 return Err(Error::Config {
410 message: format!("refusing to --clean `{}`", resolved.display()),
411 });
412 }
413 Ok(())
414}
415
416fn remove_existing(path: &Path) -> Result<()> {
418 match std::fs::symlink_metadata(path) {
419 Ok(metadata) if metadata.is_dir() => std::fs::remove_dir_all(path).map_err(|e| io(path, e)),
422 Ok(_) => std::fs::remove_file(path).map_err(|e| io(path, e)),
423 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
424 Err(e) => Err(io(path, e)),
425 }
426}
427
428fn has_symlinked_ancestor(output: &Path, path: &Path) -> bool {
432 let depth = path.components().count();
435 let mut steps = 0usize;
436 let mut current = path;
437 while current != output {
438 steps += 1;
439 assert!(steps <= depth);
440
441 match std::fs::symlink_metadata(current) {
442 Ok(metadata) if metadata.is_symlink() => return true,
443 Ok(_) | Err(_) => {}
444 }
445 let Some(parent) = current.parent() else {
446 break;
447 };
448 current = parent;
449 }
450 false
451}
452
453#[derive(Debug, Default, Clone, Copy)]
455pub struct RootFsReport {
456 pub files: u32,
457 pub directories: u32,
458 pub symlinks: u32,
459 pub bytes: u64,
460}
461
462fn set_mode(path: &Path, mode: u32) -> Result<()> {
463 use std::os::unix::fs::PermissionsExt;
464
465 assert!(mode <= 0o7777);
466 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|e| io(path, e))
467}
468
469fn set_permissions_from(path: &Path, metadata: &std::fs::Metadata) -> Result<()> {
470 std::fs::set_permissions(path, metadata.permissions()).map_err(|e| io(path, e))
471}
472
473fn set_times_from(path: &Path, metadata: &std::fs::Metadata) {
474 let (Ok(accessed), Ok(modified), Ok(file)) = (
475 metadata.accessed(),
476 metadata.modified(),
477 std::fs::File::open(path),
478 ) else {
479 return;
480 };
481 let _ = file.set_times(
482 std::fs::FileTimes::new()
483 .set_accessed(accessed)
484 .set_modified(modified),
485 );
486}
487
488fn pin_times(path: &Path) {
492 let Ok(file) = std::fs::File::open(path) else {
493 return;
494 };
495 let Ok(time) = source_date_epoch() else {
496 return;
497 };
498 let _ = file.set_times(
499 std::fs::FileTimes::new()
500 .set_accessed(time)
501 .set_modified(time),
502 );
503}
504
505#[cfg(test)]
506mod tests {
507 use super::{
508 ensure_directory, finish_exchange, guard_clean, guard_output, has_symlinked_ancestor,
509 remove_existing,
510 };
511 use std::path::Path;
512
513 #[test]
514 fn detects_a_symlink_above_a_missing_parent() {
515 let temp = tempfile::tempdir().unwrap();
516 let output = temp.path().join("output");
517 let outside = temp.path().join("outside");
518 std::fs::create_dir_all(&output).unwrap();
519 std::fs::create_dir(&outside).unwrap();
520 std::os::unix::fs::symlink(&outside, output.join("nested")).unwrap();
521
522 assert!(has_symlinked_ancestor(
523 &output,
524 &output.join("nested/deeper")
525 ));
526 }
527
528 #[test]
529 fn removing_a_symlink_leaves_its_target_alone() {
530 let temp = tempfile::tempdir().unwrap();
531 let target = temp.path().join("target");
532 let link = temp.path().join("link");
533 std::fs::write(&target, b"keep me").unwrap();
534 std::os::unix::fs::symlink(&target, &link).unwrap();
535
536 remove_existing(&link).unwrap();
537 assert!(!link.exists());
538 assert_eq!(std::fs::read(&target).unwrap(), b"keep me");
539
540 remove_existing(&link).unwrap();
542 }
543
544 #[test]
545 fn clean_refuses_to_delete_a_filesystem_root() {
546 let err = guard_clean(Path::new("/")).unwrap_err();
547 assert_eq!(err.code(), "E4001");
548 let temp = tempfile::tempdir().unwrap();
549 guard_clean(&temp.path().join("rootfs")).unwrap();
550 }
551
552 #[test]
553 fn materialization_refuses_a_filesystem_root() {
554 let err = guard_output(Path::new("/")).unwrap_err();
555 assert_eq!(err.code(), "E4001");
556 let err = guard_output(Path::new("/new-rootfs/..")).unwrap_err();
557 assert_eq!(err.code(), "E4001");
558 let temp = tempfile::tempdir().unwrap();
559 guard_output(&temp.path().join("rootfs")).unwrap();
560 }
561
562 #[test]
563 fn an_output_root_symlink_is_rejected() {
564 let temp = tempfile::tempdir().unwrap();
565 let target = temp.path().join("target");
566 let output = temp.path().join("output");
567 std::fs::create_dir(&target).unwrap();
568 std::os::unix::fs::symlink(&target, &output).unwrap();
569
570 assert!(ensure_directory(&output).is_err());
571 }
572
573 #[test]
574 fn unsupported_atomic_exchange_falls_back_to_portable_publication() {
575 let temp = tempfile::tempdir().unwrap();
576 let output = temp.path().join("output");
577 std::fs::create_dir(&output).unwrap();
578 std::fs::write(output.join("old"), b"old").unwrap();
579
580 let stage = tempfile::Builder::new()
581 .prefix(".elfpak-rootfs-")
582 .tempdir_in(temp.path())
583 .unwrap();
584 std::fs::write(stage.path().join("new"), b"new").unwrap();
585
586 finish_exchange(stage, &output, Err(rustix::io::Errno::INVAL)).unwrap();
587
588 assert_eq!(std::fs::read(output.join("new")).unwrap(), b"new");
589 assert!(!output.join("old").exists());
590 }
591
592 #[test]
593 fn unrelated_exchange_errors_leave_the_existing_output_untouched() {
594 let temp = tempfile::tempdir().unwrap();
595 let output = temp.path().join("output");
596 std::fs::create_dir(&output).unwrap();
597 std::fs::write(output.join("old"), b"old").unwrap();
598
599 let stage = tempfile::Builder::new()
600 .prefix(".elfpak-rootfs-")
601 .tempdir_in(temp.path())
602 .unwrap();
603 std::fs::write(stage.path().join("new"), b"new").unwrap();
604
605 assert!(finish_exchange(stage, &output, Err(rustix::io::Errno::PERM)).is_err());
606
607 assert_eq!(std::fs::read(output.join("old")).unwrap(), b"old");
608 assert!(!output.join("new").exists());
609 }
610}