Skip to main content

a3s_box_runtime/rootfs/
ext4.rs

1//! Deterministic guest-native ext4 artifact construction.
2//!
3//! This adapter deliberately owns the policy around `mkext4`: capacity
4//! limits, OCI metadata replay, host-only xattr filtering, validation, and
5//! atomic publication are A3S contracts rather than properties delegated to
6//! a third-party tree-walking example.
7
8use 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
31/// Versioned A3S wrapper contract for a published ext4 artifact directory.
32pub const EXT4_ARTIFACT_SCHEMA: &str = "a3s.box.rootfs-ext4.v1";
33/// Exact writer identity included in cache keys and artifact manifests.
34pub const EXT4_BUILDER_ID: &str = "mkext4/0.0.3+a3s-adapter-v3";
35/// Builder identities that remain safe to resume as already-published disks.
36#[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/// Validation result for a mutable guest-owned generation selected for boot.
48#[cfg(any(target_os = "macos", all(unix, test)))]
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(super) enum Ext4ResumeValidation {
51    /// The filesystem was cleanly unmounted and passed full structural checks.
52    Clean,
53    /// The ext4 journal must be replayed by the guest kernel before use.
54    JournalRecoveryRequired,
55}
56
57/// Determinism inputs and capacity policy for one ext4 artifact.
58#[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/// Durable metadata committed in the same atomically published directory as
101/// the raw filesystem image.
102#[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/// Paths and metadata for one complete, validated artifact generation.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct Ext4Artifact {
115    pub directory: PathBuf,
116    pub disk: PathBuf,
117    pub manifest: Ext4ArtifactManifest,
118}
119
120/// Build, validate, and atomically publish a raw ext4 filesystem.
121///
122/// `destination` is a generation directory, not the disk file itself. The
123/// directory rename is the commit record: consumers can never observe an image
124/// without its matching schema and builder identity.
125pub 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
149/// Publish a fully declared ext4 namespace without requiring a host directory
150/// tree. The OCI direct assembler uses this boundary after resolving all layer
151/// operations in raw guest-path space.
152pub(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    // Runtime-owned replacements are a different generation than any OCI
512    // entry at the same path. Replaying the image entry's kind, owner, mode, or
513    // timestamp would make the generated base depend on stale image metadata
514    // and can even reject a regular guest-init that replaced an image symlink.
515    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            // APFS/Finder metadata is a property of the host staging view, not
588            // Linux image content. Never bake it into the guest filesystem.
589            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/// Validate a mutable persistent disk without parsing unreplayed metadata.
790///
791/// A clean generation receives the same full reader verification as an
792/// immutable cache entry. After a host crash, ext4 sets only the dynamic
793/// `RECOVER` incompatibility bit. In that state the home metadata can lag the
794/// journal, so a host-side tree reader is the wrong recovery mechanism. We
795/// instead validate the exact A3S superblock envelope and let the isolated
796/// guest kernel replay its own journal on the next read-write mount.
797#[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;