1use std::collections::{BTreeMap, HashMap};
9use std::fs::File;
10use std::io::Read;
11#[cfg(any(target_os = "macos", all(unix, test)))]
12use std::io::{Seek, SeekFrom};
13use std::os::unix::ffi::{OsStrExt, OsStringExt};
14use std::os::unix::fs::{FileTypeExt, MetadataExt};
15use std::path::{Path, PathBuf};
16
17use a3s_box_core::error::{BoxError, Result};
18use a3s_box_core::rootfs_metadata::{
19 RootfsEntryKind, RootfsMetadataEntry, RootfsMetadataManifest, IMAGE_ROOTFS_METADATA_PATH,
20};
21use base64::Engine as _;
22use mkext4::sink::FileSink;
23use mkext4::{FsBuilder, InodeHandle, Meta, Options, SparseSeg, SpecialKind, ROOT};
24use serde::{Deserialize, Serialize};
25
26#[path = "ext4_sparse.rs"]
27pub(super) mod sparse;
28
29use sparse::{sparse_layout, FileFill, SourceSegment};
30
31pub const EXT4_ARTIFACT_SCHEMA: &str = "a3s.box.rootfs-ext4.v1";
33pub const EXT4_BUILDER_ID: &str = "mkext4/0.0.3+a3s-adapter-v3";
35#[cfg(any(target_os = "macos", all(unix, test)))]
37pub(super) const LEGACY_EXT4_BUILDER_IDS: &[&str] =
38 &["mkext4/0.0.3+a3s-adapter-v1", "mkext4/0.0.3+a3s-adapter-v2"];
39
40pub(super) const DISK_FILE_NAME: &str = "rootfs.ext4";
41pub(super) const MANIFEST_FILE_NAME: &str = "artifact.json";
42pub(super) const STAGING_DIRECTORY_PREFIX: &str = ".a3s-rootfs-ext4-";
43const MIN_CAPACITY_BYTES: u64 = 16 * 1024 * 1024;
44const MAX_CAPACITY_BYTES: u64 = 64 * 1024 * 1024 * 1024;
45const MAX_IMAGE_METADATA_BYTES: u64 = 64 * 1024 * 1024;
46
47#[cfg(any(target_os = "macos", all(unix, test)))]
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(super) enum Ext4ResumeValidation {
51 Clean,
53 JournalRecoveryRequired,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct Ext4ArtifactOptions {
60 pub capacity_bytes: u64,
61 pub fs_uuid: [u8; 16],
62 pub epoch: i64,
63}
64
65impl Ext4ArtifactOptions {
66 pub fn from_disk_mib(disk_mib: u32, fs_uuid: [u8; 16]) -> Result<Self> {
67 let capacity_bytes = u64::from(disk_mib)
68 .checked_mul(1024 * 1024)
69 .ok_or_else(|| BoxError::BuildError("ext4 capacity overflow".to_string()))?;
70 let options = Self {
71 capacity_bytes,
72 fs_uuid,
73 epoch: 0,
74 };
75 options.validate()?;
76 Ok(options)
77 }
78
79 pub(super) fn validate(&self) -> Result<()> {
80 if !(MIN_CAPACITY_BYTES..=MAX_CAPACITY_BYTES).contains(&self.capacity_bytes) {
81 return Err(BoxError::BuildError(format!(
82 "ext4 capacity {} is outside the supported range {}..={} bytes",
83 self.capacity_bytes, MIN_CAPACITY_BYTES, MAX_CAPACITY_BYTES
84 )));
85 }
86 if self.capacity_bytes & 4095 != 0 {
87 return Err(BoxError::BuildError(
88 "ext4 capacity must be 4096-byte aligned".to_string(),
89 ));
90 }
91 if !(0..=i64::from(u32::MAX)).contains(&self.epoch) {
92 return Err(BoxError::BuildError(
93 "ext4 epoch is outside the superblock timestamp range".to_string(),
94 ));
95 }
96 Ok(())
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Ext4ArtifactManifest {
105 pub schema: String,
106 pub builder: String,
107 pub format: String,
108 pub capacity_bytes: u64,
109 pub fs_uuid: String,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Ext4Artifact {
115 pub directory: PathBuf,
116 pub disk: PathBuf,
117 pub manifest: Ext4ArtifactManifest,
118}
119
120pub fn publish_ext4_artifact(
126 source: &Path,
127 destination: &Path,
128 options: Ext4ArtifactOptions,
129) -> Result<Ext4Artifact> {
130 options.validate()?;
131 let parent = destination.parent().ok_or_else(|| {
132 BoxError::BuildError(format!(
133 "ext4 artifact destination has no parent: {}",
134 destination.display()
135 ))
136 })?;
137 std::fs::create_dir_all(parent).map_err(|error| {
138 BoxError::BuildError(format!(
139 "Failed to create ext4 artifact parent {}: {error}",
140 parent.display()
141 ))
142 })?;
143 validate_source_and_destination(source, destination)?;
144
145 let (builder, fills) = declare_source_tree(source, options)?;
146 publish_ext4_build_plan(destination, options, builder, fills)
147}
148
149pub(super) fn publish_ext4_build_plan(
153 destination: &Path,
154 options: Ext4ArtifactOptions,
155 builder: FsBuilder,
156 fills: Vec<FileFill>,
157) -> Result<Ext4Artifact> {
158 options.validate()?;
159 let parent = destination.parent().ok_or_else(|| {
160 BoxError::BuildError(format!(
161 "ext4 artifact destination has no parent: {}",
162 destination.display()
163 ))
164 })?;
165 std::fs::create_dir_all(parent).map_err(|error| {
166 BoxError::BuildError(format!(
167 "Failed to create ext4 artifact parent {}: {error}",
168 parent.display()
169 ))
170 })?;
171 validate_artifact_destination(destination)?;
172
173 let temporary = tempfile::Builder::new()
174 .prefix(STAGING_DIRECTORY_PREFIX)
175 .tempdir_in(parent)
176 .map_err(|error| {
177 BoxError::BuildError(format!(
178 "Failed to create ext4 artifact staging directory in {}: {error}",
179 parent.display()
180 ))
181 })?;
182 let temporary_disk = temporary.path().join(DISK_FILE_NAME);
183
184 let layout = builder.seal().map_err(|error| {
185 BoxError::BuildError(format!(
186 "Failed to lay out {}-byte ext4 artifact: {error}",
187 options.capacity_bytes
188 ))
189 })?;
190 let mut sink = FileSink::create(&temporary_disk, layout.image_len()).map_err(|error| {
191 BoxError::BuildError(format!(
192 "Failed to create sparse ext4 artifact {}: {error}",
193 temporary_disk.display()
194 ))
195 })?;
196 let mut writer = layout.writer(&mut sink).map_err(mkext4_build_error)?;
197 for fill in fills {
198 fill.write_into(&mut writer)?;
199 }
200 let summary = writer.finish().map_err(mkext4_build_error)?;
201 if summary.image_len != options.capacity_bytes {
202 return Err(BoxError::BuildError(format!(
203 "ext4 writer returned unexpected image length {} (expected {})",
204 summary.image_len, options.capacity_bytes
205 )));
206 }
207 sink.into_file().sync_all().map_err(|error| {
208 BoxError::BuildError(format!(
209 "Failed to sync ext4 artifact {}: {error}",
210 temporary_disk.display()
211 ))
212 })?;
213 validate_ext4_image(&temporary_disk, options.capacity_bytes)?;
214
215 let manifest = Ext4ArtifactManifest {
216 schema: EXT4_ARTIFACT_SCHEMA.to_string(),
217 builder: EXT4_BUILDER_ID.to_string(),
218 format: "raw-ext4".to_string(),
219 capacity_bytes: options.capacity_bytes,
220 fs_uuid: hex::encode(options.fs_uuid),
221 };
222 let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|error| {
223 BoxError::BuildError(format!("Failed to encode ext4 artifact manifest: {error}"))
224 })?;
225 let temporary_manifest = temporary.path().join(MANIFEST_FILE_NAME);
226 std::fs::write(&temporary_manifest, manifest_bytes).map_err(|error| {
227 BoxError::BuildError(format!(
228 "Failed to write ext4 artifact manifest {}: {error}",
229 temporary_manifest.display()
230 ))
231 })?;
232 File::open(&temporary_manifest)
233 .and_then(|file| file.sync_all())
234 .map_err(|error| {
235 BoxError::BuildError(format!(
236 "Failed to sync ext4 artifact manifest {}: {error}",
237 temporary_manifest.display()
238 ))
239 })?;
240 sync_directory(temporary.path())?;
241
242 let temporary_path = temporary.keep();
243 if let Err(error) = std::fs::rename(&temporary_path, destination) {
244 let _ = std::fs::remove_dir_all(&temporary_path);
245 return Err(BoxError::BuildError(format!(
246 "Failed to atomically publish ext4 artifact {}: {error}",
247 destination.display()
248 )));
249 }
250 sync_directory(parent)?;
251
252 Ok(Ext4Artifact {
253 directory: destination.to_path_buf(),
254 disk: destination.join(DISK_FILE_NAME),
255 manifest,
256 })
257}
258
259pub(super) fn new_ext4_fs_builder(options: Ext4ArtifactOptions) -> Result<FsBuilder> {
260 options.validate()?;
261 let mut mkfs_options = Options::new(options.capacity_bytes, options.fs_uuid, options.epoch);
262 mkfs_options.label = Some("a3s-rootfs".to_string());
263 mkfs_options.reserved_percent = 0;
264 FsBuilder::new(mkfs_options).map_err(mkext4_build_error)
265}
266
267fn validate_artifact_destination(destination: &Path) -> Result<()> {
268 match std::fs::symlink_metadata(destination) {
269 Ok(_) => Err(BoxError::BuildError(format!(
270 "ext4 artifact generation already exists: {}",
271 destination.display()
272 ))),
273 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
274 Err(error) => Err(BoxError::BuildError(format!(
275 "Failed to inspect ext4 artifact destination {}: {error}",
276 destination.display()
277 ))),
278 }
279}
280
281fn validate_source_and_destination(source: &Path, destination: &Path) -> Result<()> {
282 let source_metadata = std::fs::symlink_metadata(source).map_err(|error| {
283 BoxError::BuildError(format!(
284 "Failed to inspect ext4 source {}: {error}",
285 source.display()
286 ))
287 })?;
288 if !source_metadata.is_dir() || source_metadata.file_type().is_symlink() {
289 return Err(BoxError::BuildError(format!(
290 "ext4 source is not a plain directory: {}",
291 source.display()
292 )));
293 }
294 validate_artifact_destination(destination)?;
295
296 let canonical_source = source.canonicalize().map_err(BoxError::IoError)?;
297 if let Some(parent) = destination.parent() {
298 if let Ok(canonical_parent) = parent.canonicalize() {
299 if canonical_parent.starts_with(&canonical_source) {
300 return Err(BoxError::BuildError(
301 "ext4 artifact destination must be outside the source tree".to_string(),
302 ));
303 }
304 }
305 }
306 Ok(())
307}
308
309fn declare_source_tree(
310 source: &Path,
311 options: Ext4ArtifactOptions,
312) -> Result<(FsBuilder, Vec<FileFill>)> {
313 let mut builder = new_ext4_fs_builder(options)?;
314 let metadata = ImageMetadata::load(source)?;
315 let root_metadata = std::fs::symlink_metadata(source).map_err(BoxError::IoError)?;
316 builder
317 .set_meta(
318 ROOT,
319 node_meta(source, Path::new(""), &root_metadata, &metadata)?,
320 )
321 .map_err(mkext4_build_error)?;
322 apply_xattrs(&mut builder, ROOT, source)?;
323
324 let mut hardlinks = HashMap::new();
325 let mut fills = Vec::new();
326 let mut state = TreeDeclarationState {
327 image_metadata: &metadata,
328 hardlinks: &mut hardlinks,
329 fills: &mut fills,
330 };
331 declare_directory(
332 &mut builder,
333 ROOT,
334 source,
335 Path::new(""),
336 Path::new(""),
337 &mut state,
338 )?;
339 Ok((builder, fills))
340}
341
342struct TreeDeclarationState<'a> {
343 image_metadata: &'a ImageMetadata,
344 hardlinks: &'a mut HashMap<(u64, u64), InodeHandle>,
345 fills: &'a mut Vec<FileFill>,
346}
347
348fn declare_directory(
349 builder: &mut FsBuilder,
350 parent: InodeHandle,
351 directory: &Path,
352 physical_relative_directory: &Path,
353 logical_relative_directory: &Path,
354 state: &mut TreeDeclarationState<'_>,
355) -> Result<()> {
356 let mut entries = std::fs::read_dir(directory)
357 .map_err(|error| {
358 BoxError::BuildError(format!(
359 "Failed to read ext4 source directory {}: {error}",
360 directory.display()
361 ))
362 })?
363 .collect::<std::result::Result<Vec<_>, _>>()
364 .map_err(BoxError::IoError)?;
365 entries.sort_by(|left, right| {
366 left.file_name()
367 .as_bytes()
368 .cmp(right.file_name().as_bytes())
369 });
370
371 for entry in entries {
372 let name_os = entry.file_name();
373 let path = entry.path();
374 let physical_relative = physical_relative_directory.join(&name_os);
375 let logical_relative = state.image_metadata.logical_path(
376 &physical_relative,
377 logical_relative_directory,
378 &name_os,
379 )?;
380 let name = logical_relative
381 .file_name()
382 .ok_or_else(|| {
383 BoxError::BuildError(format!(
384 "Guest path has no directory entry name: {}",
385 logical_relative.display()
386 ))
387 })?
388 .as_bytes();
389 let filesystem = std::fs::symlink_metadata(&path).map_err(BoxError::IoError)?;
390 let file_type = filesystem.file_type();
391
392 if !file_type.is_dir() && filesystem.nlink() > 1 {
393 let identity = (filesystem.dev(), filesystem.ino());
394 if let Some(existing) = state.hardlinks.get(&identity).copied() {
395 builder
396 .hardlink(parent, name, existing)
397 .map_err(mkext4_build_error)?;
398 continue;
399 }
400 }
401
402 let metadata = node_meta(&path, &logical_relative, &filesystem, state.image_metadata)?;
403 let handle = if file_type.is_dir() {
404 builder
405 .mkdir(parent, name, metadata)
406 .map_err(mkext4_build_error)?
407 } else if file_type.is_symlink() {
408 let target = state
409 .image_metadata
410 .symlink_target(&logical_relative)?
411 .unwrap_or(
412 std::fs::read_link(&path)
413 .map_err(BoxError::IoError)?
414 .as_os_str()
415 .as_bytes()
416 .to_vec(),
417 );
418 builder
419 .symlink(parent, name, &target, metadata)
420 .map_err(mkext4_build_error)?
421 } else if file_type.is_file() {
422 let file = File::open(&path).map_err(BoxError::IoError)?;
423 if let Some(sparse) = sparse_layout(&file, filesystem.len())? {
424 let segments: Vec<_> = sparse
425 .segments
426 .iter()
427 .map(|segment| match *segment {
428 SourceSegment::Data { len, .. } => SparseSeg::Data(len),
429 SourceSegment::Hole { len } => SparseSeg::Hole(len),
430 })
431 .collect();
432 let handle = builder
433 .file_sparse(parent, name, metadata, &segments)
434 .map_err(mkext4_build_error)?;
435 if !sparse.data_ranges.is_empty() {
436 state.fills.push(FileFill::Sparse {
437 handle,
438 path: path.clone(),
439 ranges: sparse.data_ranges,
440 });
441 }
442 handle
443 } else {
444 let handle = builder
445 .file(parent, name, metadata, filesystem.len())
446 .map_err(mkext4_build_error)?;
447 if filesystem.len() > 0 {
448 state.fills.push(FileFill::Dense {
449 handle,
450 path: path.clone(),
451 });
452 }
453 handle
454 }
455 } else if file_type.is_char_device() || file_type.is_block_device() {
456 let (major, minor) = device_numbers(filesystem.rdev());
457 let kind = if file_type.is_char_device() {
458 SpecialKind::Char { major, minor }
459 } else {
460 SpecialKind::Block { major, minor }
461 };
462 builder
463 .mknod(parent, name, metadata, kind)
464 .map_err(mkext4_build_error)?
465 } else if file_type.is_fifo() {
466 builder
467 .mknod(parent, name, metadata, SpecialKind::Fifo)
468 .map_err(mkext4_build_error)?
469 } else if file_type.is_socket() {
470 builder
471 .mknod(parent, name, metadata, SpecialKind::Socket)
472 .map_err(mkext4_build_error)?
473 } else {
474 return Err(BoxError::BuildError(format!(
475 "Unsupported rootfs entry type at {}",
476 path.display()
477 )));
478 };
479
480 apply_xattrs(builder, handle, &path)?;
481 if !file_type.is_dir() && filesystem.nlink() > 1 {
482 state
483 .hardlinks
484 .insert((filesystem.dev(), filesystem.ino()), handle);
485 }
486 if file_type.is_dir() {
487 declare_directory(
488 builder,
489 handle,
490 &path,
491 &physical_relative,
492 &logical_relative,
493 state,
494 )?;
495 }
496 }
497 Ok(())
498}
499
500fn node_meta(
501 path: &Path,
502 relative: &Path,
503 filesystem: &std::fs::Metadata,
504 image_metadata: &ImageMetadata,
505) -> Result<Meta> {
506 let runtime_mode = a3s_box_core::rootfs_metadata::runtime_managed_rootfs_mode(relative)
507 .or_else(|| {
508 (relative == Path::new(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/')))
509 .then_some(0o600)
510 });
511 let override_entry = runtime_mode
516 .is_none()
517 .then(|| image_metadata.entries.get(relative))
518 .flatten();
519 if let Some(entry) = override_entry {
520 let actual_kind = if filesystem.file_type().is_dir() {
521 Some(RootfsEntryKind::Directory)
522 } else if filesystem.file_type().is_file() {
523 Some(RootfsEntryKind::Regular)
524 } else if filesystem.file_type().is_symlink() {
525 Some(RootfsEntryKind::Symlink)
526 } else {
527 None
528 };
529 if actual_kind != Some(entry.kind) {
530 return Err(BoxError::BuildError(format!(
531 "OCI metadata kind does not match staged rootfs entry {}",
532 path.display()
533 )));
534 }
535 }
536
537 let mode = runtime_mode
538 .or_else(|| override_entry.map(|entry| entry.mode))
539 .unwrap_or_else(|| filesystem.mode())
540 & 0o7777;
541 let uid = match (runtime_mode, override_entry) {
542 (Some(_), _) => 0,
543 (None, Some(entry)) => u32::try_from(entry.uid).map_err(|_| {
544 BoxError::BuildError(format!("UID exceeds ext4 range at {}", path.display()))
545 })?,
546 (None, None) => filesystem.uid(),
547 };
548 let gid = match (runtime_mode, override_entry) {
549 (Some(_), _) => 0,
550 (None, Some(entry)) => u32::try_from(entry.gid).map_err(|_| {
551 BoxError::BuildError(format!("GID exceeds ext4 range at {}", path.display()))
552 })?,
553 (None, None) => filesystem.gid(),
554 };
555 let (mtime, mtime_nsec) = match (runtime_mode, override_entry) {
556 (Some(_), _) => (0, 0),
557 (None, Some(entry)) => (
558 i64::try_from(entry.mtime).map_err(|_| {
559 BoxError::BuildError(format!("mtime exceeds ext4 range at {}", path.display()))
560 })?,
561 0,
562 ),
563 (None, None) => (filesystem.mtime(), filesystem.mtime_nsec()),
564 };
565 let mtime_nsec = u32::try_from(mtime_nsec)
566 .ok()
567 .filter(|value| *value < 1_000_000_000)
568 .ok_or_else(|| {
569 BoxError::BuildError(format!("Invalid mtime nanoseconds at {}", path.display()))
570 })?;
571 Ok(Meta::new(mode as u16, uid, gid, (mtime, mtime_nsec)))
572}
573
574fn apply_xattrs(builder: &mut FsBuilder, handle: InodeHandle, path: &Path) -> Result<()> {
575 let mut names = xattr::list(path)
576 .map_err(|error| {
577 BoxError::BuildError(format!(
578 "Failed to list xattrs for {}: {error}",
579 path.display()
580 ))
581 })?
582 .collect::<Vec<_>>();
583 names.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
584 for name in names {
585 let raw_name = name.as_bytes();
586 if raw_name.starts_with(b"com.apple.") {
587 continue;
590 }
591 let name = name.to_str().ok_or_else(|| {
592 BoxError::BuildError(format!(
593 "Non-UTF-8 xattr name cannot be represented at {}",
594 path.display()
595 ))
596 })?;
597 if !is_linux_xattr_name(name) {
598 return Err(BoxError::BuildError(format!(
599 "Unsupported xattr namespace {name:?} at {}",
600 path.display()
601 )));
602 }
603 let value = xattr::get(path, name)
604 .map_err(|error| {
605 BoxError::BuildError(format!(
606 "Failed to read xattr {name:?} at {}: {error}",
607 path.display()
608 ))
609 })?
610 .ok_or_else(|| {
611 BoxError::BuildError(format!(
612 "xattr {name:?} disappeared while building {}",
613 path.display()
614 ))
615 })?;
616 builder
617 .set_xattr(handle, name, &value)
618 .map_err(mkext4_build_error)?;
619 }
620 Ok(())
621}
622
623pub(super) fn is_linux_xattr_name(name: &str) -> bool {
624 name.strip_prefix("user.")
625 .is_some_and(|suffix| !suffix.is_empty())
626 || name
627 .strip_prefix("trusted.")
628 .is_some_and(|suffix| !suffix.is_empty())
629 || name
630 .strip_prefix("security.")
631 .is_some_and(|suffix| !suffix.is_empty())
632 || name == "system.posix_acl_access"
633 || name == "system.posix_acl_default"
634 || name
635 .strip_prefix("system.")
636 .is_some_and(|suffix| !suffix.is_empty())
637}
638
639#[derive(Default)]
640struct ImageMetadata {
641 entries: BTreeMap<PathBuf, RootfsMetadataEntry>,
642 staging_to_logical: BTreeMap<PathBuf, PathBuf>,
643}
644
645impl ImageMetadata {
646 fn load(root: &Path) -> Result<Self> {
647 let path = root.join(IMAGE_ROOTFS_METADATA_PATH.trim_start_matches('/'));
648 let file = match File::open(&path) {
649 Ok(file) => file,
650 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
651 return Ok(Self::default())
652 }
653 Err(error) => return Err(BoxError::IoError(error)),
654 };
655 let length = file.metadata().map_err(BoxError::IoError)?.len();
656 if length > MAX_IMAGE_METADATA_BYTES {
657 return Err(BoxError::BuildError(format!(
658 "Image metadata {} exceeds {} bytes",
659 path.display(),
660 MAX_IMAGE_METADATA_BYTES
661 )));
662 }
663 let mut bytes = Vec::with_capacity(length as usize);
664 file.take(MAX_IMAGE_METADATA_BYTES + 1)
665 .read_to_end(&mut bytes)
666 .map_err(BoxError::IoError)?;
667 if bytes.len() as u64 > MAX_IMAGE_METADATA_BYTES {
668 return Err(BoxError::BuildError(
669 "Image metadata grew beyond its byte limit while reading".to_string(),
670 ));
671 }
672 let manifest: RootfsMetadataManifest = serde_json::from_slice(&bytes).map_err(|error| {
673 BoxError::BuildError(format!(
674 "Invalid image metadata {}: {error}",
675 path.display()
676 ))
677 })?;
678 manifest.validate().map_err(BoxError::BuildError)?;
679
680 let mut entries = BTreeMap::new();
681 for entry in manifest.entries {
682 let raw = base64::engine::general_purpose::STANDARD
683 .decode(&entry.path_base64)
684 .map_err(|error| {
685 BoxError::BuildError(format!("Invalid image metadata path: {error}"))
686 })?;
687 let path = normalize_manifest_path(Path::new(&std::ffi::OsString::from_vec(raw)))?;
688 if entries.insert(path, entry).is_some() {
689 return Err(BoxError::BuildError(
690 "Duplicate path in image metadata".to_string(),
691 ));
692 }
693 }
694 let staging_to_logical = super::staging_path_map(entries.keys())?;
695 Ok(Self {
696 entries,
697 staging_to_logical,
698 })
699 }
700
701 fn logical_path(
702 &self,
703 physical: &Path,
704 logical_parent: &Path,
705 physical_name: &std::ffi::OsStr,
706 ) -> Result<PathBuf> {
707 super::logical_path_for_staged_child(
708 &self.staging_to_logical,
709 physical,
710 logical_parent,
711 physical_name,
712 )
713 }
714
715 fn symlink_target(&self, relative: &Path) -> Result<Option<Vec<u8>>> {
716 let Some(encoded) = self
717 .entries
718 .get(relative)
719 .and_then(|entry| entry.link_target_base64.as_ref())
720 else {
721 return Ok(None);
722 };
723 base64::engine::general_purpose::STANDARD
724 .decode(encoded)
725 .map(Some)
726 .map_err(|error| {
727 BoxError::BuildError(format!(
728 "Invalid symlink target in image metadata at {}: {error}",
729 relative.display()
730 ))
731 })
732 }
733}
734
735fn normalize_manifest_path(path: &Path) -> Result<PathBuf> {
736 let mut normalized = PathBuf::new();
737 for component in path.components() {
738 match component {
739 std::path::Component::CurDir => {}
740 std::path::Component::Normal(name) => normalized.push(name),
741 _ => {
742 return Err(BoxError::BuildError(
743 "Unsafe path in image metadata".to_string(),
744 ))
745 }
746 }
747 }
748 Ok(normalized)
749}
750
751pub(super) fn validate_ext4_image(path: &Path, expected_length: u64) -> Result<()> {
752 let file = File::open(path).map_err(BoxError::IoError)?;
753 let actual_length = file.metadata().map_err(BoxError::IoError)?.len();
754 if actual_length != expected_length {
755 return Err(BoxError::BuildError(format!(
756 "ext4 artifact {} has length {} instead of {}",
757 path.display(),
758 actual_length,
759 expected_length
760 )));
761 }
762 let filesystem = mkext4::reader::Fs::open(&file).map_err(|error| {
763 BoxError::BuildError(format!(
764 "Failed to reopen ext4 artifact {}: {error}",
765 path.display()
766 ))
767 })?;
768 let issues = filesystem.verify().map_err(|error| {
769 BoxError::BuildError(format!(
770 "Failed to verify ext4 artifact {}: {error}",
771 path.display()
772 ))
773 })?;
774 if !issues.is_empty() {
775 let details = issues
776 .iter()
777 .take(8)
778 .map(|issue| issue.what.as_str())
779 .collect::<Vec<_>>()
780 .join("; ");
781 return Err(BoxError::BuildError(format!(
782 "ext4 artifact {} failed structural verification: {details}",
783 path.display()
784 )));
785 }
786 Ok(())
787}
788
789#[cfg(any(target_os = "macos", all(unix, test)))]
798pub(super) fn validate_ext4_image_for_resume(
799 path: &Path,
800 expected_length: u64,
801 expected_uuid: [u8; 16],
802) -> Result<Ext4ResumeValidation> {
803 let mut file = File::open(path).map_err(BoxError::IoError)?;
804 let actual_length = file.metadata().map_err(BoxError::IoError)?.len();
805 if actual_length != expected_length {
806 return Err(BoxError::BuildError(format!(
807 "ext4 artifact {} has length {} instead of {}",
808 path.display(),
809 actual_length,
810 expected_length
811 )));
812 }
813
814 let mut bytes = [0u8; mkext4::spec::Superblock::LEN];
815 file.seek(SeekFrom::Start(1024))
816 .and_then(|_| file.read_exact(&mut bytes))
817 .map_err(BoxError::IoError)?;
818 let superblock = mkext4::spec::Superblock::decode(&bytes).map_err(|error| {
819 BoxError::BuildError(format!(
820 "Failed to decode ext4 artifact superblock {}: {error}",
821 path.display()
822 ))
823 })?;
824 let needs_recovery = superblock.feature_incompat & mkext4::spec::incompat::RECOVER != 0;
825 if !needs_recovery {
826 validate_ext4_image(path, expected_length)?;
827 return Ok(Ext4ResumeValidation::Clean);
828 }
829
830 validate_recovery_superblock(&superblock, &bytes, expected_length, expected_uuid).map_err(
831 |reason| {
832 BoxError::BuildError(format!(
833 "Refused crash recovery for ext4 artifact {}: {reason}",
834 path.display()
835 ))
836 },
837 )?;
838 Ok(Ext4ResumeValidation::JournalRecoveryRequired)
839}
840
841#[cfg(any(target_os = "macos", all(unix, test)))]
842fn validate_recovery_superblock(
843 superblock: &mkext4::spec::Superblock,
844 bytes: &[u8; mkext4::spec::Superblock::LEN],
845 expected_length: u64,
846 expected_uuid: [u8; 16],
847) -> std::result::Result<(), String> {
848 let expected_incompat = mkext4::spec::incompat::WRITER | mkext4::spec::incompat::RECOVER;
849 if superblock.feature_compat != mkext4::spec::compat::WRITER
850 || superblock.feature_incompat != expected_incompat
851 || superblock.feature_ro_compat != mkext4::spec::ro_compat::WRITER
852 {
853 return Err(format!(
854 "feature set changed (compat={:#x}, incompat={:#x}, ro_compat={:#x})",
855 superblock.feature_compat, superblock.feature_incompat, superblock.feature_ro_compat
856 ));
857 }
858 if superblock.uuid != expected_uuid {
859 return Err("filesystem UUID no longer matches the artifact manifest".to_string());
860 }
861 if superblock.block_size() != mkext4::spec::BLOCK_SIZE as u64
862 || superblock.blocks_per_group != mkext4::spec::BLOCKS_PER_GROUP
863 || superblock.clusters_per_group != mkext4::spec::BLOCKS_PER_GROUP
864 || superblock.inode_size != mkext4::spec::INODE_SIZE as u16
865 || superblock.first_data_block != 0
866 {
867 return Err("filesystem geometry no longer matches the A3S ext4 contract".to_string());
868 }
869 let filesystem_length = superblock
870 .blocks_count
871 .checked_mul(superblock.block_size())
872 .ok_or_else(|| "filesystem length overflow".to_string())?;
873 if filesystem_length != expected_length {
874 return Err(format!(
875 "superblock describes {filesystem_length} bytes instead of {expected_length}"
876 ));
877 }
878 if superblock.journal_inum != mkext4::spec::JOURNAL_INO
879 || superblock.journal_dev != 0
880 || superblock.journal_uuid != [0; 16]
881 {
882 return Err("journal identity no longer matches the A3S ext4 contract".to_string());
883 }
884 if mkext4::csum::superblock(bytes) != superblock.checksum {
885 return Err("primary superblock checksum is invalid".to_string());
886 }
887 Ok(())
888}
889
890fn sync_directory(path: &Path) -> Result<()> {
891 File::open(path)
892 .and_then(|directory| directory.sync_all())
893 .map_err(|error| {
894 BoxError::BuildError(format!(
895 "Failed to sync ext4 artifact directory {}: {error}",
896 path.display()
897 ))
898 })
899}
900
901fn mkext4_build_error(error: mkext4::Error) -> BoxError {
902 BoxError::BuildError(format!("ext4 artifact builder failed: {error}"))
903}
904
905fn device_numbers(device: u64) -> (u32, u32) {
906 (
907 ((device >> 8) & 0x0fff) as u32,
908 ((device & 0x00ff) | ((device >> 12) & 0x000f_ff00)) as u32,
909 )
910}
911
912#[cfg(test)]
913#[path = "ext4_tests.rs"]
914mod tests;