diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2422c69 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Generated by Cargo +/target/ + +# Cargo.lock for binaries +# Uncomment if this is a library +# Cargo.lock + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Build artifacts +*.o +*.a +*.so + +# Debug files +*.dSYM/ + +.github/ + +omnect-os-init_0.1.0.bb + +origin-rewrite-initramfs-plan.md + +rewrite-initramfs-plan.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ae8e302 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,241 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "omnect-os-init" +version = "0.1.0" +dependencies = [ + "log", + "nix", + "serde", + "serde_json", + "tempfile", + "thiserror", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c5bccc9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "omnect-os-init" +version = "0.1.0" +edition = "2024" +authors = ["omnect team"] +description = "Rust-based init process for omnect-os initramfs" +license = "MIT OR Apache-2.0" +repository = "https://github.com/omnect/omnect-os-init" + +[lib] +name = "omnect_os_init" +path = "src/lib.rs" + +[[bin]] +name = "omnect-os-init" +path = "src/main.rs" + +[dependencies] +# Error handling +thiserror = { version = "2.0", default-features = false } + +# Serialization +serde = { version = "1.0", default-features = false, features = ["derive"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } + +# Syscalls (symlink, mount, reboot, etc.) +nix = { version = "0.29", default-features = false, features = ["fs", "mount", "process", "reboot"] } + +# Logging +log = { version = "0.4", default-features = false, features = ["std"] } + +[features] +default = ["core"] +core = [] +factory-reset = ["core"] +flash-mode-1 = ["factory-reset"] +flash-mode-2 = ["flash-mode-1"] +flash-mode-3 = ["flash-mode-1"] +persistent-var-log = ["core"] +resize-data = ["core"] + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-time optimization +codegen-units = 1 # Better optimization +strip = true # Strip symbols +panic = "abort" # Smaller panic handling + +[dev-dependencies] +tempfile = { version = "3.20", default-features = false } diff --git a/README.md b/README.md index bd22020..33690db 100644 --- a/README.md +++ b/README.md @@ -1 +1,98 @@ -# omnect-os-init \ No newline at end of file +# omnect-os-init + +Rust-based init process for omnect-os initramfs. + +## Overview + +Replaces 14 bash-based initramfs scripts (~1500 LOC) with a single Rust binary +acting as `/init` in the initramfs. Runs as PID 1 before `switch_root`. + +Implemented functionality: + +- **Bootloader abstraction**: Unified `Bootloader` trait for GRUB (`grub-editenv`) and U-Boot (`fw_printenv`/`fw_setenv`); fsck output persisted across reboots as gzip+base64 in the bootloader env (encoded via busybox `gzip`/`base64` — no crate dependencies) +- **Configuration**: Parses `/proc/cmdline` and `/etc/os-release` +- **Partition management**: Root device detection, partition layout (GPT/DOS), `/dev/omnect/*` symlinks +- **Filesystem operations**: fsck, mount manager (RAII), overlayfs for `/etc` and `/home`, bind mounts +- **Logging**: Kernel ring buffer (`/dev/kmsg`) with log level prefixes +- **ODS integration**: Runtime files for `omnect-device-service` +- **fs-links**: Symlink creation from `etc/omnect/fs-link.json` and `etc/omnect/fs-link.d/` +- **switch\_root**: MS_MOVE + chroot + exec systemd (`pivot_root(2)` is not used; ramfs does not support it) + +Not yet implemented (planned): + +- Factory reset (backup, wipe, restore) +- Flash modes (disk clone, network, HTTP/HTTPS) +- Data partition auto-resize + +## Building + +```bash +# Debug build +cargo build + +# Release build (optimized for size) +cargo build --release + +# With optional features +cargo build --release --features "persistent-var-log,resize-data" +``` + +## Features + +| Feature | Description | Status | +|---------|-------------|--------| +| `core` | Core boot sequence (default) | Implemented | +| `persistent-var-log` | Bind-mount `/var/log` to data partition | Implemented | +| `factory-reset` | Factory reset support | Planned | +| `flash-mode-1` | Disk cloning | Planned | +| `flash-mode-2` | Network flashing | Planned | +| `flash-mode-3` | HTTP/HTTPS flashing | Planned | +| `resize-data` | Data partition auto-resize | Planned | + +## Testing + +```bash +cargo test + +# Verbose output +cargo test -- --nocapture +``` + +## Architecture + +``` +src/ +├── main.rs # Entry point (PID 1) +├── lib.rs # Library exports +├── error.rs # Error type hierarchy +├── early_init.rs # Mount /dev, /proc, /sys, /run before logging +├── bootloader/ +│ ├── mod.rs # Bootloader trait + auto-detection +│ ├── grub.rs # GRUB implementation (grub-editenv) +│ ├── uboot.rs # U-Boot implementation (fw_printenv/fw_setenv) +│ └── types.rs # BootloaderType enum +├── config/ +│ └── mod.rs # /proc/cmdline + /etc/os-release parser +├── filesystem/ +│ ├── mod.rs # Public API +│ ├── fsck.rs # e2fsck wrapper (all exit codes handled) +│ ├── mount.rs # MountManager (RAII, LIFO unmount) +│ └── overlayfs.rs # /etc overlay, /home overlay, bind mounts +├── logging/ +│ ├── mod.rs # KmsgLogger initializer +│ └── kmsg.rs # /dev/kmsg writer with kernel log levels +├── partition/ +│ ├── mod.rs # Public API +│ ├── device.rs # Root device detection (GRUB: blkid/fsuuid, U-Boot: root=) +│ ├── layout.rs # GPT/DOS partition map builder +│ └── symlinks.rs # /dev/omnect/* symlink creation +└── runtime/ + ├── mod.rs # Public API + ├── fs_link.rs # fs-link symlink creation + ├── omnect_device_service.rs # ODS JSON status file writer + └── switch_root.rs # MS_MOVE new root to / + chroot + exec init +``` + +## License + +MIT OR Apache-2.0 diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..09cf5ed --- /dev/null +++ b/build.rs @@ -0,0 +1,86 @@ +//! Build script for omnect-os-init. +//! +//! Reads Yocto build-time environment variables and generates compile-time +//! constants into `$OUT_DIR/build_config.rs`, included via the `config::build` +//! module. +//! +//! All constants are `Option`: +//! - `None` when the variable is absent (e.g. GRUB systems, local dev builds) +//! - `Some(n)` when Yocto provides the value +//! +//! Yocto variables → Rust constants: +//! | Env var | Constant | Used by | +//! |--------------------------------|-------------------|----------------| +//! | OMNECT_PART_OFFSET_UBOOT_ENV1 | UBOOT_ENV1_START | flash-mode-1 | +//! | OMNECT_PART_OFFSET_UBOOT_ENV2 | UBOOT_ENV2_START | flash-mode-1 | +//! | OMNECT_PART_SIZE_UBOOT_ENV | UBOOT_ENV_SIZE | flash-mode-1 | +//! | OMNECT_PART_SIZE_DATA | DATA_SIZE | flash-mode-1 | +//! | BOOTLOADER_SEEK | BOOTLOADER_START | flash-mode-1 | + +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + // Tell Cargo to re-run this build script whenever any of these env vars change. + println!("cargo:rerun-if-env-changed=OMNECT_PART_OFFSET_UBOOT_ENV1"); + println!("cargo:rerun-if-env-changed=OMNECT_PART_OFFSET_UBOOT_ENV2"); + println!("cargo:rerun-if-env-changed=OMNECT_PART_SIZE_UBOOT_ENV"); + println!("cargo:rerun-if-env-changed=OMNECT_PART_SIZE_DATA"); + println!("cargo:rerun-if-env-changed=BOOTLOADER_SEEK"); + + let uboot_env1_start = read_u64_env("OMNECT_PART_OFFSET_UBOOT_ENV1"); + let uboot_env2_start = read_u64_env("OMNECT_PART_OFFSET_UBOOT_ENV2"); + let uboot_env_size = read_u64_env("OMNECT_PART_SIZE_UBOOT_ENV"); + let data_size = read_u64_env("OMNECT_PART_SIZE_DATA"); + let bootloader_start = read_u64_env("BOOTLOADER_SEEK"); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set")); + let dest = out_dir.join("build_config.rs"); + + let content = format!( + "// Auto-generated by build.rs from Yocto environment variables. Do not edit.\n\ + \n\ + /// Start sector of the first U-Boot environment partition.\n\ + pub const UBOOT_ENV1_START: Option = {uboot_env1_start};\n\ + /// Start sector of the second U-Boot environment partition.\n\ + pub const UBOOT_ENV2_START: Option = {uboot_env2_start};\n\ + /// Size (in bytes) of the U-Boot environment.\n\ + pub const UBOOT_ENV_SIZE: Option = {uboot_env_size};\n\ + /// Initial size of the data partition (in KB).\n\ + pub const DATA_SIZE: Option = {data_size};\n\ + /// Start offset of the bootloader area (optional, platform-specific).\n\ + pub const BOOTLOADER_START: Option = {bootloader_start};\n", + uboot_env1_start = fmt_option(uboot_env1_start), + uboot_env2_start = fmt_option(uboot_env2_start), + uboot_env_size = fmt_option(uboot_env_size), + data_size = fmt_option(data_size), + bootloader_start = fmt_option(bootloader_start), + ); + + fs::write(&dest, content).unwrap_or_else(|e| { + panic!("Failed to write {}: {}", dest.display(), e); + }); +} + +/// Reads an env var and parses it as `u64`. Returns `None` if absent or unparseable. +fn read_u64_env(var: &str) -> Option { + env::var(var).ok().and_then(|v| { + v.trim() + .parse::() + .map_err(|_| { + println!( + "cargo:warning={var} value {:?} is not a valid u64, ignoring", + v + ); + }) + .ok() + }) +} + +fn fmt_option(val: Option) -> String { + match val { + Some(n) => format!("Some({n}_u64)"), + None => "None".to_string(), + } +} diff --git a/project-context.md b/project-context.md new file mode 100644 index 0000000..6d28d29 --- /dev/null +++ b/project-context.md @@ -0,0 +1,56 @@ +# Project Context + +## 1. Architecture & Tech Stack +- **Role:** Initramfs init process for omnect Secure OS +- **Runtime:** Runs as PID 1 in initramfs before switch_root +- **Language:** Rust (safety-critical, no_std-friendly patterns) +- **Target:** Embedded Linux (x86-64 EFI with GRUB, ARM with U-Boot) + +## 2. Key Files +- `src/main.rs`: Entry point, mounts essential filesystems, initializes logging +- `src/lib.rs`: Library exports for all modules +- `src/error.rs`: Hierarchical error types (`InitramfsError`, subsystem errors) +- `src/early_init.rs`: Mounts `/dev`, `/proc`, `/sys` before anything else +- `src/bootloader/mod.rs`: Trait-based abstraction over GRUB/U-Boot +- `src/bootloader/grub.rs`: GRUB implementation using `grub-editenv` +- `src/bootloader/uboot.rs`: U-Boot implementation using `fw_printenv`/`fw_setenv` +- `src/config/mod.rs`: Parses `/proc/cmdline` and `/etc/os-release` +- `src/logging/kmsg.rs`: Writes to `/dev/kmsg` with kernel log levels + +## 3. Build & Test Commands +- **Build:** `cargo build` / `cargo build --release` +- **Check:** `cargo check` +- **Test:** `cargo test` +- **Lint:** `cargo clippy -- -D warnings` +- **Format:** `cargo fmt -- --check` + +## 4. Feature Flags +| Feature | Purpose | +|---------|---------| +| `core` | Default, required functionality | +| `factory-reset` | Backup/wipe/restore operations | +| `flash-mode-1` | Disk cloning | +| `flash-mode-2` | Network flashing | +| `flash-mode-3` | HTTP/HTTPS flashing | +| `resize-data` | Auto-resize data partition | +| `persistent-var-log` | Persistent `/var/log` mount | + +## 5. Runtime Constraints +- **No heap allocator dependency** for early init paths +- **Read-only rootfs:** All state goes to `/data` or bootloader env +- **Logging:** Available only after `/dev` is mounted +- **Exit behavior:** + - Release image: infinite loop on fatal error (prevent reboot loops) + - Debug image: spawn shell for debugging + +## 6. Key Patterns +- **Error handling:** `thiserror` for typed errors, `Result` everywhere +- **Bootloader abstraction:** `dyn Bootloader` trait for GRUB/U-Boot +- **Compression:** fsck exit code and full output stored in bootloader env as gzip+base64(`"exit_code\noutput"`); full output also written to `/data/var/log/fsck/.log` +- **Idempotent mounts:** `is_mounted()` check before mounting + +## 7. Integration Points +- **Kernel cmdline:** `rootpart=`, `rootblk=`, `root=`, `quiet` +- **os-release:** `OMNECT_RELEASE_IMAGE`, `MACHINE_FEATURES`, `DISTRO_FEATURES` +- **Device symlinks:** Creates `/dev/omnect/{boot,rootfs,data,...}` +- **ODS:** Prepares runtime files for `omnect-device-service` \ No newline at end of file diff --git a/src/bootloader/grub.rs b/src/bootloader/grub.rs new file mode 100644 index 0000000..9b91f32 --- /dev/null +++ b/src/bootloader/grub.rs @@ -0,0 +1,191 @@ +//! GRUB bootloader implementation +//! +//! This module provides access to GRUB bootloader environment variables +//! using the `grub-editenv` command. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::bootloader::types::{decode_fsck_output, encode_fsck_output}; +use crate::bootloader::{Bootloader, BootloaderType, Result}; +use crate::error::BootloaderError; + +/// Command name for GRUB environment manipulation +const GRUB_EDITENV_CMD: &str = "/bin/grub-editenv"; + +/// Path to grubenv file relative to boot partition +const GRUBENV_RELATIVE_PATH: &str = "EFI/BOOT/grubenv"; + +/// grubenv key used for boot partition fsck status +const BOOT_FSCK_VAR: &str = "omnect_fsck_boot"; + +/// fsck exit code 2: fsck requests a reboot (filesystem still in inconsistent state) +const FSCK_REBOOT_REQUESTED: i32 = 2; + +/// GRUB bootloader implementation +/// +/// Uses `grub-editenv` to read/write environment variables from the grubenv file. +pub struct GrubBootloader { + grubenv_path: PathBuf, + /// Mount point of the boot partition (parent of EFI/BOOT/grubenv) + boot_dir: PathBuf, +} + +impl GrubBootloader { + /// Create a new GRUB bootloader instance + /// + /// # Arguments + /// * `rootfs_dir` - Path to the mounted rootfs (e.g., `/rootfs`) + /// + /// # Errors + /// Returns an error if the grubenv file doesn't exist + pub fn new(rootfs_dir: &Path) -> Result { + let grubenv_path = rootfs_dir.join("boot").join(GRUBENV_RELATIVE_PATH); + + if !grubenv_path.is_file() { + return Err(BootloaderError::EnvFileNotFound { path: grubenv_path }); + } + + // boot_dir = rootfs/boot (3 levels above grubenv) + let boot_dir = rootfs_dir.join("boot"); + + Ok(Self { + grubenv_path, + boot_dir, + }) + } + + /// Run grub-editenv with the given arguments + fn run_grub_editenv(&self, args: &[&str]) -> Result { + let output = Command::new(GRUB_EDITENV_CMD) + .arg(&self.grubenv_path) + .args(args) + .output() + .map_err(|e| BootloaderError::CommandFailed { + command: GRUB_EDITENV_CMD.to_string(), + reason: e.to_string(), + })?; + + if !output.status.success() { + return Err(BootloaderError::CommandExitCode { + command: GRUB_EDITENV_CMD.to_string(), + code: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } +} + +impl Bootloader for GrubBootloader { + fn get_env(&self, key: &str) -> Result> { + let output = self.run_grub_editenv(&["list"])?; + + for line in output.lines() { + if let Some((k, v)) = line.split_once('=') + && k == key + { + return Ok(Some(v.to_string())); + } + } + + Ok(None) + } + + fn set_env(&mut self, key: &str, value: Option<&str>) -> Result<()> { + match value { + Some(v) => { + let assignment = format!("{}={}", key, v); + self.run_grub_editenv(&["set", &assignment])?; + } + None => { + self.run_grub_editenv(&["unset", key])?; + } + } + Ok(()) + } + + fn save_fsck_status(&mut self, partition: &str, code: i32, output: &str) -> Result<()> { + let encoded = encode_fsck_output(code, output); + + if partition == "boot" { + // When code==2, fsck requests a reboot because the boot partition itself + // is in an inconsistent state. Attempting to write to it at this point + // is unreliable — match legacy bash behaviour and skip. + if code == FSCK_REBOOT_REQUESTED { + log::warn!( + "Skipping fsck status save for boot partition (code 2 — reboot requested)" + ); + return Ok(()); + } + self.set_env(BOOT_FSCK_VAR, Some(&encoded)) + } else { + // For non-boot partitions: write to a file on the boot partition instead + // of grubenv. grubenv is a fixed 1024-byte block — storing multiple large + // encoded blobs there would overflow it. Matches legacy bash behaviour. + let file_path = self.boot_dir.join(format!("fsck.{partition}")); + fs::write(&file_path, &encoded).map_err(|e| BootloaderError::CommandFailed { + command: format!("write {}", file_path.display()), + reason: e.to_string(), + }) + } + } + + fn get_fsck_status(&self, partition: &str) -> Result> { + if partition == "boot" { + Ok(self + .get_env(BOOT_FSCK_VAR)? + .and_then(|v| decode_fsck_output(&v))) + } else { + let file_path = self.boot_dir.join(format!("fsck.{partition}")); + if !file_path.exists() { + return Ok(None); + } + let encoded = + fs::read_to_string(&file_path).map_err(|e| BootloaderError::CommandFailed { + command: format!("read {}", file_path.display()), + reason: e.to_string(), + })?; + // Remove file after reading — matches legacy behaviour + let _ = fs::remove_file(&file_path); + Ok(decode_fsck_output(&encoded)) + } + } + + fn clear_fsck_status(&mut self, partition: &str) -> Result<()> { + if partition == "boot" { + self.set_env(BOOT_FSCK_VAR, None) + } else { + let file_path = self.boot_dir.join(format!("fsck.{partition}")); + if file_path.exists() { + fs::remove_file(&file_path).map_err(|e| BootloaderError::CommandFailed { + command: format!("remove {}", file_path.display()), + reason: e.to_string(), + })?; + } + Ok(()) + } + } + + fn bootloader_type(&self) -> BootloaderType { + BootloaderType::Grub + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_grubenv_path_construction() { + let rootfs = PathBuf::from("/rootfs"); + let expected = PathBuf::from("/rootfs/boot/EFI/BOOT/grubenv"); + + // Can't actually test new() without the file existing + // but we can verify the path construction logic + let path = rootfs.join("boot").join(GRUBENV_RELATIVE_PATH); + assert_eq!(path, expected); + } +} diff --git a/src/bootloader/mod.rs b/src/bootloader/mod.rs new file mode 100644 index 0000000..5361a0b --- /dev/null +++ b/src/bootloader/mod.rs @@ -0,0 +1,212 @@ +//! Bootloader abstraction module +//! +//! This module provides a trait-based abstraction over different bootloaders +//! (GRUB and U-Boot) to allow unified access to bootloader environment variables. + +mod grub; +mod types; +mod uboot; + +use std::path::Path; + +use crate::error::BootloaderError; + +pub use self::grub::GrubBootloader; +pub use self::types::BootloaderType; +pub use self::uboot::UBootBootloader; + +pub type Result = std::result::Result; + +/// Bootloader environment variable names +pub mod vars { + pub const FACTORY_RESET: &str = "factory-reset"; + pub const FLASH_MODE: &str = "flash-mode"; + pub const FLASH_MODE_DEVPATH: &str = "flash-mode-devpath"; + pub const FLASH_MODE_URL: &str = "flash-mode-url"; + pub const RESIZED_DATA: &str = "resized-data"; + pub const OMNECT_VALIDATE_UPDATE: &str = "omnect_validate_update"; + pub const OMNECT_BOOTLOADER_UPDATED: &str = "omnect_bootloader_updated"; + pub const DATA_MOUNT_OPTIONS: &str = "data-mount-options"; +} + +/// Prefix for fsck status variables in bootloader environment +pub const FSCK_VAR_PREFIX: &str = "omnect_fsck_"; + +/// Trait for bootloader environment access +/// +/// This trait abstracts the differences between GRUB and U-Boot bootloader +/// environment access, allowing the rest of the codebase to work with +/// bootloader variables in a unified way. +pub trait Bootloader: Send + Sync { + /// Get the value of a bootloader environment variable + /// + /// Returns `Ok(None)` if the variable doesn't exist. + /// Returns `Err` if there was an error accessing the bootloader environment. + fn get_env(&self, key: &str) -> Result>; + + /// Set or delete a bootloader environment variable + /// + /// Pass `Some(value)` to set the variable, or `None` to delete it. + fn set_env(&mut self, key: &str, value: Option<&str>) -> Result<()>; + + /// Save fsck result to bootloader environment. + /// + /// Stores exit code and full fsck output as gzip+base64 encoded string so the + /// diagnostic text survives the reboot required after fsck corrects errors. + fn save_fsck_status(&mut self, partition: &str, code: i32, output: &str) -> Result<()>; + + /// Get fsck status from bootloader environment. + /// + /// Returns the decoded `(exit_code, output)` pair if a value is present, + /// or `None` if no status was stored for this partition. + fn get_fsck_status(&self, partition: &str) -> Result>; + + /// Clear fsck status from bootloader environment + fn clear_fsck_status(&mut self, partition: &str) -> Result<()>; + + /// Get the bootloader type + fn bootloader_type(&self) -> BootloaderType; +} + +/// Creates the appropriate bootloader implementation based on available tools. +/// +/// Detection logic: +/// - If `grub-editenv` is present in the initramfs (`/bin/grub-editenv`), use GRUB. +/// Must be called after the boot partition is mounted (grubenv lives there). +/// - Otherwise, use U-Boot (assumes fw_printenv/fw_setenv available in initramfs). +pub fn create_bootloader(rootfs_dir: &Path) -> Result> { + // grub-editenv is an initramfs tool, not installed in the rootfs. + const GRUB_EDITENV_INITRAMFS_PATH: &str = "/bin/grub-editenv"; + + if std::path::Path::new(GRUB_EDITENV_INITRAMFS_PATH).exists() { + Ok(Box::new(GrubBootloader::new(rootfs_dir)?)) + } else { + Ok(Box::new(UBootBootloader::new()?)) + } +} + +/// Create a mock bootloader for testing +#[cfg(test)] +pub fn create_mock_bootloader() -> MockBootloader { + MockBootloader::new() +} + +/// Mock bootloader for testing +#[cfg(test)] +pub struct MockBootloader { + env: std::collections::HashMap, +} + +#[cfg(test)] +impl MockBootloader { + pub fn new() -> Self { + Self { + env: std::collections::HashMap::new(), + } + } + + pub fn with_env(mut self, key: &str, value: &str) -> Self { + self.env.insert(key.to_string(), value.to_string()); + self + } +} + +#[cfg(test)] +impl Bootloader for MockBootloader { + fn get_env(&self, key: &str) -> Result> { + Ok(self.env.get(key).cloned()) + } + + fn set_env(&mut self, key: &str, value: Option<&str>) -> Result<()> { + match value { + Some(v) => { + self.env.insert(key.to_string(), v.to_string()); + } + None => { + self.env.remove(key); + } + } + Ok(()) + } + + fn save_fsck_status(&mut self, partition: &str, code: i32, output: &str) -> Result<()> { + use crate::bootloader::types::encode_fsck_output; + let key = format!("omnect_fsck_{}", partition); + self.env.insert(key, encode_fsck_output(code, output)); + Ok(()) + } + + fn get_fsck_status(&self, partition: &str) -> Result> { + use crate::bootloader::types::decode_fsck_output; + let key = format!("omnect_fsck_{}", partition); + Ok(self.env.get(&key).and_then(|v| decode_fsck_output(v))) + } + + fn clear_fsck_status(&mut self, partition: &str) -> Result<()> { + let key = format!("omnect_fsck_{}", partition); + self.env.remove(&key); + Ok(()) + } + + fn bootloader_type(&self) -> BootloaderType { + BootloaderType::Mock + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mock_bootloader_get_set() { + let mut bl = MockBootloader::new(); + + // Test set and get + bl.set_env("test-key", Some("test-value")).unwrap(); + assert_eq!( + bl.get_env("test-key").unwrap(), + Some("test-value".to_string()) + ); + + // Test delete + bl.set_env("test-key", None).unwrap(); + assert_eq!(bl.get_env("test-key").unwrap(), None); + } + + #[test] + fn test_mock_bootloader_with_env() { + let bl = MockBootloader::new() + .with_env("factory-reset", r#"{"mode":1}"#) + .with_env("flash-mode", "1"); + + assert_eq!( + bl.get_env("factory-reset").unwrap(), + Some(r#"{"mode":1}"#.to_string()) + ); + assert_eq!(bl.get_env("flash-mode").unwrap(), Some("1".to_string())); + assert_eq!(bl.get_env("nonexistent").unwrap(), None); + } + + #[test] + fn test_mock_bootloader_fsck_status() { + let mut bl = MockBootloader::new(); + + bl.save_fsck_status("boot", 1, "errors corrected on pass 1") + .unwrap(); + + let retrieved = bl.get_fsck_status("boot").unwrap(); + assert_eq!( + retrieved, + Some((1, "errors corrected on pass 1".to_string())) + ); + + bl.clear_fsck_status("boot").unwrap(); + assert_eq!(bl.get_fsck_status("boot").unwrap(), None); + } + + #[test] + fn test_bootloader_type() { + let bl = MockBootloader::new(); + assert_eq!(bl.bootloader_type(), BootloaderType::Mock); + } +} diff --git a/src/bootloader/types.rs b/src/bootloader/types.rs new file mode 100644 index 0000000..1e63111 --- /dev/null +++ b/src/bootloader/types.rs @@ -0,0 +1,195 @@ +//! Common types for bootloader implementations + +use std::fmt; +use std::io::Write as _; +use std::process::{Command, Stdio}; + +const GZIP_CMD: &str = "/bin/gzip"; +const GUNZIP_CMD: &str = "/bin/gunzip"; +/// busybox base64 applet lives under /bin, not /usr/bin +const BASE64_CMD: &str = "/bin/base64"; + +/// Bootloader type enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootloaderType { + /// GRUB bootloader (typically x86-64 EFI systems) + Grub, + /// U-Boot bootloader (typically ARM systems) + UBoot, + /// Mock bootloader for testing + #[cfg(test)] + Mock, +} + +impl fmt::Display for BootloaderType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Grub => write!(f, "GRUB"), + Self::UBoot => write!(f, "U-Boot"), + #[cfg(test)] + Self::Mock => write!(f, "Mock"), + } + } +} + +/// Encode fsck result for storage in the bootloader environment. +/// +/// Produces `base64(gzip("{code}\n{output}"))` using the busybox `gzip` and +/// `base64` applets that are always present in the initramfs. This matches the +/// legacy bash script encoding so ODS can decode the value identically. +/// +/// Returns an empty string if encoding fails (non-fatal; the plain log file +/// on the data partition still captures the output). +pub fn encode_fsck_output(code: i32, output: &str) -> String { + let raw = format!("{code}\n{output}"); + + // Pipe raw text through `gzip -c` to get compressed bytes. + let gzip_result = (|| -> std::io::Result> { + let mut gzip = Command::new(GZIP_CMD) + .args(["-c"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + gzip.stdin + .take() + .ok_or_else(|| std::io::Error::other("no gzip stdin"))? + .write_all(raw.as_bytes())?; + + let out = gzip.wait_with_output()?; + if !out.status.success() { + return Err(std::io::Error::other(format!( + "gzip exited with status {}", + out.status + ))); + } + Ok(out.stdout) + })(); + + let compressed = match gzip_result { + Ok(c) => c, + Err(e) => { + log::warn!("encode_fsck_output: gzip failed: {e}"); + return String::new(); + } + }; + + // Pipe compressed bytes through `base64 -w 0` (no line wrapping). + let base64_result = (|| -> std::io::Result { + let mut b64 = Command::new(BASE64_CMD) + .args(["-w", "0"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + b64.stdin + .take() + .ok_or_else(|| std::io::Error::other("no base64 stdin"))? + .write_all(&compressed)?; + + let out = b64.wait_with_output()?; + if !out.status.success() { + return Err(std::io::Error::other(format!( + "base64 exited with status {}", + out.status + ))); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + })(); + + match base64_result { + Ok(s) => s, + Err(e) => { + log::warn!("encode_fsck_output: base64 failed: {e}"); + String::new() + } + } +} + +/// Decode a fsck result previously encoded with [`encode_fsck_output`]. +/// +/// Returns `(exit_code, output)` on success, or `None` if decoding fails. +pub fn decode_fsck_output(encoded: &str) -> Option<(i32, String)> { + // Decode base64 → compressed bytes. + let b64_out = Command::new(BASE64_CMD) + .args(["-d"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .ok_or_else(|| std::io::Error::other("no stdin"))? + .write_all(encoded.as_bytes())?; + child.wait_with_output() + }) + .ok() + .filter(|out| out.status.success())?; + + // Decompress gzip → raw text. + let gz_out = Command::new(GUNZIP_CMD) + .args(["-c"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .and_then(|mut child| { + child + .stdin + .take() + .ok_or_else(|| std::io::Error::other("no stdin"))? + .write_all(&b64_out.stdout)?; + child.wait_with_output() + }) + .ok() + .filter(|out| out.status.success())?; + + let raw = String::from_utf8_lossy(&gz_out.stdout); + let (code_str, output) = raw.split_once('\n')?; + let code = code_str.trim().parse::().ok()?; + Some((code, output.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Returns true if all required external commands are available at their + /// expected initramfs paths. Tests that invoke subprocesses are skipped + /// on developer/CI machines where these may live elsewhere (e.g. /usr/bin). + fn commands_available() -> bool { + [GZIP_CMD, GUNZIP_CMD, BASE64_CMD] + .iter() + .all(|cmd| std::path::Path::new(cmd).exists()) + } + + #[test] + fn test_bootloader_type_display() { + assert_eq!(BootloaderType::Grub.to_string(), "GRUB"); + assert_eq!(BootloaderType::UBoot.to_string(), "U-Boot"); + assert_eq!(BootloaderType::Mock.to_string(), "Mock"); + } + + #[test] + fn test_encode_decode_roundtrip() { + if !commands_available() { + eprintln!( + "Skipping test_encode_decode_roundtrip: required commands not at initramfs paths" + ); + return; + } + let code = 1; + let output = "Pass 1: Checking inodes, blocks, and sizes\nErrors corrected."; + let encoded = encode_fsck_output(code, output); + assert!(!encoded.is_empty(), "encoding should succeed"); + let (dec_code, dec_output) = decode_fsck_output(&encoded).unwrap(); + assert_eq!(dec_code, code); + assert_eq!(dec_output, output); + } + + #[test] + fn test_decode_invalid_returns_none() { + assert!(decode_fsck_output("not-valid-base64!!!").is_none()); + assert!(decode_fsck_output("").is_none()); + } +} diff --git a/src/bootloader/uboot.rs b/src/bootloader/uboot.rs new file mode 100644 index 0000000..db7f705 --- /dev/null +++ b/src/bootloader/uboot.rs @@ -0,0 +1,125 @@ +//! U-Boot bootloader implementation +//! +//! This module provides access to U-Boot bootloader environment variables +//! using `fw_printenv` and `fw_setenv` commands. + +use std::process::Command; + +use crate::bootloader::types::{decode_fsck_output, encode_fsck_output}; +use crate::bootloader::{Bootloader, BootloaderType, FSCK_VAR_PREFIX, Result}; +use crate::error::BootloaderError; + +/// Command to read U-Boot environment variables +const FW_PRINTENV_CMD: &str = "/bin/fw_printenv"; + +/// Command to write U-Boot environment variables +const FW_SETENV_CMD: &str = "/bin/fw_setenv"; + +/// U-Boot bootloader implementation +/// +/// Uses `fw_printenv` and `fw_setenv` to access environment variables. +/// Fsck status is stored as gzip+base64 encoded `"exit_code\noutput"` string +/// via busybox subprocess commands to survive the reboot required after fsck. +pub struct UBootBootloader { + // No state needed - commands access environment directly +} + +impl UBootBootloader { + /// Create a new U-Boot bootloader instance + pub fn new() -> Result { + Ok(Self {}) + } + + /// Run fw_printenv to get a variable + fn run_fw_printenv(&self, var: &str) -> Result> { + let output = Command::new(FW_PRINTENV_CMD) + .arg("-n") + .arg(var) + .output() + .map_err(|e| BootloaderError::CommandFailed { + command: FW_PRINTENV_CMD.to_string(), + reason: e.to_string(), + })?; + + // Exit code 1 means the variable was not found — that is a normal condition. + // Any other non-zero code indicates a real failure (bad /etc/fw_env.config, + // I/O error, permission denied, etc.) and must be surfaced as an error. + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + if code == 1 { + return Ok(None); + } + return Err(BootloaderError::CommandExitCode { + command: FW_PRINTENV_CMD.to_string(), + code: Some(code), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() { + Ok(None) + } else { + Ok(Some(value)) + } + } + + /// Run fw_setenv to set or unset a variable + fn run_fw_setenv(&self, var: &str, value: Option<&str>) -> Result<()> { + let mut cmd = Command::new(FW_SETENV_CMD); + cmd.arg(var); + + if let Some(v) = value { + cmd.arg(v); + } + + let output = cmd.output().map_err(|e| BootloaderError::CommandFailed { + command: FW_SETENV_CMD.to_string(), + reason: e.to_string(), + })?; + + if !output.status.success() { + return Err(BootloaderError::CommandExitCode { + command: FW_SETENV_CMD.to_string(), + code: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); + } + + Ok(()) + } +} + +impl Bootloader for UBootBootloader { + fn get_env(&self, key: &str) -> Result> { + self.run_fw_printenv(key) + } + + fn set_env(&mut self, key: &str, value: Option<&str>) -> Result<()> { + self.run_fw_setenv(key, value) + } + + fn save_fsck_status(&mut self, partition: &str, code: i32, output: &str) -> Result<()> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + self.run_fw_setenv(&var_name, Some(&encode_fsck_output(code, output))) + } + + fn get_fsck_status(&self, partition: &str) -> Result> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + Ok(self + .run_fw_printenv(&var_name)? + .and_then(|v| decode_fsck_output(&v))) + } + + fn clear_fsck_status(&mut self, partition: &str) -> Result<()> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + self.run_fw_setenv(&var_name, None) + } + + fn bootloader_type(&self) -> BootloaderType { + BootloaderType::UBoot + } +} + +#[cfg(test)] +mod tests {} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..b7ea7e8 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,262 @@ +//! Configuration module for omnect-os-init +//! +//! This module handles loading configuration from various sources: +//! - Kernel command line (/proc/cmdline) +//! - Environment variables +//! - /etc/os-release +//! +//! Build-time constants generated from Yocto environment variables are +//! available via the `build` submodule. + +/// Build-time constants generated from Yocto environment variables by build.rs. +pub mod build { + include!(concat!(env!("OUT_DIR"), "/build_config.rs")); +} + +use crate::error::{ConfigError, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Runtime configuration for the initramfs +#[derive(Debug, Clone)] +pub struct Config { + /// Path to the rootfs mount point + pub rootfs_dir: PathBuf, + + /// Root partition identifier (e.g., "2" for /dev/sda2) + pub rootpart: Option, + + /// Root block device hint from kernel cmdline + pub rootblk_hint: Option, + + /// Root device from kernel cmdline (e.g., /dev/mmcblk0p2) + pub root_device: Option, + + /// Whether this is a release image + pub is_release_image: bool, + + /// Machine features from os-release + pub machine_features: Vec, + + /// Distro features from os-release + pub distro_features: Vec, + + /// Kernel command line parameters + pub cmdline_params: HashMap, +} + +impl Config { + /// Load configuration from all sources + pub fn load() -> Result { + let cmdline_params = Self::parse_cmdline()?; + + // Get rootfs_dir from environment or use default + let rootfs_dir = std::env::var("ROOTFS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/rootfs")); + + // os-release is not read here: rootfs is not mounted yet at this point. + // Call load_os_release() after mount_partitions() succeeds. + + Ok(Self { + rootfs_dir, + rootpart: cmdline_params.get("rootpart").cloned(), + rootblk_hint: cmdline_params.get("rootblk").cloned(), + root_device: cmdline_params.get("root").cloned(), + is_release_image: false, + machine_features: vec![], + distro_features: vec![], + cmdline_params, + }) + } + + /// Load os-release fields from the mounted rootfs. + /// + /// Must be called after `mount_partitions` so that `rootfs_dir/etc/os-release` + /// exists and reflects the real OS image. + pub fn load_os_release(&mut self) -> Result<()> { + let (is_release, machine_features, distro_features) = + Self::parse_os_release(&self.rootfs_dir)?; + self.is_release_image = is_release; + self.machine_features = machine_features; + self.distro_features = distro_features; + Ok(()) + } + + /// Parse kernel command line parameters + fn parse_cmdline() -> Result> { + let cmdline = fs::read_to_string("/proc/cmdline").map_err(|e| ConfigError::ReadFailed { + path: "/proc/cmdline".to_string(), + reason: e.to_string(), + })?; + let mut params: HashMap = HashMap::new(); + + for part in cmdline.split_whitespace() { + if let Some((key, value)) = part.split_once('=') { + params.insert(key.to_string(), value.to_string()); + } else { + // Boolean parameter (just the key) + params.insert(part.to_string(), String::new()); + } + } + + Ok(params) + } + + /// Parse os-release file for configuration + fn parse_os_release(rootfs_dir: &Path) -> Result<(bool, Vec, Vec)> { + let os_release_path = rootfs_dir.join("etc/os-release"); + let content = match fs::read_to_string(&os_release_path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => { + return Err(ConfigError::ReadFailed { + path: os_release_path.display().to_string(), + reason: e.to_string(), + } + .into()); + } + }; + + let mut is_release = false; + let mut machine_features = vec![]; + let mut distro_features = vec![]; + + for line in content.lines() { + if let Some((key, value)) = line.split_once('=') { + let value = value.trim_matches('"'); + + match key { + "OMNECT_RELEASE_IMAGE" => { + is_release = value == "1"; + } + "MACHINE_FEATURES" => { + machine_features = + value.split_whitespace().map(|s| s.to_string()).collect(); + } + "DISTRO_FEATURES" => { + distro_features = value.split_whitespace().map(|s| s.to_string()).collect(); + } + _ => {} + } + } + } + + Ok((is_release, machine_features, distro_features)) + } + + /// Check if a distro feature is enabled + pub fn has_distro_feature(&self, feature: &str) -> bool { + self.distro_features.iter().any(|f| f == feature) + } + + /// Check if a machine feature is enabled + pub fn has_machine_feature(&self, feature: &str) -> bool { + self.machine_features.iter().any(|f| f == feature) + } + + /// Check if flash-mode-2 is enabled + pub fn has_flash_mode_2(&self) -> bool { + self.has_distro_feature("flash-mode-2") + } + + /// Check if flash-mode-3 is enabled + pub fn has_flash_mode_3(&self) -> bool { + self.has_distro_feature("flash-mode-3") + } + + /// Check if resize-data is enabled + pub fn has_resize_data(&self) -> bool { + self.has_distro_feature("resize-data") + } + + /// Check if persistent-var-log is enabled + pub fn has_persistent_var_log(&self) -> bool { + self.has_distro_feature("persistent-var-log") + } + + /// Check if EFI is supported + pub fn has_efi(&self) -> bool { + self.has_machine_feature("efi") + } + + /// Check if kernel quiet mode is enabled + pub fn is_quiet(&self) -> bool { + self.cmdline_params.contains_key("quiet") + } +} + +impl Default for Config { + fn default() -> Self { + Self { + rootfs_dir: PathBuf::from("/rootfs"), + rootpart: None, + rootblk_hint: None, + root_device: None, + is_release_image: false, + machine_features: vec![], + distro_features: vec![], + cmdline_params: HashMap::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert_eq!(config.rootfs_dir, PathBuf::from("/rootfs")); + assert!(!config.is_release_image); + assert!(config.machine_features.is_empty()); + } + + #[test] + fn test_has_distro_feature() { + let mut config = Config::default(); + config.distro_features = vec!["flash-mode-2".to_string(), "resize-data".to_string()]; + + assert!(config.has_distro_feature("flash-mode-2")); + assert!(config.has_distro_feature("resize-data")); + assert!(!config.has_distro_feature("flash-mode-3")); + } + + #[test] + fn test_has_machine_feature() { + let mut config = Config::default(); + config.machine_features = vec!["efi".to_string(), "tpm2".to_string()]; + + assert!(config.has_efi()); + assert!(config.has_machine_feature("tpm2")); + assert!(!config.has_machine_feature("nonexistent")); + } + + #[test] + fn test_convenience_methods() { + let mut config = Config::default(); + config.distro_features = vec![ + "flash-mode-2".to_string(), + "resize-data".to_string(), + "persistent-var-log".to_string(), + ]; + + assert!(config.has_flash_mode_2()); + assert!(!config.has_flash_mode_3()); + assert!(config.has_resize_data()); + assert!(config.has_persistent_var_log()); + } + + #[test] + fn test_quiet_mode() { + let mut config = Config::default(); + assert!(!config.is_quiet()); + + config + .cmdline_params + .insert("quiet".to_string(), String::new()); + assert!(config.is_quiet()); + } +} diff --git a/src/early_init.rs b/src/early_init.rs new file mode 100644 index 0000000..7df78cc --- /dev/null +++ b/src/early_init.rs @@ -0,0 +1,116 @@ +//! Early initialization before logging is available +//! +//! This module mounts essential filesystems (/dev, /proc, /sys, /run) +//! that must be available before any other initialization can occur. + +use nix::mount::{MsFlags, mount}; +use std::fs; + +use crate::error::EarlyInitError; + +pub type Result = std::result::Result; + +/// Essential filesystem mount points and their configuration +mod mounts { + pub const DEV_PATH: &str = "/dev"; + pub const DEV_FSTYPE: &str = "devtmpfs"; + + pub const PROC_PATH: &str = "/proc"; + pub const PROC_FSTYPE: &str = "proc"; + + pub const SYS_PATH: &str = "/sys"; + pub const SYS_FSTYPE: &str = "sysfs"; + + pub const RUN_PATH: &str = "/run"; + pub const RUN_FSTYPE: &str = "tmpfs"; +} + +/// Path to mount information +const PROC_MOUNTS_PATH: &str = "/proc/mounts"; + +/// Mounts essential filesystems required before any other initialization. +/// +/// Must be called as early as possible, before logging or device access. +/// Order matters: /dev must be first (needed for /dev/kmsg logging). +pub fn mount_essential_filesystems() -> Result<()> { + mount_if_needed( + mounts::DEV_FSTYPE, + mounts::DEV_PATH, + mounts::DEV_FSTYPE, + MsFlags::empty(), + )?; + + mount_if_needed( + mounts::PROC_FSTYPE, + mounts::PROC_PATH, + mounts::PROC_FSTYPE, + MsFlags::empty(), + )?; + + mount_if_needed( + mounts::SYS_FSTYPE, + mounts::SYS_PATH, + mounts::SYS_FSTYPE, + MsFlags::empty(), + )?; + + mount_if_needed( + mounts::RUN_FSTYPE, + mounts::RUN_PATH, + mounts::RUN_FSTYPE, + MsFlags::empty(), + )?; + + // Disable printk rate limiting for /dev/kmsg + // This ensures all init messages are logged without suppression + disable_printk_ratelimit(); + + Ok(()) +} + +/// Disable printk rate limiting for /dev/kmsg +/// +/// By default, the kernel rate-limits messages written to /dev/kmsg. +/// For the init process, we want all messages to be logged. +fn disable_printk_ratelimit() { + // Try to set printk_devkmsg to "on" to disable rate limiting + // This is a best-effort operation - if it fails, we continue anyway + let _ = fs::write("/proc/sys/kernel/printk_devkmsg", "on\n"); +} + +fn mount_if_needed(source: &str, target: &str, fstype: &str, flags: MsFlags) -> Result<()> { + if is_mounted(target)? { + return Ok(()); + } + + mount(Some(source), target, Some(fstype), flags, None::<&str>).map_err(|e| { + EarlyInitError::MountFailed { + target: target.to_string(), + reason: e.to_string(), + } + }) +} + +fn is_mounted(path: &str) -> Result { + // Before /proc is mounted, we can't check - assume not mounted + let mounts = std::fs::read_to_string(PROC_MOUNTS_PATH).unwrap_or_default(); + + Ok(mounts.lines().any(|line| { + line.split_whitespace() + .nth(1) + .is_some_and(|mount_point| mount_point == path) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_mounted_parses_proc_mounts() { + // This test just verifies the parsing logic works + // Actual mount checking requires root privileges + let result = is_mounted("/nonexistent"); + assert!(result.is_ok()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d22d6c7 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,214 @@ +//! Error types for the initramfs +//! +//! This module defines a hierarchy of error types for different subsystems. + +use std::path::PathBuf; + +use thiserror::Error; + +/// Result type alias for the initramfs +pub type Result = std::result::Result; + +/// Top-level error type for the initramfs +#[derive(Error, Debug)] +pub enum InitramfsError { + #[error("Bootloader error: {0}")] + Bootloader(#[from] BootloaderError), + + #[error("Early init error: {0}")] + EarlyInit(#[from] EarlyInitError), + + #[error("Config error: {0}")] + Config(#[from] ConfigError), + + #[error("Partition error: {0}")] + Partition(#[from] PartitionError), + + #[error("Filesystem error: {0}")] + Filesystem(#[from] FilesystemError), + + #[error("Factory reset error: {0}")] + FactoryReset(#[from] FactoryResetError), + + #[error("Flash mode error: {0}")] + FlashMode(#[from] FlashModeError), + + #[error("Logging error: {0}")] + Logging(#[from] LoggingError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors during early initialization (before logging is available) +#[derive(Error, Debug)] +pub enum EarlyInitError { + #[error("Failed to mount {target}: {reason}")] + MountFailed { target: String, reason: String }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to bootloader environment access +#[derive(Error, Debug)] +pub enum BootloaderError { + #[error("Bootloader environment file not found: {}", path.display())] + EnvFileNotFound { path: PathBuf }, + + #[error("Command '{command}' failed: {reason}")] + CommandFailed { command: String, reason: String }, + + #[error("Command '{command}' exited with code {code:?}: {stderr}")] + CommandExitCode { + command: String, + code: Option, + stderr: String, + }, + + #[error("Compression failed: {0}")] + CompressionFailed(String), + + #[error("Decompression failed: {0}")] + DecompressionFailed(String), + + #[error("Invalid environment value for '{key}': {reason}")] + InvalidValue { key: String, reason: String }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to configuration parsing +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Failed to read {path}: {reason}")] + ReadFailed { path: String, reason: String }, + + #[error("Missing required kernel parameter: {0}")] + MissingParameter(String), + + #[error("Invalid parameter value for '{key}': {value}")] + InvalidParameter { key: String, value: String }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to partition detection and management +#[derive(Error, Debug)] +pub enum PartitionError { + #[error("device detection failed: {0}")] + DeviceDetection(String), + + #[error("invalid partition table on {}: {reason}", device.display())] + InvalidPartitionTable { device: PathBuf, reason: String }, + + #[error("symlink creation failed for {} -> {}: {reason}", link.display(), target.display())] + SymlinkFailed { + link: PathBuf, + target: PathBuf, + reason: String, + }, + + #[error("symlink removal failed for {}: {reason}", path.display())] + SymlinkRemoveFailed { path: PathBuf, reason: String }, + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to filesystem operations +#[derive(Error, Debug)] +pub enum FilesystemError { + #[error("Failed to mount {} on {}: {reason}", src_path.display(), target.display())] + MountFailed { + src_path: PathBuf, + target: PathBuf, + reason: String, + }, + + #[error("Failed to unmount {}: {reason}", target.display())] + UnmountFailed { target: PathBuf, reason: String }, + + #[error("Filesystem check failed for {} with code {code}: {output}", device.display())] + FsckFailed { + device: PathBuf, + code: i32, + output: String, + }, + + #[error("Filesystem check for {} requires reboot (fsck exit code {code})", device.display())] + FsckRequiresReboot { + device: PathBuf, + code: i32, + output: String, + }, + + #[error("Overlayfs setup failed for {}: {reason}", target.display())] + OverlayFailed { target: PathBuf, reason: String }, + + #[error("Failed to format {} as {fstype}: {reason}", device.display())] + FormatFailed { + device: PathBuf, + fstype: String, + reason: String, + }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to factory reset operations +#[derive(Error, Debug)] +pub enum FactoryResetError { + #[error("Invalid factory reset configuration: {0}")] + InvalidConfig(String), + + #[error("Backup failed for path '{path}': {reason}")] + BackupFailed { path: String, reason: String }, + + #[error("Restore failed for path '{path}': {reason}")] + RestoreFailed { path: String, reason: String }, + + #[error("Wipe failed for partition '{partition}': {reason}")] + WipeFailed { partition: String, reason: String }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to flash mode operations +#[derive(Error, Debug)] +pub enum FlashModeError { + #[error("Invalid flash mode: {0}")] + InvalidMode(String), + + #[error("Destination device not found: {}", .0.display())] + DestinationNotFound(PathBuf), + + #[error("Clone failed: {0}")] + CloneFailed(String), + + #[error("Network setup failed: {0}")] + NetworkFailed(String), + + #[error("Download failed: {0}")] + DownloadFailed(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// Errors related to logging +#[derive(Error, Debug)] +pub enum LoggingError { + #[error("Failed to open kmsg: {0}")] + KmsgOpenFailed(String), + + #[error("Failed to initialize logger: {0}")] + InitFailed(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/src/filesystem/fsck.rs b/src/filesystem/fsck.rs new file mode 100644 index 0000000..914f96b --- /dev/null +++ b/src/filesystem/fsck.rs @@ -0,0 +1,351 @@ +//! Filesystem check (fsck) operations +//! +//! Runs fsck on partitions before mounting and handles exit codes appropriately. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::FilesystemError; +use crate::filesystem::Result; + +/// fsck command name +const FSCK_CMD: &str = "/sbin/fsck"; + +/// fsck exit codes +mod exit_code { + /// No errors + pub const OK: i32 = 0; + /// Filesystem errors corrected + pub const CORRECTED: i32 = 1; + /// System should be rebooted + pub const REBOOT_REQUIRED: i32 = 2; + /// Filesystem errors left uncorrected + pub const ERRORS_UNCORRECTED: i32 = 4; + /// Operational error + pub const OPERATIONAL_ERROR: i32 = 8; + /// Usage or syntax error + pub const USAGE_ERROR: i32 = 16; + /// Cancelled by user + pub const CANCELLED: i32 = 32; + /// Shared library error + pub const LIBRARY_ERROR: i32 = 128; +} + +/// Result of a filesystem check +#[derive(Debug, Clone)] +pub struct FsckResult { + /// Device that was checked + pub device: PathBuf, + /// Exit code from fsck + pub exit_code: i32, + /// Output from fsck (stdout + stderr) + pub output: String, + /// Whether the check was successful (code 0: clean, or code 1: errors corrected by -y) + pub success: bool, + /// Whether a reboot is required (code 2 only: fsck explicitly requests reboot) + pub reboot_required: bool, +} + +impl FsckResult { + /// Check if there were uncorrected errors + pub fn has_uncorrected_errors(&self) -> bool { + self.exit_code & exit_code::ERRORS_UNCORRECTED != 0 + } + + /// Check if there was an operational error + pub fn has_operational_error(&self) -> bool { + self.exit_code & exit_code::OPERATIONAL_ERROR != 0 + } +} + +/// Run fsck on a device +/// +/// # Arguments +/// * `device` - Path to the block device to check +/// * `auto_repair` - If true, automatically repair errors (-y flag) +/// +/// # Returns +/// * `Ok(FsckResult)` - Result of the check (including exit code 1: errors corrected, safe to mount) +/// * `Err(FilesystemError::FsckRequiresReboot)` - If fsck requests a reboot (exit code 2 only) +/// * `Err(FilesystemError::FsckFailed)` - If check failed with uncorrectable errors +pub fn check_filesystem(device: &Path, auto_repair: bool) -> Result { + log::info!("Running fsck on {}", device.display()); + + // Disable kernel message rate limiting during fsck — RAII guard restores on all exit paths. + disable_kmsg_ratelimit(); + let _ratelimit_guard = KmsgRatelimitGuard; + + let mut cmd = Command::new(FSCK_CMD); + + if auto_repair { + cmd.arg("-y"); // Automatically repair + } + + cmd.arg("-C0"); // per e2fsck(8): -C0 writes progress to stdout + cmd.arg(device); + + let output = cmd.output().map_err(|e| FilesystemError::FsckFailed { + device: device.to_path_buf(), + code: -1, + output: format!("Failed to execute fsck: {}", e), + })?; + + let exit_code = output.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined_output = format!("{}{}", stdout, stderr); + + let result = FsckResult { + device: device.to_path_buf(), + exit_code, + output: combined_output.clone(), + // Exit code 1: errors were corrected by -y. The filesystem is now clean + // and safe to mount — no reboot needed. Matches legacy bash behaviour. + // Exit code 2: fsck explicitly requests a reboot (e.g. kernel needs to + // replay journal). Only this code triggers a reboot. + success: exit_code == exit_code::OK || exit_code == exit_code::CORRECTED, + reboot_required: exit_code & exit_code::REBOOT_REQUIRED != 0, + }; + + // Log the result + if exit_code == exit_code::OK { + log::debug!("fsck: {} is clean", device.display()); + } else if exit_code == exit_code::CORRECTED { + log::info!( + "fsck corrected errors on {} (code 1) — filesystem is clean, continuing", + device.display() + ); + } else if result.reboot_required { + log::warn!( + "fsck on {} requires reboot (code {})", + device.display(), + exit_code + ); + } else { + log::error!( + "fsck failed on {} with code {}: {}", + device.display(), + exit_code, + combined_output.lines().next().unwrap_or("(no output)") + ); + } + + // Exit code 2: fsck requests a reboot before mounting + if result.reboot_required { + return Err(FilesystemError::FsckRequiresReboot { + device: device.to_path_buf(), + code: exit_code, + output: combined_output, + }); + } + + // Exit codes ≥4: uncorrectable errors + if !result.success { + return Err(FilesystemError::FsckFailed { + device: device.to_path_buf(), + code: exit_code, + output: combined_output, + }); + } + + Ok(result) +} + +/// Run fsck on a device, ignoring non-critical errors +/// +/// This variant returns Ok even if fsck reports errors, unless a reboot is required. +/// Useful for partitions where we want to log errors but continue booting. +pub fn check_filesystem_lenient(device: &Path) -> Result { + match check_filesystem(device, true) { + Ok(result) => Ok(result), + Err(FilesystemError::FsckRequiresReboot { + device, + code, + output, + }) => Err(FilesystemError::FsckRequiresReboot { + device, + code, + output, + }), + Err(FilesystemError::FsckFailed { + device, + code, + output, + }) => { + log::warn!( + "fsck on {} had errors (code {}), continuing anyway", + device.display(), + code + ); + Ok(FsckResult { + device, + exit_code: code, + output, + success: false, + reboot_required: false, + }) + } + Err(e) => Err(e), + } +} + +/// RAII guard that re-enables kmsg rate limiting when dropped. +/// Guarantees restoration on all exit paths including early error returns. +struct KmsgRatelimitGuard; + +impl Drop for KmsgRatelimitGuard { + fn drop(&mut self) { + enable_kmsg_ratelimit(); + } +} + +const PRINTK_RATELIMIT_PATH: &str = "/proc/sys/kernel/printk_ratelimit"; +const PRINTK_RATELIMIT_BURST_PATH: &str = "/proc/sys/kernel/printk_ratelimit_burst"; + +use std::sync::Mutex; + +/// Saved rate limit values for restoration +static SAVED_RATELIMIT: Mutex> = Mutex::new(None); + +/// Disable kernel message rate limiting +/// +/// This ensures fsck output isn't throttled in dmesg. +fn disable_kmsg_ratelimit() { + let ratelimit = match std::fs::read_to_string(PRINTK_RATELIMIT_PATH) { + Ok(s) => s.trim().to_string(), + Err(e) => { + log::warn!("Failed to read {PRINTK_RATELIMIT_PATH}: {e}; skipping ratelimit save"); + return; + } + }; + let burst = match std::fs::read_to_string(PRINTK_RATELIMIT_BURST_PATH) { + Ok(s) => s.trim().to_string(), + Err(e) => { + log::warn!( + "Failed to read {PRINTK_RATELIMIT_BURST_PATH}: {e}; skipping ratelimit save" + ); + return; + } + }; + + if let Ok(mut saved) = SAVED_RATELIMIT.lock() { + *saved = Some((ratelimit, burst)); + } + + let _ = std::fs::write(PRINTK_RATELIMIT_PATH, "0"); + let _ = std::fs::write(PRINTK_RATELIMIT_BURST_PATH, "0"); +} + +/// Re-enable kernel message rate limiting +fn enable_kmsg_ratelimit() { + if let Ok(mut saved) = SAVED_RATELIMIT.lock() + && let Some((ratelimit, burst)) = saved.take() + { + let _ = std::fs::write(PRINTK_RATELIMIT_PATH, ratelimit); + let _ = std::fs::write(PRINTK_RATELIMIT_BURST_PATH, burst); + } +} + +/// Parse fsck exit code into human-readable description +pub fn describe_fsck_exit_code(code: i32) -> String { + let mut descriptions = Vec::new(); + + if code == exit_code::OK { + return "No errors".to_string(); + } + + if code & exit_code::CORRECTED != 0 { + descriptions.push("errors corrected"); + } + if code & exit_code::REBOOT_REQUIRED != 0 { + descriptions.push("reboot required"); + } + if code & exit_code::ERRORS_UNCORRECTED != 0 { + descriptions.push("uncorrected errors"); + } + if code & exit_code::OPERATIONAL_ERROR != 0 { + descriptions.push("operational error"); + } + if code & exit_code::USAGE_ERROR != 0 { + descriptions.push("usage error"); + } + if code & exit_code::CANCELLED != 0 { + descriptions.push("cancelled"); + } + if code & exit_code::LIBRARY_ERROR != 0 { + descriptions.push("library error"); + } + + if descriptions.is_empty() { + format!("unknown error (code {})", code) + } else { + descriptions.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_describe_fsck_exit_code_ok() { + assert_eq!(describe_fsck_exit_code(0), "No errors"); + } + + #[test] + fn test_describe_fsck_exit_code_corrected() { + assert_eq!(describe_fsck_exit_code(1), "errors corrected"); + } + + #[test] + fn test_describe_fsck_exit_code_reboot() { + assert_eq!(describe_fsck_exit_code(2), "reboot required"); + } + + #[test] + fn test_describe_fsck_exit_code_combined() { + // Code 3 = CORRECTED | REBOOT_REQUIRED + assert_eq!( + describe_fsck_exit_code(3), + "errors corrected, reboot required" + ); + } + + #[test] + fn test_describe_fsck_exit_code_errors() { + assert_eq!(describe_fsck_exit_code(4), "uncorrected errors"); + } + + #[test] + fn test_fsck_result_has_uncorrected_errors() { + let result = FsckResult { + device: PathBuf::from("/dev/sda1"), + exit_code: 4, + output: String::new(), + success: false, + reboot_required: false, + }; + assert!(result.has_uncorrected_errors()); + + let clean = FsckResult { + device: PathBuf::from("/dev/sda1"), + exit_code: 0, + output: String::new(), + success: true, + reboot_required: false, + }; + assert!(!clean.has_uncorrected_errors()); + } + + #[test] + fn test_fsck_result_has_operational_error() { + let result = FsckResult { + device: PathBuf::from("/dev/sda1"), + exit_code: 8, + output: String::new(), + success: false, + reboot_required: false, + }; + assert!(result.has_operational_error()); + } +} diff --git a/src/filesystem/mod.rs b/src/filesystem/mod.rs new file mode 100644 index 0000000..8c45b7a --- /dev/null +++ b/src/filesystem/mod.rs @@ -0,0 +1,23 @@ +//! Filesystem operations +//! +//! This module handles: +//! - Mounting and unmounting filesystems +//! - Running fsck before mounting +//! - Overlayfs setup for etc and home +//! - Tracking mounts for cleanup on error + +mod fsck; +mod mount; +mod overlayfs; + +pub use self::fsck::{ + FsckResult, check_filesystem, check_filesystem_lenient, describe_fsck_exit_code, +}; +pub use self::mount::{MountManager, MountOptions, MountPoint, is_path_mounted}; +pub use self::overlayfs::{ + OverlayConfig, setup_data_overlay, setup_etc_overlay, setup_raw_rootfs_mount, +}; + +use crate::error::FilesystemError; + +pub type Result = std::result::Result; diff --git a/src/filesystem/mount.rs b/src/filesystem/mount.rs new file mode 100644 index 0000000..c602c72 --- /dev/null +++ b/src/filesystem/mount.rs @@ -0,0 +1,496 @@ +//! Mount operations with tracking for cleanup +//! +//! Provides a MountManager that tracks all mounts and can unmount them +//! in reverse order on error or cleanup. + +use std::path::{Path, PathBuf}; + +use nix::mount::{MntFlags, MsFlags, mount, umount2}; + +use crate::error::FilesystemError; +use crate::filesystem::Result; + +/// Mount flag constants +mod flags { + use nix::mount::MsFlags; + + pub const RDONLY: MsFlags = MsFlags::MS_RDONLY; + pub const BIND: MsFlags = MsFlags::MS_BIND; + pub const PRIVATE: MsFlags = MsFlags::MS_PRIVATE; + pub const REC: MsFlags = MsFlags::MS_REC; + pub const NOATIME: MsFlags = MsFlags::MS_NOATIME; + pub const NOSUID: MsFlags = MsFlags::MS_NOSUID; + pub const NODEV: MsFlags = MsFlags::MS_NODEV; + pub const NOEXEC: MsFlags = MsFlags::MS_NOEXEC; +} + +/// Common filesystem types +mod fstype { + pub const EXT4: &str = "ext4"; + pub const VFAT: &str = "vfat"; + pub const TMPFS: &str = "tmpfs"; +} + +/// Options for mounting a filesystem +#[derive(Debug, Clone)] +pub struct MountOptions { + /// Filesystem type (e.g., "ext4", "vfat", "overlay") + pub fstype: Option, + /// Mount flags + pub flags: MsFlags, + /// Additional mount data/options string + pub data: Option, +} + +impl Default for MountOptions { + fn default() -> Self { + Self { + fstype: None, + flags: MsFlags::empty(), + data: None, + } + } +} + +impl MountOptions { + /// Create options for a read-only ext4 mount + pub fn ext4_readonly() -> Self { + Self { + fstype: Some(fstype::EXT4.to_string()), + flags: flags::RDONLY, + data: None, + } + } + + /// Create options for a read-write ext4 mount + pub fn ext4_readwrite() -> Self { + Self { + fstype: Some(fstype::EXT4.to_string()), + flags: MsFlags::empty(), + data: None, + } + } + + /// Create options for a FAT32 boot partition + pub fn vfat() -> Self { + Self { + fstype: Some(fstype::VFAT.to_string()), + flags: MsFlags::empty(), + data: None, + } + } + + /// Create options for a bind mount + pub fn bind() -> Self { + Self { + fstype: None, + flags: flags::BIND, + data: None, + } + } + + /// Create options for a tmpfs mount + pub fn tmpfs() -> Self { + Self { + fstype: Some(fstype::TMPFS.to_string()), + flags: MsFlags::empty(), + data: None, + } + } + + /// Add read-only flag + pub fn readonly(mut self) -> Self { + self.flags |= flags::RDONLY; + self + } + + /// Add noatime flag + pub fn noatime(mut self) -> Self { + self.flags |= flags::NOATIME; + self + } + + /// Add nosuid flag + pub fn nosuid(mut self) -> Self { + self.flags |= flags::NOSUID; + self + } + + /// Add nodev flag + pub fn nodev(mut self) -> Self { + self.flags |= flags::NODEV; + self + } + + /// Add noexec flag + pub fn noexec(mut self) -> Self { + self.flags |= flags::NOEXEC; + self + } + + /// Set mount data/options string + pub fn with_data(mut self, data: &str) -> Self { + self.data = Some(data.to_string()); + self + } +} + +/// Represents a mounted filesystem +#[derive(Debug, Clone)] +pub struct MountPoint { + /// Source device or path + pub source: PathBuf, + /// Target mount point + pub target: PathBuf, + /// Mount options used + pub options: MountOptions, +} + +impl MountPoint { + /// Create a new mount point definition + pub fn new( + source: impl Into, + target: impl Into, + options: MountOptions, + ) -> Self { + Self { + source: source.into(), + target: target.into(), + options, + } + } +} + +/// Manages filesystem mounts with tracking for cleanup +/// +/// Tracks all mounts made and provides methods to unmount them +/// in reverse order (LIFO) for proper cleanup. +pub struct MountManager { + mounts: Vec, +} + +impl MountManager { + /// Create a new mount manager + pub fn new() -> Self { + Self { mounts: Vec::new() } + } + + /// Mount a filesystem and track it + pub fn mount(&mut self, mp: MountPoint) -> Result<()> { + // Ensure target directory exists + if !mp.target.exists() { + std::fs::create_dir_all(&mp.target).map_err(|e| FilesystemError::MountFailed { + src_path: mp.source.clone(), + target: mp.target.clone(), + reason: format!("Failed to create mount point: {}", e), + })?; + } + + // Perform the mount + let source: Option<&Path> = if mp.source.as_os_str().is_empty() { + None + } else { + Some(&mp.source) + }; + + let fstype: Option<&str> = mp.options.fstype.as_deref(); + let data: Option<&str> = mp.options.data.as_deref(); + + mount(source, &mp.target, fstype, mp.options.flags, data).map_err(|e| { + FilesystemError::MountFailed { + src_path: mp.source.clone(), + target: mp.target.clone(), + reason: e.to_string(), + } + })?; + + log::info!( + "Mounted {} on {} ({})", + mp.source.display(), + mp.target.display(), + mp.options.fstype.as_deref().unwrap_or("bind") + ); + + self.mounts.push(mp); + Ok(()) + } + + /// Mount a filesystem read-only + pub fn mount_readonly( + &mut self, + source: impl Into, + target: impl Into, + fstype: &str, + ) -> Result<()> { + let options = MountOptions { + fstype: Some(fstype.to_string()), + flags: flags::RDONLY, + data: None, + }; + self.mount(MountPoint::new(source, target, options)) + } + + /// Mount a filesystem read-write + pub fn mount_readwrite( + &mut self, + source: impl Into, + target: impl Into, + fstype: &str, + ) -> Result<()> { + let options = MountOptions { + fstype: Some(fstype.to_string()), + flags: MsFlags::empty(), + data: None, + }; + self.mount(MountPoint::new(source, target, options)) + } + + /// Mount a tmpfs filesystem + pub fn mount_tmpfs( + &mut self, + target: impl Into, + flags: MsFlags, + data: Option<&str>, + ) -> Result<()> { + let options = MountOptions { + fstype: Some(fstype::TMPFS.to_string()), + flags, + data: data.map(|s| s.to_string()), + }; + self.mount(MountPoint::new("tmpfs", target, options)) + } + + /// Create a bind mount + pub fn mount_bind( + &mut self, + source: impl Into, + target: impl Into, + ) -> Result<()> { + self.mount(MountPoint::new(source, target, MountOptions::bind())) + } + + /// Create a private bind mount (doesn't propagate submounts) + pub fn mount_bind_private( + &mut self, + source: impl Into, + target: impl Into, + ) -> Result<()> { + let source = source.into(); + let target = target.into(); + + // First, create the bind mount + self.mount(MountPoint::new( + source.clone(), + target.clone(), + MountOptions::bind(), + ))?; + + // Then make it private (remount with MS_PRIVATE) + self.make_private(&target)?; + + Ok(()) + } + + /// Make a mount point private (no propagation) + pub fn make_private(&mut self, target: &Path) -> Result<()> { + mount( + None::<&str>, + target, + None::<&str>, + flags::PRIVATE | flags::REC, + None::<&str>, + ) + .map_err(|e| FilesystemError::MountFailed { + src_path: PathBuf::new(), + target: target.to_path_buf(), + reason: format!("Failed to make mount private: {}", e), + })?; + + log::debug!("Made {} private", target.display()); + Ok(()) + } + + /// Unmount a specific target + pub fn umount(&mut self, target: &Path) -> Result<()> { + umount2(target, MntFlags::empty()).map_err(|e| FilesystemError::UnmountFailed { + target: target.to_path_buf(), + reason: e.to_string(), + })?; + + // Remove from tracking + self.mounts.retain(|mp| mp.target != target); + + log::info!("Unmounted {}", target.display()); + Ok(()) + } + + /// Unmount all tracked mounts in reverse order + /// + /// Continues on error, collecting all errors. + pub fn umount_all(&mut self) -> Result<()> { + let mut errors = Vec::new(); + + // Unmount in reverse order (LIFO) + while let Some(mp) = self.mounts.pop() { + if let Err(e) = umount2(&mp.target, MntFlags::empty()) { + log::warn!("Failed to unmount {}: {}", mp.target.display(), e); + errors.push(FilesystemError::UnmountFailed { + target: mp.target, + reason: e.to_string(), + }); + } else { + log::info!("Unmounted {}", mp.target.display()); + } + } + + if let Some(first_error) = errors.into_iter().next() { + Err(first_error) + } else { + Ok(()) + } + } + + /// Get the number of tracked mounts + pub fn mount_count(&self) -> usize { + self.mounts.len() + } + + /// Check if a path is currently mounted (tracked) + pub fn is_mounted(&self, target: &Path) -> bool { + self.mounts.iter().any(|mp| mp.target == target) + } + + /// Get all tracked mount points + pub fn mounts(&self) -> &[MountPoint] { + &self.mounts + } + + /// Forget all tracked mounts without unmounting them. + /// + /// Call this immediately before exec-ing into the new root so that + /// the Drop impl does not tear down mounts that must survive into + /// the new userspace. + pub fn release(&mut self) { + self.mounts.clear(); + } +} + +impl Default for MountManager { + fn default() -> Self { + Self::new() + } +} + +impl Drop for MountManager { + fn drop(&mut self) { + if !self.mounts.is_empty() { + log::warn!( + "MountManager dropped with {} active mounts - unmounting", + self.mounts.len() + ); + let _ = self.umount_all(); + } + } +} + +/// Check if a path is mounted by reading /proc/mounts +pub fn is_path_mounted(path: &Path) -> Result { + let mounts = + std::fs::read_to_string("/proc/mounts").map_err(|e| FilesystemError::MountFailed { + src_path: PathBuf::new(), + target: path.to_path_buf(), + reason: format!("Failed to read /proc/mounts: {e}"), + })?; + let path_str = path.to_string_lossy(); + + Ok(mounts.lines().any(|line| { + line.split_whitespace() + .nth(1) + .is_some_and(|mount_point| mount_point == path_str) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mount_options_default() { + let opts = MountOptions::default(); + assert!(opts.fstype.is_none()); + assert!(opts.flags.is_empty()); + assert!(opts.data.is_none()); + } + + #[test] + fn test_mount_options_ext4_readonly() { + let opts = MountOptions::ext4_readonly(); + assert_eq!(opts.fstype, Some("ext4".to_string())); + assert!(opts.flags.contains(MsFlags::MS_RDONLY)); + } + + #[test] + fn test_mount_options_builder() { + let opts = MountOptions::ext4_readwrite() + .noatime() + .nosuid() + .with_data("discard"); + + assert_eq!(opts.fstype, Some("ext4".to_string())); + assert!(opts.flags.contains(MsFlags::MS_NOATIME)); + assert!(opts.flags.contains(MsFlags::MS_NOSUID)); + assert!(!opts.flags.contains(MsFlags::MS_RDONLY)); + assert_eq!(opts.data, Some("discard".to_string())); + } + + #[test] + fn test_mount_point_new() { + let mp = MountPoint::new("/dev/sda1", "/mnt/boot", MountOptions::vfat()); + assert_eq!(mp.source, PathBuf::from("/dev/sda1")); + assert_eq!(mp.target, PathBuf::from("/mnt/boot")); + assert_eq!(mp.options.fstype, Some("vfat".to_string())); + } + + #[test] + fn test_mount_manager_new() { + let mm = MountManager::new(); + assert_eq!(mm.mount_count(), 0); + } + + #[test] + fn test_mount_manager_tracking() { + let mut mm = MountManager::new(); + + // Manually add a mount point for testing (without actually mounting) + mm.mounts.push(MountPoint::new( + "/dev/sda1", + "/mnt/test", + MountOptions::ext4_readonly(), + )); + + assert_eq!(mm.mount_count(), 1); + assert!(mm.is_mounted(Path::new("/mnt/test"))); + assert!(!mm.is_mounted(Path::new("/mnt/other"))); + } + + #[test] + fn test_mount_manager_mounts_accessor() { + let mut mm = MountManager::new(); + + mm.mounts.push(MountPoint::new( + "/dev/sda1", + "/mnt/a", + MountOptions::default(), + )); + mm.mounts.push(MountPoint::new( + "/dev/sda2", + "/mnt/b", + MountOptions::default(), + )); + + let mounts = mm.mounts(); + assert_eq!(mounts.len(), 2); + assert_eq!(mounts[0].target, PathBuf::from("/mnt/a")); + assert_eq!(mounts[1].target, PathBuf::from("/mnt/b")); + } +} diff --git a/src/filesystem/overlayfs.rs b/src/filesystem/overlayfs.rs new file mode 100644 index 0000000..6c4323e --- /dev/null +++ b/src/filesystem/overlayfs.rs @@ -0,0 +1,448 @@ +//! Overlayfs setup for etc and home directories +//! +//! This module handles: +//! - Setting up overlayfs for /etc (factory defaults + persistent upper) +//! - Setting up overlayfs for /home (factory defaults + data upper) +//! - Bind mounts for /var/lib and /usr/local +//! - Initial copy of factory etc to upper layer + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use nix::mount::MsFlags; + +use crate::error::FilesystemError; +use crate::filesystem::{MountManager, MountOptions, MountPoint, Result}; + +/// Overlay filesystem type +const OVERLAY_FSTYPE: &str = "overlay"; +/// cp command for copying directory contents (preserves attributes via -a) +const CP_CMD: &str = "/bin/cp"; + +/// Directory names for overlay layers +mod overlay_dirs { + pub const UPPER: &str = "upper"; + pub const WORK: &str = "work"; +} + +/// Standard paths relative to rootfs +mod paths { + pub const ETC: &str = "etc"; + pub const HOME: &str = "home"; + pub const VAR_LIB: &str = "var/lib"; + pub const USR_LOCAL: &str = "usr/local"; + pub const VAR_LOG: &str = "var/log"; +} + +/// Mount point paths for partitions +mod mount_points { + pub const ETC_PARTITION: &str = "mnt/etc"; + pub const DATA_PARTITION: &str = "mnt/data"; + pub const FACTORY_PARTITION: &str = "mnt/factory"; +} + +/// Configuration for overlay setup +#[derive(Debug, Clone)] +pub struct OverlayConfig { + /// Root filesystem directory (e.g., /rootfs) + pub rootfs_dir: PathBuf, + /// Whether to enable persistent /var/log + pub persistent_var_log: bool, + /// Additional mount options for data partition + pub data_mount_options: Option, +} + +impl OverlayConfig { + /// Create a new overlay configuration + pub fn new(rootfs_dir: impl Into) -> Self { + Self { + rootfs_dir: rootfs_dir.into(), + persistent_var_log: false, + data_mount_options: None, + } + } + + /// Enable persistent /var/log + pub fn with_persistent_var_log(mut self, enabled: bool) -> Self { + self.persistent_var_log = enabled; + self + } + + /// Set additional data mount options + pub fn with_data_mount_options(mut self, options: Option) -> Self { + self.data_mount_options = options; + self + } +} + +/// Setup the etc partition with overlayfs +/// +/// Creates an overlay where: +/// - Lower layer: rootfs/etc (read-only from current OS) +/// - Upper layer: mnt/etc/upper (persistent changes) +/// - Work dir: mnt/etc/work +/// - Target: rootfs/etc +pub fn setup_etc_overlay(mm: &mut MountManager, config: &OverlayConfig) -> Result<()> { + let rootfs = &config.rootfs_dir; + let etc_mount = rootfs.join(mount_points::ETC_PARTITION); + let factory_mount = rootfs.join(mount_points::FACTORY_PARTITION); + + // Overlay directories + let upper_dir = etc_mount.join(overlay_dirs::UPPER); + let work_dir = etc_mount.join(overlay_dirs::WORK); + let lower_dir = rootfs.join(paths::ETC); + let target = rootfs.join(paths::ETC); + + // Factory etc is only used for first-boot initialization + let factory_etc = factory_mount.join(paths::ETC); + + // Ensure directories exist + ensure_overlay_dirs(&upper_dir, &work_dir)?; + + // Check if this is first boot (upper is empty) + let is_first_boot = is_directory_empty(&upper_dir)?; + + if is_first_boot { + log::info!("First boot detected - copying factory etc to upper layer"); + copy_directory_contents(&factory_etc, &upper_dir)?; + } + + // Mount the overlay + mount_overlay(mm, &lower_dir, &upper_dir, &work_dir, &target)?; + + log::info!( + "Setup etc overlay: lower={}, upper={} -> {}", + lower_dir.display(), + upper_dir.display(), + target.display() + ); + + Ok(()) +} + +/// Setup the data partition with home overlayfs and bind mounts +/// +/// Creates: +/// - Overlay for /home (rootfs/home lower, data/home/upper upper) +/// - Bind mount: data/var/lib -> rootfs/var/lib +/// - Bind mount: data/local -> rootfs/usr/local +/// - Optional: data/var/log -> rootfs/var/log (if persistent_var_log enabled) +pub fn setup_data_overlay(mm: &mut MountManager, config: &OverlayConfig) -> Result<()> { + let rootfs = &config.rootfs_dir; + let data_mount = rootfs.join(mount_points::DATA_PARTITION); + + // Setup home overlay (no factory_mount parameter needed) + setup_home_overlay(mm, rootfs, &data_mount)?; + + // Setup bind mounts + setup_var_lib_bind(mm, rootfs, &data_mount)?; + setup_usr_local_bind(mm, rootfs, &data_mount)?; + + // Optional: persistent /var/log + if config.persistent_var_log { + setup_var_log_bind(mm, rootfs, &data_mount)?; + } + + Ok(()) +} + +/// Setup home directory overlay +fn setup_home_overlay(mm: &mut MountManager, rootfs: &Path, data_mount: &Path) -> Result<()> { + let home_data = data_mount.join(paths::HOME); + let upper_dir = home_data.join(overlay_dirs::UPPER); + let work_dir = home_data.join(overlay_dirs::WORK); + let lower_dir = rootfs.join(paths::HOME); + let target = rootfs.join(paths::HOME); + + // Ensure directories exist + ensure_dir(&home_data)?; + ensure_overlay_dirs(&upper_dir, &work_dir)?; + + // Mount the overlay with rootfs/home as lower layer + mount_overlay(mm, &lower_dir, &upper_dir, &work_dir, &target)?; + + log::info!( + "Setup home overlay: lower={}, upper={} -> {}", + lower_dir.display(), + upper_dir.display(), + target.display() + ); + + Ok(()) +} + +/// Setup bind mount for /var/lib +fn setup_var_lib_bind(mm: &mut MountManager, rootfs: &Path, data_mount: &Path) -> Result<()> { + let source = data_mount.join(paths::VAR_LIB); + let target = rootfs.join(paths::VAR_LIB); + + ensure_dir(&source)?; + ensure_dir(&target)?; + + mm.mount_bind(&source, &target)?; + + log::info!("Bind mounted {} -> {}", source.display(), target.display()); + + Ok(()) +} + +/// Setup bind mount for /usr/local +fn setup_usr_local_bind(mm: &mut MountManager, rootfs: &Path, data_mount: &Path) -> Result<()> { + // Data partition uses "local" instead of "usr/local" + let source = data_mount.join("local"); + let target = rootfs.join(paths::USR_LOCAL); + + ensure_dir(&source)?; + ensure_dir(&target)?; + + mm.mount_bind(&source, &target)?; + + log::info!("Bind mounted {} -> {}", source.display(), target.display()); + + Ok(()) +} + +/// Setup bind mount for persistent /var/log +fn setup_var_log_bind(mm: &mut MountManager, rootfs: &Path, data_mount: &Path) -> Result<()> { + let source = data_mount.join(paths::VAR_LOG); + let target = rootfs.join(paths::VAR_LOG); + + ensure_dir(&source)?; + ensure_dir(&target)?; + + mm.mount_bind(&source, &target)?; + + log::info!( + "Bind mounted persistent var/log: {} -> {}", + source.display(), + target.display() + ); + + Ok(()) +} + +/// Mount an overlayfs +fn mount_overlay( + mm: &mut MountManager, + lower: &Path, + upper: &Path, + work: &Path, + target: &Path, +) -> Result<()> { + let options = format!( + "lowerdir={},upperdir={},workdir={}", + lower.display(), + upper.display(), + work.display() + ); + + let mount_opts = MountOptions { + fstype: Some(OVERLAY_FSTYPE.to_string()), + flags: MsFlags::empty(), + data: Some(options.clone()), + }; + + mm.mount(MountPoint::new(OVERLAY_FSTYPE, target, mount_opts)) + .map_err(|e| FilesystemError::OverlayFailed { + target: target.to_path_buf(), + reason: format!("{}: options={}", e, options), + })?; + + Ok(()) +} + +/// Ensure overlay directories (upper and work) exist +fn ensure_overlay_dirs(upper: &Path, work: &Path) -> Result<()> { + ensure_dir(upper)?; + ensure_dir(work)?; + Ok(()) +} + +/// Ensure a directory exists, creating it if necessary +fn ensure_dir(path: &Path) -> Result<()> { + if !path.exists() { + fs::create_dir_all(path).map_err(|e| FilesystemError::OverlayFailed { + target: path.to_path_buf(), + reason: format!("Failed to create directory: {}", e), + })?; + } + Ok(()) +} + +/// Check if a directory is empty +fn is_directory_empty(path: &Path) -> Result { + if !path.exists() { + return Ok(true); + } + + let entries = fs::read_dir(path).map_err(|e| FilesystemError::OverlayFailed { + target: path.to_path_buf(), + reason: format!("Failed to read directory: {}", e), + })?; + + Ok(entries.count() == 0) +} + +/// Copy contents of one directory to another +/// +/// Uses `cp -a` for proper attribute preservation. +fn copy_directory_contents(src: &Path, dst: &Path) -> Result<()> { + if !src.exists() { + log::warn!("Source directory does not exist: {}", src.display()); + return Ok(()); + } + + // Use cp -a to preserve all attributes + let output = Command::new(CP_CMD) + .arg("-a") + .arg(format!("{}/.", src.display())) + .arg(dst) + .output() + .map_err(|e| FilesystemError::OverlayFailed { + target: dst.to_path_buf(), + reason: format!("Failed to execute cp: {}", e), + })?; + + if !output.status.success() { + return Err(FilesystemError::OverlayFailed { + target: dst.to_path_buf(), + reason: format!("cp failed: {}", String::from_utf8_lossy(&output.stderr)), + }); + } + + log::debug!("Copied {} -> {}", src.display(), dst.display()); + + Ok(()) +} + +/// Setup raw rootfs bind mount (must be called BEFORE overlays) +/// +/// Creates a private bind mount at /mnt/rootCurrentPrivate that provides +/// access to the raw rootfs without overlay modifications. +pub fn setup_raw_rootfs_mount(mm: &mut MountManager, rootfs_dir: &Path) -> Result<()> { + let raw_mount = rootfs_dir.join("mnt/rootCurrentPrivate"); + + ensure_dir(&raw_mount)?; + + // Create private bind mount + mm.mount_bind_private(rootfs_dir, &raw_mount)?; + + log::info!( + "Created raw rootfs mount: {} -> {}", + rootfs_dir.display(), + raw_mount.display() + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_overlay_config_new() { + let config = OverlayConfig::new("/rootfs"); + assert_eq!(config.rootfs_dir, PathBuf::from("/rootfs")); + assert!(!config.persistent_var_log); + assert!(config.data_mount_options.is_none()); + } + + #[test] + fn test_overlay_config_builder() { + let config = OverlayConfig::new("/rootfs") + .with_persistent_var_log(true) + .with_data_mount_options(Some("discard".to_string())); + + assert!(config.persistent_var_log); + assert_eq!(config.data_mount_options, Some("discard".to_string())); + } + + #[test] + fn test_ensure_dir_creates_directory() { + let temp = TempDir::new().unwrap(); + let new_dir = temp.path().join("test/nested/dir"); + + assert!(!new_dir.exists()); + ensure_dir(&new_dir).unwrap(); + assert!(new_dir.exists()); + } + + #[test] + fn test_ensure_dir_existing() { + let temp = TempDir::new().unwrap(); + let existing = temp.path(); + + assert!(existing.exists()); + ensure_dir(existing).unwrap(); + assert!(existing.exists()); + } + + #[test] + fn test_is_directory_empty_true() { + let temp = TempDir::new().unwrap(); + assert!(is_directory_empty(temp.path()).unwrap()); + } + + #[test] + fn test_is_directory_empty_false() { + let temp = TempDir::new().unwrap(); + fs::write(temp.path().join("file.txt"), "content").unwrap(); + assert!(!is_directory_empty(temp.path()).unwrap()); + } + + #[test] + fn test_is_directory_empty_nonexistent() { + let path = PathBuf::from("/nonexistent/path"); + assert!(is_directory_empty(&path).unwrap()); + } + + #[test] + fn test_ensure_overlay_dirs() { + let temp = TempDir::new().unwrap(); + let upper = temp.path().join("upper"); + let work = temp.path().join("work"); + + ensure_overlay_dirs(&upper, &work).unwrap(); + + assert!(upper.exists()); + assert!(work.exists()); + } + + #[test] + fn test_copy_directory_contents() { + let temp = TempDir::new().unwrap(); + let src = temp.path().join("src"); + let dst = temp.path().join("dst"); + + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dst).unwrap(); + fs::write(src.join("file1.txt"), "content1").unwrap(); + fs::create_dir_all(src.join("subdir")).unwrap(); + fs::write(src.join("subdir/file2.txt"), "content2").unwrap(); + + copy_directory_contents(&src, &dst).unwrap(); + + assert!(dst.join("file1.txt").exists()); + assert!(dst.join("subdir/file2.txt").exists()); + assert_eq!( + fs::read_to_string(dst.join("file1.txt")).unwrap(), + "content1" + ); + } + + #[test] + fn test_copy_directory_nonexistent_source() { + let temp = TempDir::new().unwrap(); + let src = temp.path().join("nonexistent"); + let dst = temp.path().join("dst"); + + fs::create_dir_all(&dst).unwrap(); + + // Should not error on nonexistent source + let result = copy_directory_contents(&src, &dst); + assert!(result.is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..28a3a0c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +//! omnect-os-init library +//! +//! This library provides the core functionality for the omnect-os init process. +//! It replaces the bash-based initramfs scripts with a type-safe Rust implementation. + +pub mod bootloader; +pub mod config; +pub mod early_init; +pub mod error; +pub mod filesystem; +pub mod logging; +pub mod partition; +pub mod runtime; + +// Re-export main types for convenience +pub use crate::bootloader::{Bootloader, BootloaderType, create_bootloader}; +pub use crate::config::Config; +pub use crate::early_init::mount_essential_filesystems; +pub use crate::error::{InitramfsError, Result}; +pub use crate::logging::KmsgLogger; diff --git a/src/logging/kmsg.rs b/src/logging/kmsg.rs new file mode 100644 index 0000000..a73d845 --- /dev/null +++ b/src/logging/kmsg.rs @@ -0,0 +1,127 @@ +//! Kernel message buffer (kmsg) logging +//! +//! This module provides a logger that writes to /dev/kmsg with proper +//! kernel log level prefixes. + +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::sync::Mutex; + +use log::{Level, Log, Metadata, Record, SetLoggerError}; + +/// Kernel log level prefixes (see kernel Documentation/admin-guide/serial-console.rst) +mod kernel_level { + pub const CRIT: &str = "<2>"; + pub const ERR: &str = "<3>"; + pub const WARNING: &str = "<4>"; + pub const INFO: &str = "<6>"; + pub const DEBUG: &str = "<7>"; +} + +/// Log message prefix for all omnect-os-init messages +const LOG_PREFIX: &str = "omnect-os-initramfs: "; + +/// Path to kernel message buffer +const KMSG_PATH: &str = "/dev/kmsg"; + +/// Logger that writes to /dev/kmsg +pub struct KmsgLogger { + kmsg: Mutex, +} + +impl KmsgLogger { + /// Create a new kmsg logger + /// + /// # Errors + /// Returns an error if /dev/kmsg cannot be opened for writing + pub fn new() -> std::io::Result { + let file = OpenOptions::new().write(true).open(KMSG_PATH)?; + + Ok(Self { + kmsg: Mutex::new(file), + }) + } + + /// Initialize the global logger with kmsg output + /// + /// Convenience method that creates a new logger and sets it as global. + /// + /// # Errors + /// Returns an error if /dev/kmsg cannot be opened or a logger is already set + pub fn init_global() -> std::result::Result<(), String> { + let logger = Self::new().map_err(|e| format!("Failed to open kmsg: {}", e))?; + logger + .init() + .map_err(|e| format!("Failed to set logger: {}", e)) + } + + /// Initialize this logger as the global logger + /// + /// # Errors + /// Returns an error if a logger has already been set + pub fn init(self) -> std::result::Result<(), SetLoggerError> { + log::set_max_level(log::LevelFilter::Debug); + log::set_boxed_logger(Box::new(self)) + } + + fn level_to_kernel_prefix(level: Level) -> &'static str { + match level { + Level::Error => kernel_level::ERR, + Level::Warn => kernel_level::WARNING, + Level::Info => kernel_level::INFO, + Level::Debug => kernel_level::DEBUG, + Level::Trace => kernel_level::DEBUG, + } + } +} + +impl Log for KmsgLogger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + + let prefix = Self::level_to_kernel_prefix(record.level()); + let message = format!("{}{}{}\n", prefix, LOG_PREFIX, record.args()); + + if let Ok(mut kmsg) = self.kmsg.lock() { + // Ignore write errors - nothing we can do if kmsg fails + let _ = kmsg.write_all(message.as_bytes()); + } + } + + fn flush(&self) { + if let Ok(mut kmsg) = self.kmsg.lock() { + let _ = kmsg.flush(); + } + } +} + +/// Write a fatal message to kmsg and prepare for system halt +/// +/// This function is used when a fatal error occurs and we need to +/// log before potentially halting the system. +pub fn log_fatal(message: &str) { + if let Ok(mut file) = OpenOptions::new().write(true).open(KMSG_PATH) { + let _ = writeln!( + file, + "{}{}FATAL: {}", + kernel_level::CRIT, + LOG_PREFIX, + message + ); + } +} + +/// Write directly to kmsg without going through the logger +/// +/// Useful for early initialization before the logger is set up. +pub fn log_direct(message: &str) { + if let Ok(mut file) = OpenOptions::new().write(true).open(KMSG_PATH) { + let _ = writeln!(file, "{}{}{}", kernel_level::INFO, LOG_PREFIX, message); + } +} diff --git a/src/logging/mod.rs b/src/logging/mod.rs new file mode 100644 index 0000000..27956fc --- /dev/null +++ b/src/logging/mod.rs @@ -0,0 +1,7 @@ +//! Logging infrastructure for initramfs +//! +//! This module provides logging to /dev/kmsg with kernel log levels. + +mod kmsg; + +pub use self::kmsg::{KmsgLogger, log_direct, log_fatal}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6dbfa83 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,381 @@ +//! omnect-os-init - Rust-based init process for omnect-os initramfs +//! +//! This binary replaces the bash-based initramfs scripts with a type-safe +//! Rust implementation. + +use nix::mount::MsFlags; +use std::fs; +use std::path::Path; +use std::process; +use std::thread; +use std::time::Duration; + +use log::{error, info, warn}; + +use omnect_os_init::{ + Result, + bootloader::Bootloader, + bootloader::create_bootloader, + config::Config, + error::{FilesystemError, InitramfsError, PartitionError}, + filesystem::{ + MountManager, OverlayConfig, check_filesystem_lenient, setup_data_overlay, + setup_etc_overlay, setup_raw_rootfs_mount, + }, + logging::{KmsgLogger, log_fatal}, + mount_essential_filesystems, + partition::{PartitionLayout, create_omnect_symlinks, detect_root_device, partition_names}, + runtime::{OdsStatus, create_fs_links, create_ods_runtime_files, switch_root}, +}; + +/// Sleep duration for fatal error loop (seconds) +const FATAL_ERROR_SLEEP_SECS: u64 = 60; +const BASH_CMD: &str = "/bin/bash"; +const SH_CMD: &str = "/bin/sh"; + +fn main() { + // Mount essential filesystems first (/dev, /proc, /sys, /run) + if let Err(e) = mount_essential_filesystems() { + eprintln!("FATAL: Failed to mount essential filesystems: {}", e); + spawn_emergency_shell(); + } + + // Determine release mode from /proc/cmdline — rootfs is not yet mounted + // so os-release cannot be read here. This value is intentionally kept + // separate from config.is_release_image (updated inside run()): if run() + // fails at any point, this cmdline-derived value is the only safe fallback + // for handle_fatal_error, which must decide debug vs. release behavior + // before the rootfs is available. + let is_release_image = match fs::read_to_string("/proc/cmdline") { + Ok(s) => s.split_whitespace().any(|p| p == "omnect_release_image=1"), + Err(e) => { + eprintln!("Warning: failed to read /proc/cmdline: {e}; defaulting to debug mode"); + false + } + }; + + // Initialize logging + match KmsgLogger::new() { + Ok(logger) => { + if let Err(e) = logger.init() { + log_fatal(&format!("Logger initialization failed: {}", e)); + } + } + Err(e) => { + log_fatal(&format!("Failed to open kmsg: {}", e)); + } + } + + // Run main initialization + if let Err(e) = run() { + error!("Initramfs failed: {}", e); + handle_fatal_error(e, is_release_image); + } +} + +fn run() -> Result<()> { + info!("omnect-os-initramfs starting"); + + // Load configuration + let mut config = Config::load()?; + info!( + "Configuration loaded: rootfs_dir={}, release={}", + config.rootfs_dir.display(), + config.is_release_image + ); + + // Initialize mount manager for tracking + let mut mount_manager = MountManager::new(); + + // Detect root device + info!("Detecting root device..."); + let root_device = detect_root_device()?; + info!( + "Root device: {} (partition {})", + root_device.base.display(), + root_device.root_partition.display() + ); + + // Detect partition layout + let layout = PartitionLayout::detect(root_device)?; + info!("Partition table: {}", layout.table_type); + + // Create /dev/omnect/* symlinks + create_omnect_symlinks(&layout)?; + + // Initialize ODS status + let mut ods_status = OdsStatus::new(); + + // Run fsck on partitions and mount them. + // Boot partition must be mounted before create_bootloader() so that + // GrubBootloader can access the grubenv file at rootfs/boot/EFI/BOOT/grubenv. + let mount_result = mount_partitions(&mut mount_manager, &layout, &config, &mut ods_status); + + // Attempt to create bootloader and persist fsck results before propagating any + // mount error. This ensures results are stored even on the FsckRequiresReboot + // reboot path. For GRUB: requires boot partition mounted; best-effort if it isn't. + let mut bootloader_result = create_bootloader(&config.rootfs_dir); + if let Ok(ref mut bl) = bootloader_result { + info!("Bootloader type: {}", bl.bootloader_type()); + // Persist fsck results: gzip+base64 encoded output (code + full text) to + // bootloader env, and full output to data partition log. + // Non-fatal: failures are logged as warnings. + persist_fsck_results(&ods_status, bl.as_mut(), &config.rootfs_dir); + } else { + warn!("Could not create bootloader; fsck results will not be persisted to bootloader env"); + } + + // Propagate mount failure after persistence attempt (FsckRequiresReboot → reboot) + mount_result?; + + // Safe: mount succeeded means boot partition is mounted, so bootloader was created above. + let bootloader = bootloader_result?; + + // Now that rootfs is mounted, read os-release for feature flags. + // Non-fatal: missing os-release means no features enabled. + if let Err(e) = config.load_os_release() { + log::warn!("Failed to read os-release from rootfs: {}", e); + } + info!("release={}", config.is_release_image); + + // Setup raw rootfs mount (before overlays) + setup_raw_rootfs_mount(&mut mount_manager, &config.rootfs_dir)?; + + // Setup overlays + let overlay_config = OverlayConfig::new(&config.rootfs_dir) + .with_persistent_var_log(config.has_persistent_var_log()); + + setup_etc_overlay(&mut mount_manager, &overlay_config)?; + setup_data_overlay(&mut mount_manager, &overlay_config)?; + + // Create fs-links + create_fs_links(&config.rootfs_dir)?; + + // Create ODS runtime files + create_ods_runtime_files(&ods_status, bootloader.as_ref())?; + + info!("omnect-os-initramfs completed successfully"); + + // Release all tracked mounts before exec. The mounts themselves must + // survive into the new root; the RAII destructor must not unmount them. + mount_manager.release(); + + // Switch root to final rootfs + switch_root(&config.rootfs_dir, None)?; + + // This should never be reached + Ok(()) +} + +/// Run fsck on a partition and record the result (including output) in ods_status. +/// +/// Intercepts `FsckRequiresReboot` to save the output before propagating, ensuring +/// it is available for persistence even when mounting is aborted early. +fn fsck_and_record( + dev: &Path, + name: &str, + ods_status: &mut OdsStatus, +) -> std::result::Result<(), FilesystemError> { + match check_filesystem_lenient(dev) { + Ok(r) => { + ods_status.add_fsck_result(name, r.exit_code, r.output); + Ok(()) + } + Err(FilesystemError::FsckRequiresReboot { + device, + code, + output, + }) => { + ods_status.add_fsck_result(name, code, output.clone()); + Err(FilesystemError::FsckRequiresReboot { + device, + code, + output, + }) + } + Err(e) => Err(e), + } +} + +/// Mount all required partitions +fn mount_partitions( + mm: &mut MountManager, + layout: &PartitionLayout, + config: &Config, + ods_status: &mut OdsStatus, +) -> Result<()> { + let rootfs = &config.rootfs_dir; + + // Mount rootfs read-only — rootCurrent is mandatory; abort if missing. + let root_dev = layout + .partitions + .get(partition_names::ROOT_CURRENT) + .ok_or_else(|| { + InitramfsError::Partition(PartitionError::DeviceDetection( + "rootCurrent not found in partition map; cannot mount rootfs".to_string(), + )) + })?; + fsck_and_record(root_dev, partition_names::ROOT_CURRENT, ods_status)?; + mm.mount_readonly(root_dev, rootfs, "ext4")?; + info!("Mounted rootfs at {}", rootfs.display()); + + // Mount boot partition + if let Some(boot_dev) = layout.partitions.get(partition_names::BOOT) { + let boot_mount = rootfs.join("boot"); + fsck_and_record(boot_dev, partition_names::BOOT, ods_status)?; + mm.mount_readwrite(boot_dev, &boot_mount, "vfat")?; + } + + // Mount factory partition + if let Some(factory_dev) = layout.partitions.get(partition_names::FACTORY) { + let factory_mount = rootfs.join("mnt/factory"); + fsck_and_record(factory_dev, partition_names::FACTORY, ods_status)?; + mm.mount_readonly(factory_dev, &factory_mount, "ext4")?; + } + + // Mount cert partition + if let Some(cert_dev) = layout.partitions.get(partition_names::CERT) { + let cert_mount = rootfs.join("mnt/cert"); + fsck_and_record(cert_dev, partition_names::CERT, ods_status)?; + mm.mount_readonly(cert_dev, &cert_mount, "ext4")?; + } + + // Mount etc partition (for overlay upper) + if let Some(etc_dev) = layout.partitions.get(partition_names::ETC) { + let etc_mount = rootfs.join("mnt/etc"); + fsck_and_record(etc_dev, partition_names::ETC, ods_status)?; + mm.mount_readwrite(etc_dev, &etc_mount, "ext4")?; + } + + // Mount data partition + if let Some(data_dev) = layout.partitions.get(partition_names::DATA) { + let data_mount = rootfs.join("mnt/data"); + fsck_and_record(data_dev, partition_names::DATA, ods_status)?; + mm.mount_readwrite(data_dev, &data_mount, "ext4")?; + } + + // /var/volatile provides a writable mount for volatile data under the read-only rootfs + let var_volatile = rootfs.join("var/volatile"); + mm.mount_tmpfs(&var_volatile, MsFlags::empty(), None)?; + + // /run is NOT mounted here: the initramfs /run tmpfs (mounted by + // mount_essential_filesystems) is moved into the new root by switch_root + // using MS_MOVE. Mounting a second tmpfs at /rootfs/run would cause EBUSY + // and lose any files written there (e.g. ODS runtime state). + + Ok(()) +} + +/// Persist fsck results after all partitions are mounted. +/// +/// For each partition with a non-zero fsck exit code: +/// - Stores the gzip+base64 encoded exit code and full output in the bootloader +/// environment (grubenv / uboot-env) for inspection after the next boot. +/// - Writes the full output to `/data/var/log/fsck/.log` on the data +/// partition so ODS and operators can inspect it after boot. +fn persist_fsck_results( + ods_status: &OdsStatus, + bootloader: &mut dyn Bootloader, + rootfs_dir: &Path, +) { + // Data partition is mounted at rootfs/mnt/data by mount_partitions. + // Files written here appear at /data/var/log/fsck/ in the final OS. + let log_dir = rootfs_dir.join("mnt/data/var/log/fsck"); + + for (partition, fsck) in &ods_status.fsck { + if fsck.code == 0 { + continue; + } + + if let Err(e) = bootloader.save_fsck_status(partition, fsck.code, &fsck.output) { + warn!( + "Failed to save fsck status for {} to bootloader env: {}", + partition, e + ); + } + + if !fsck.output.is_empty() { + if let Err(e) = fs::create_dir_all(&log_dir) { + warn!("Failed to create fsck log dir {}: {}", log_dir.display(), e); + } else { + let log_path = log_dir.join(format!("{}.log", partition)); + if let Err(e) = fs::write(&log_path, &fsck.output) { + warn!("Failed to write fsck log {}: {}", log_path.display(), e); + } else { + info!("Wrote fsck log: {}", log_path.display()); + } + } + } + } +} + +/// Handle fatal errors based on image type +fn handle_fatal_error(error: InitramfsError, is_release: bool) -> ! { + // fsck exit code 2 means fsck explicitly requests a reboot before mounting. + if matches!( + error, + InitramfsError::Filesystem(FilesystemError::FsckRequiresReboot { .. }) + ) { + error!("fsck requires reboot: {}", error); + let _ = nix::sys::reboot::reboot(nix::sys::reboot::RebootMode::RB_AUTOBOOT); + // reboot(2) should not return; loop as a last resort + loop { + thread::sleep(Duration::from_secs(FATAL_ERROR_SLEEP_SECS)); + } + } + + if is_release { + // Release image: loop forever to prevent reboot loops + loop { + error!("FATAL: {}", error); + thread::sleep(Duration::from_secs(FATAL_ERROR_SLEEP_SECS)); + } + } else { + // Debug image: spawn shell + warn!("Debug mode: spawning shell due to error: {}", error); + spawn_debug_shell(); + } +} + +/// Spawn emergency shell (before logging available) +fn spawn_emergency_shell() -> ! { + // PID 1 must never exit. Respawn the shell so the operator can retry. + // Use eprintln! — the kmsg logger may not be initialised yet at this point. + loop { + match process::Command::new(SH_CMD).status() { + Ok(status) => eprintln!("Emergency shell exited with {status} — respawning"), + Err(e) => { + eprintln!( + "Failed to spawn emergency shell ({e}) — retrying in {FATAL_ERROR_SLEEP_SECS}s" + ); + thread::sleep(Duration::from_secs(FATAL_ERROR_SLEEP_SECS)); + } + } + } +} + +/// Spawn debug shell for debugging +fn spawn_debug_shell() -> ! { + // PID 1 must never exit — the kernel would panic. Respawn the shell + // in a loop so the operator can re-enter after an accidental exit. + loop { + let status = process::Command::new(BASH_CMD) + .arg("--init-file") + .arg("/dev/null") + .status(); + + match status { + Ok(_) => log::info!("debug shell exited — respawning"), + Err(e) => { + log::warn!("bash unavailable ({e}), falling back to sh"); + match process::Command::new(SH_CMD).status() { + Ok(_) => log::info!("sh exited — respawning"), + Err(e) => { + log::error!("sh also unavailable ({e}) — sleeping before retry"); + thread::sleep(Duration::from_secs(FATAL_ERROR_SLEEP_SECS)); + } + } + } + } + } +} diff --git a/src/partition/device.rs b/src/partition/device.rs new file mode 100644 index 0000000..27de5bd --- /dev/null +++ b/src/partition/device.rs @@ -0,0 +1,450 @@ +//! Root device detection from kernel command line. +//! +//! Supports two omnect-os boot paths depending on the bootloader: +//! +//! - **GRUB** (`rootpart=N` + `bootpart_fsuuid=`): GRUB probes the filesystem +//! UUID of its boot partition via `probe --fs-uuid` and passes it as `bootpart_fsuuid=` +//! on the kernel cmdline. initramfs calls `blkid -t UUID=` to resolve the exact +//! boot partition device, then strips the partition suffix to get the base disk. +//! +//! - **U-Boot** (`root=/dev/`): full device path set by U-Boot bootargs +//! (e.g. `root=/dev/mmcblk1p2`). Base device and separator are derived from the path. + +use std::fs; +use std::path::PathBuf; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::partition::{PartitionError, Result}; + +const DEVICE_WAIT_TIMEOUT_SECS: u64 = 30; +const DEVICE_POLL_INTERVAL_MS: u64 = 100; +const BLKID_CMD: &str = "/sbin/blkid"; + +/// Represents the detected root block device and its properties. +#[derive(Debug, Clone)] +pub struct RootDevice { + /// Base block device path (e.g., `/dev/sda`, `/dev/nvme0n1`, `/dev/mmcblk0`) + pub base: PathBuf, + /// Partition separator ("" for sda/vda, "p" for nvme0n1/mmcblk0) + pub partition_sep: String, + /// Root partition device path (e.g., `/dev/sda2`, `/dev/mmcblk0p2`) + pub root_partition: PathBuf, +} + +impl RootDevice { + /// Constructs the path to a specific partition number. + pub fn partition_path(&self, partition_num: u32) -> PathBuf { + PathBuf::from(format!( + "{}{}{}", + self.base.display(), + self.partition_sep, + partition_num + )) + } +} + +/// Detects the root device by parsing kernel command line parameters. +pub fn detect_root_device() -> Result { + detect_root_device_from_cmdline("/proc/cmdline") +} + +/// Internal implementation with configurable cmdline path for testing. +pub(crate) fn detect_root_device_from_cmdline(cmdline_path: &str) -> Result { + let cmdline = fs::read_to_string(cmdline_path).map_err(|e| { + PartitionError::DeviceDetection(format!("failed to read {}: {}", cmdline_path, e)) + })?; + + // GRUB: rootpart=N + bootpart_fsuuid= + // GRUB and initramfs always ship in the same image, so bootpart_fsuuid is + // always present on GRUB boots — no fallback paths needed. + if let Some(part_str) = parse_cmdline_param(&cmdline, "rootpart")? { + let part_num: u32 = part_str.parse().map_err(|_| { + PartitionError::DeviceDetection(format!( + "rootpart= is not a valid partition number: {}", + part_str + )) + })?; + + let fsuuid = parse_cmdline_param(&cmdline, "bootpart_fsuuid")?.ok_or_else(|| { + PartitionError::DeviceDetection( + "rootpart= present but bootpart_fsuuid= missing from cmdline".into(), + ) + })?; + + return device_from_fsuuid(&fsuuid, part_num); + } + + // U-Boot: root=/dev/ (full partition path in bootargs) + if let Some(root) = parse_cmdline_param(&cmdline, "root")? { + if !root.starts_with("/dev/") { + return Err(PartitionError::DeviceDetection(format!( + "root= must start with /dev/, got: {}", + root + ))); + } + return device_from_path(&root); + } + + Err(PartitionError::DeviceDetection( + "neither rootpart= (GRUB) nor root= (U-Boot) found in kernel cmdline".into(), + )) +} + +/// Resolves the boot disk via the filesystem UUID of the boot partition (`bootpart_fsuuid=`). +/// +/// GRUB runs `probe --fs-uuid` on `${root}` (the boot partition) and passes the result +/// on the kernel cmdline. `blkid` is retried in a loop until the UUID is found or the +/// timeout expires — block devices may not be ready immediately at initramfs startup. +fn device_from_fsuuid(fsuuid: &str, part_num: u32) -> Result { + use std::process::Command; + + log::info!( + "device_from_fsuuid: resolving boot partition UUID={}", + fsuuid + ); + + // Retry blkid until the UUID appears or the timeout expires. + // Block devices may not be ready when initramfs first runs blkid. + let timeout = Duration::from_secs(DEVICE_WAIT_TIMEOUT_SECS); + let start = Instant::now(); + let boot_part_str = loop { + // busybox blkid does not support -t / -o arguments; run without args + // and parse output ourselves. Each line has the format: + // /dev/sda1: UUID="xxxx-xxxx" TYPE="vfat" ... + let output = Command::new(BLKID_CMD) + .output() + .map_err(|e| PartitionError::DeviceDetection(format!("failed to run blkid: {}", e)))?; + + // A non-zero exit from blkid means a hard failure (binary missing, + // I/O error, etc.) — not just "UUID not found". Treat it as fatal + // rather than retrying, to avoid spinning until timeout. + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(PartitionError::DeviceDetection(format!( + "blkid exited with status {:?}: {}", + output.status.code(), + stderr.trim() + ))); + } + + let stdout = std::str::from_utf8(&output.stdout) + .map_err(|_| PartitionError::DeviceDetection("blkid output is not UTF-8".into()))?; + + match parse_blkid_output(stdout, fsuuid) { + Ok(dev) => { + log::info!( + "device_from_fsuuid: UUID={} resolved to {} after {:.1}s", + fsuuid, + dev, + start.elapsed().as_secs_f32() + ); + break dev; + } + Err(_) => { + if start.elapsed() >= timeout { + return Err(PartitionError::DeviceDetection(format!( + "blkid found no device with UUID={} within {}s", + fsuuid, + timeout.as_secs() + ))); + } + log::debug!( + "device_from_fsuuid: UUID={} not found yet, retrying...", + fsuuid + ); + thread::sleep(Duration::from_millis(DEVICE_POLL_INTERVAL_MS)); + } + } + }; + + let name = PathBuf::from(&boot_part_str) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + PartitionError::DeviceDetection(format!("invalid blkid output: {}", boot_part_str)) + })? + .to_string(); + + let (base_name, sep) = split_partition_suffix(&name)?; + let base = PathBuf::from("/dev").join(&base_name); + let root_partition = PathBuf::from(format!("/dev/{}{}{}", base_name, sep, part_num)); + wait_for_device(&root_partition)?; + + log::info!( + "device_from_fsuuid: root device = {} (partition {})", + base.display(), + part_num + ); + Ok(RootDevice { + base, + partition_sep: sep, + root_partition, + }) +} + +/// Builds a `RootDevice` from a full `root=/dev/` path (U-Boot boot path). +fn device_from_path(path: &str) -> Result { + let root_partition = PathBuf::from(path); + wait_for_device(&root_partition)?; + let name = root_partition + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| PartitionError::DeviceDetection(format!("invalid device path: {}", path)))?; + let (base_name, sep) = split_partition_suffix(name)?; + let base = PathBuf::from("/dev").join(&base_name); + log::info!("root device from root= (U-Boot): {}", base.display()); + Ok(RootDevice { + base, + partition_sep: sep, + root_partition, + }) +} + +/// Splits a partition device name into `(base_name, separator)`. +/// +/// Examples: `"sda2"` → `("sda", "")`, `"mmcblk1p2"` → `("mmcblk1", "p")` +fn split_partition_suffix(name: &str) -> Result<(String, String)> { + // NVMe / MMC: partition number follows a "p" separator + if (name.contains("nvme") || name.starts_with("mmcblk")) + && let Some(pos) = name.rfind('p') + { + let suffix = &name[pos + 1..]; + if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { + return Ok((name[..pos].to_string(), "p".to_string())); + } + } + + // SATA / virtio: partition number appended directly (e.g. sda2, vda2) + let base_end = name.trim_end_matches(|c: char| c.is_ascii_digit()).len(); + if base_end > 0 && base_end < name.len() { + return Ok((name[..base_end].to_string(), String::new())); + } + + Err(PartitionError::DeviceDetection(format!( + "could not derive base device from: {}", + name + ))) +} + +fn wait_for_device(device: &std::path::Path) -> Result<()> { + let timeout = Duration::from_secs(DEVICE_WAIT_TIMEOUT_SECS); + let start = Instant::now(); + loop { + if device.exists() { + return Ok(()); + } + if start.elapsed() > timeout { + return Err(PartitionError::DeviceDetection(format!( + "device {} did not appear within {} seconds", + device.display(), + timeout.as_secs() + ))); + } + thread::sleep(Duration::from_millis(DEVICE_POLL_INTERVAL_MS)); + } +} + +/// Parses a parameter value from kernel command line. +/// +/// Handles `key=value` format. Values containing spaces are not supported +/// (the kernel cmdline splits on whitespace; quoted values with spaces +/// would be split into multiple tokens by `split_whitespace`). +pub(crate) fn parse_cmdline_param(cmdline: &str, key: &str) -> Result> { + let prefix = format!("{}=", key); + for token in cmdline.split_whitespace() { + if let Some(value) = token.strip_prefix(&prefix) { + return Ok(Some(value.trim_matches('"').to_string())); + } + } + Ok(None) +} + +/// Parses busybox `blkid` output (no arguments) and returns the device path +/// whose `UUID=` field matches `fsuuid`. +/// +/// Each line has the format: +/// `/dev/sda1: UUID="xxxx-xxxx" TYPE="vfat" ...` +fn parse_blkid_output(output: &str, fsuuid: &str) -> Result { + let needle = format!("UUID=\"{}\"", fsuuid); + output + .lines() + .find(|line| line.contains(&needle)) + .and_then(|line| line.split(':').next()) + .map(|s| s.trim().to_string()) + .ok_or_else(|| { + PartitionError::DeviceDetection(format!("blkid found no device with UUID={}", fsuuid)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_blkid_output_found() { + let output = "/dev/sda1: UUID=\"abcd-1234\" TYPE=\"vfat\"\n\ + /dev/sda2: UUID=\"3fbe07d8-7dc2-4afb-8929-f0bdcb4a3ec5\" BLOCK_SIZE=\"4096\" TYPE=\"ext4\"\n"; + assert_eq!( + parse_blkid_output(output, "abcd-1234").unwrap(), + "/dev/sda1" + ); + assert_eq!( + parse_blkid_output(output, "3fbe07d8-7dc2-4afb-8929-f0bdcb4a3ec5").unwrap(), + "/dev/sda2" + ); + } + + #[test] + fn test_parse_blkid_output_not_found() { + let output = "/dev/sda1: UUID=\"abcd-1234\" TYPE=\"vfat\"\n"; + assert!(parse_blkid_output(output, "0000-0000").is_err()); + } + + #[test] + fn test_parse_blkid_output_lvm_skipped() { + // LVM member has UUID in a different format; should not match fs UUID + let output = "/dev/sda3: UUID=\"cxCxgR-SzbH-ea59-BRsb\" TYPE=\"LVM2_member\"\n\ + /dev/sda1: UUID=\"abcd-1234\" TYPE=\"vfat\"\n"; + assert_eq!( + parse_blkid_output(output, "abcd-1234").unwrap(), + "/dev/sda1" + ); + } + + #[test] + fn test_parse_cmdline_param_bootpart_fsuuid() { + let cmdline = "rootpart=2 bootpart_fsuuid=1234-ABCD ro quiet"; + assert_eq!( + parse_cmdline_param(cmdline, "rootpart").unwrap(), + Some("2".to_string()) + ); + assert_eq!( + parse_cmdline_param(cmdline, "bootpart_fsuuid").unwrap(), + Some("1234-ABCD".to_string()) + ); + } + + #[test] + fn test_parse_cmdline_param_rootpart() { + let cmdline = "rootpart=2 console=ttyS0,115200 quiet"; + assert_eq!( + parse_cmdline_param(cmdline, "rootpart").unwrap(), + Some("2".to_string()) + ); + assert_eq!( + parse_cmdline_param(cmdline, "bootpart_fsuuid").unwrap(), + None + ); + } + + #[test] + fn test_parse_cmdline_param_missing() { + let cmdline = "ro quiet"; + assert_eq!(parse_cmdline_param(cmdline, "rootpart").unwrap(), None); + assert_eq!( + parse_cmdline_param(cmdline, "bootpart_fsuuid").unwrap(), + None + ); + } + + #[test] + fn test_parse_cmdline_param_complex() { + let cmdline = + "rootpart=2 coherent_pool=1M console=ttyS0,115200 bootpart_fsuuid=ABCD-1234 ro"; + assert_eq!( + parse_cmdline_param(cmdline, "rootpart").unwrap(), + Some("2".to_string()) + ); + assert_eq!( + parse_cmdline_param(cmdline, "bootpart_fsuuid").unwrap(), + Some("ABCD-1234".to_string()) + ); + } + + #[test] + fn test_parse_cmdline_param_uboot_root() { + let cmdline = "root=/dev/mmcblk1p2 ro quiet"; + assert_eq!( + parse_cmdline_param(cmdline, "root").unwrap(), + Some("/dev/mmcblk1p2".to_string()) + ); + assert_eq!(parse_cmdline_param(cmdline, "rootpart").unwrap(), None); + } + + #[test] + fn test_split_partition_suffix_sata() { + assert_eq!( + split_partition_suffix("sda2").unwrap(), + ("sda".to_string(), String::new()) + ); + } + + #[test] + fn test_split_partition_suffix_mmc() { + assert_eq!( + split_partition_suffix("mmcblk1p2").unwrap(), + ("mmcblk1".to_string(), "p".to_string()) + ); + } + + #[test] + fn test_split_partition_suffix_nvme() { + assert_eq!( + split_partition_suffix("nvme0n1p2").unwrap(), + ("nvme0n1".to_string(), "p".to_string()) + ); + } + + #[test] + fn test_split_partition_suffix_virtio() { + assert_eq!( + split_partition_suffix("vda2").unwrap(), + ("vda".to_string(), String::new()) + ); + } + + #[test] + fn test_root_device_partition_path_sata() { + let device = RootDevice { + base: PathBuf::from("/dev/sda"), + partition_sep: String::new(), + root_partition: PathBuf::from("/dev/sda2"), + }; + assert_eq!(device.partition_path(1), PathBuf::from("/dev/sda1")); + assert_eq!(device.partition_path(7), PathBuf::from("/dev/sda7")); + } + + #[test] + fn test_root_device_partition_path_mmc() { + let device = RootDevice { + base: PathBuf::from("/dev/mmcblk0"), + partition_sep: "p".to_string(), + root_partition: PathBuf::from("/dev/mmcblk0p2"), + }; + assert_eq!(device.partition_path(1), PathBuf::from("/dev/mmcblk0p1")); + assert_eq!(device.partition_path(7), PathBuf::from("/dev/mmcblk0p7")); + } + + #[test] + fn test_root_device_partition_path_nvme() { + let device = RootDevice { + base: PathBuf::from("/dev/nvme0n1"), + partition_sep: "p".to_string(), + root_partition: PathBuf::from("/dev/nvme0n1p2"), + }; + assert_eq!(device.partition_path(1), PathBuf::from("/dev/nvme0n1p1")); + assert_eq!(device.partition_path(7), PathBuf::from("/dev/nvme0n1p7")); + } + + #[test] + fn test_root_device_partition_path_virtio() { + let device = RootDevice { + base: PathBuf::from("/dev/vda"), + partition_sep: String::new(), + root_partition: PathBuf::from("/dev/vda2"), + }; + assert_eq!(device.partition_path(1), PathBuf::from("/dev/vda1")); + assert_eq!(device.partition_path(7), PathBuf::from("/dev/vda7")); + } +} diff --git a/src/partition/layout.rs b/src/partition/layout.rs new file mode 100644 index 0000000..9cdca4f --- /dev/null +++ b/src/partition/layout.rs @@ -0,0 +1,457 @@ +//! Partition layout detection +//! +//! Detects GPT vs DOS partition tables and builds a partition map +//! with appropriate partition numbers for each type. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::PartitionError; +use crate::partition::{Result, RootDevice}; + +/// Command to query partition table +const SFDISK_CMD: &str = "/sbin/sfdisk"; + +/// Partition table types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PartitionTableType { + /// GUID Partition Table (modern, used on x86-64 EFI) + Gpt, + /// DOS/MBR partition table (legacy, used on some ARM) + Dos, +} + +impl std::fmt::Display for PartitionTableType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Gpt => write!(f, "GPT"), + Self::Dos => write!(f, "DOS/MBR"), + } + } +} + +/// Partition names used in omnect-os +pub mod partition_names { + pub const BOOT: &str = "boot"; + pub const ROOT_A: &str = "rootA"; + pub const ROOT_B: &str = "rootB"; + pub const FACTORY: &str = "factory"; + pub const CERT: &str = "cert"; + pub const ETC: &str = "etc"; + pub const DATA: &str = "data"; + pub const EXTENDED: &str = "extended"; + pub const ROOT_CURRENT: &str = "rootCurrent"; + pub const ROOTBLK: &str = "rootblk"; +} + +/// Partition layout for a block device +#[derive(Debug, Clone)] +pub struct PartitionLayout { + /// Partition table type + pub table_type: PartitionTableType, + /// Map of partition name to device path + pub partitions: HashMap, + /// The root device + pub device: RootDevice, +} + +impl PartitionLayout { + /// Detects the partition layout from the given root device. + pub fn detect(device: RootDevice) -> Result { + let table_type = detect_partition_table_type(&device.base)?; + let partitions = build_partition_map(&device, table_type); + + Ok(Self { + table_type, + partitions, + device, + }) + } + + /// Get the device path for a named partition + pub fn get(&self, name: &str) -> Option<&PathBuf> { + self.partitions.get(name) + } + + /// Check if current root is rootA (partition 2) + fn is_root_a(&self) -> bool { + partition_suffix(&self.device.root_partition) == PARTITION_NUM_ROOT_A + } + + /// Get the current root partition path (rootA or rootB based on boot). + /// + /// Falls back to reconstructing the path from the device if the map entry + /// is absent (indicates incomplete initialisation — should not occur in normal boot). + pub fn root_current(&self) -> PathBuf { + if self.is_root_a() { + self.partitions + .get(partition_names::ROOT_A) + .cloned() + .unwrap_or_else(|| { + log::warn!("rootA not in partition map; reconstructing path"); + self.device.partition_path(PARTITION_NUM_ROOT_A) + }) + } else { + self.partitions + .get(partition_names::ROOT_B) + .cloned() + .unwrap_or_else(|| { + log::warn!("rootB not in partition map; reconstructing path"); + self.device.partition_path(PARTITION_NUM_ROOT_B) + }) + } + } +} + +/// Partition numbers for GPT layout +const PARTITION_NUM_BOOT: u32 = 1; +const PARTITION_NUM_ROOT_A: u32 = 2; +const PARTITION_NUM_ROOT_B: u32 = 3; +const PARTITION_NUM_FACTORY_GPT: u32 = 4; +const PARTITION_NUM_CERT_GPT: u32 = 5; +const PARTITION_NUM_ETC_GPT: u32 = 6; +const PARTITION_NUM_DATA_GPT: u32 = 7; + +/// Partition numbers for DOS layout (with extended partition) +const PARTITION_NUM_EXTENDED_DOS: u32 = 4; +const PARTITION_NUM_FACTORY_DOS: u32 = 5; +const PARTITION_NUM_CERT_DOS: u32 = 6; +const PARTITION_NUM_ETC_DOS: u32 = 7; +const PARTITION_NUM_DATA_DOS: u32 = 8; + +/// Parse the trailing numeric partition suffix from a device path. +/// +/// Examples: `sda2` → 2, `mmcblk0p3` → 3, `nvme0n1p12` → 12. +/// Returns 0 if the suffix cannot be parsed. +/// +/// Uses the *trailing* digit run, not the first digit found, so that devices +/// like `mmcblk0p2` (which contain digits in the base name) are handled correctly. +fn partition_suffix(path: &Path) -> u32 { + let s = path.file_name().and_then(|s| s.to_str()).unwrap_or(""); + // Find the start of the last all-digit run at the end of the name. + let digit_start = s + .rfind(|c: char| !c.is_ascii_digit()) + .map(|i| i + 1) + .unwrap_or(0); + s[digit_start..].parse().unwrap_or(0) +} + +/// Detect partition table type using sfdisk +fn detect_partition_table_type(device: &Path) -> Result { + let output = Command::new(SFDISK_CMD) + .arg("-l") + .arg(device) + .output() + .map_err(|e| PartitionError::InvalidPartitionTable { + device: device.to_path_buf(), + reason: format!("Failed to run sfdisk: {}", e), + })?; + + if !output.status.success() { + return Err(PartitionError::InvalidPartitionTable { + device: device.to_path_buf(), + reason: format!("sfdisk failed: {}", String::from_utf8_lossy(&output.stderr)), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse sfdisk output to determine table type + // Look for "Disklabel type: gpt" or "Disklabel type: dos" + for line in stdout.lines() { + let line_lower = line.to_lowercase(); + if line_lower.contains("disklabel type:") || line_lower.contains("label-id:") { + if line_lower.contains("gpt") { + return Ok(PartitionTableType::Gpt); + } else if line_lower.contains("dos") || line_lower.contains("mbr") { + return Ok(PartitionTableType::Dos); + } + } + // Alternative format: "label: gpt" or "label: dos" + if line_lower.starts_with("label:") { + if line_lower.contains("gpt") { + return Ok(PartitionTableType::Gpt); + } else if line_lower.contains("dos") { + return Ok(PartitionTableType::Dos); + } + } + } + + Err(PartitionError::InvalidPartitionTable { + device: device.to_path_buf(), + reason: "Could not determine partition table type from sfdisk output".to_string(), + }) +} + +/// Build partition map based on table type +fn build_partition_map( + device: &RootDevice, + table_type: PartitionTableType, +) -> HashMap { + let mut partitions = HashMap::new(); + + // Common partitions (same number for both GPT and DOS) + partitions.insert( + partition_names::BOOT.to_string(), + device.partition_path(PARTITION_NUM_BOOT), + ); + partitions.insert( + partition_names::ROOT_A.to_string(), + device.partition_path(PARTITION_NUM_ROOT_A), + ); + partitions.insert( + partition_names::ROOT_B.to_string(), + device.partition_path(PARTITION_NUM_ROOT_B), + ); + + // Determine current root by parsing the numeric partition suffix. + // String suffix matching (e.g. ends_with("p2")) is wrong for devices + // with 10+ partitions (nvme0n1p12 would falsely match). + let is_root_a = partition_suffix(&device.root_partition) == PARTITION_NUM_ROOT_A; + + partitions.insert( + partition_names::ROOT_CURRENT.to_string(), + if is_root_a { + device.partition_path(PARTITION_NUM_ROOT_A) + } else { + device.partition_path(PARTITION_NUM_ROOT_B) + }, + ); + + // Table-type specific partitions + match table_type { + PartitionTableType::Gpt => { + partitions.insert( + partition_names::FACTORY.to_string(), + device.partition_path(PARTITION_NUM_FACTORY_GPT), + ); + partitions.insert( + partition_names::CERT.to_string(), + device.partition_path(PARTITION_NUM_CERT_GPT), + ); + partitions.insert( + partition_names::ETC.to_string(), + device.partition_path(PARTITION_NUM_ETC_GPT), + ); + partitions.insert( + partition_names::DATA.to_string(), + device.partition_path(PARTITION_NUM_DATA_GPT), + ); + } + PartitionTableType::Dos => { + // DOS has an extended partition container + partitions.insert( + partition_names::EXTENDED.to_string(), + device.partition_path(PARTITION_NUM_EXTENDED_DOS), + ); + partitions.insert( + partition_names::FACTORY.to_string(), + device.partition_path(PARTITION_NUM_FACTORY_DOS), + ); + partitions.insert( + partition_names::CERT.to_string(), + device.partition_path(PARTITION_NUM_CERT_DOS), + ); + partitions.insert( + partition_names::ETC.to_string(), + device.partition_path(PARTITION_NUM_ETC_DOS), + ); + partitions.insert( + partition_names::DATA.to_string(), + device.partition_path(PARTITION_NUM_DATA_DOS), + ); + } + } + + partitions +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_device_sda() -> RootDevice { + RootDevice { + base: PathBuf::from("/dev/sda"), + partition_sep: "".to_string(), + root_partition: PathBuf::from("/dev/sda2"), + } + } + + fn create_test_device_nvme() -> RootDevice { + RootDevice { + base: PathBuf::from("/dev/nvme0n1"), + partition_sep: "p".to_string(), + root_partition: PathBuf::from("/dev/nvme0n1p2"), + } + } + + fn create_test_device_mmc() -> RootDevice { + RootDevice { + base: PathBuf::from("/dev/mmcblk0"), + partition_sep: "p".to_string(), + root_partition: PathBuf::from("/dev/mmcblk0p3"), // rootB + } + } + + #[test] + fn test_partition_map_gpt_sata() { + let device = create_test_device_sda(); + let map = build_partition_map(&device, PartitionTableType::Gpt); + + assert_eq!( + map.get(partition_names::BOOT), + Some(&PathBuf::from("/dev/sda1")) + ); + assert_eq!( + map.get(partition_names::ROOT_A), + Some(&PathBuf::from("/dev/sda2")) + ); + assert_eq!( + map.get(partition_names::ROOT_B), + Some(&PathBuf::from("/dev/sda3")) + ); + assert_eq!( + map.get(partition_names::FACTORY), + Some(&PathBuf::from("/dev/sda4")) + ); + assert_eq!( + map.get(partition_names::CERT), + Some(&PathBuf::from("/dev/sda5")) + ); + assert_eq!( + map.get(partition_names::ETC), + Some(&PathBuf::from("/dev/sda6")) + ); + assert_eq!( + map.get(partition_names::DATA), + Some(&PathBuf::from("/dev/sda7")) + ); + assert_eq!(map.get(partition_names::EXTENDED), None); // No extended partition in GPT + } + + #[test] + fn test_partition_map_dos_sata() { + let device = create_test_device_sda(); + let map = build_partition_map(&device, PartitionTableType::Dos); + + assert_eq!( + map.get(partition_names::BOOT), + Some(&PathBuf::from("/dev/sda1")) + ); + assert_eq!( + map.get(partition_names::ROOT_A), + Some(&PathBuf::from("/dev/sda2")) + ); + assert_eq!( + map.get(partition_names::ROOT_B), + Some(&PathBuf::from("/dev/sda3")) + ); + assert_eq!( + map.get(partition_names::EXTENDED), + Some(&PathBuf::from("/dev/sda4")) + ); + assert_eq!( + map.get(partition_names::FACTORY), + Some(&PathBuf::from("/dev/sda5")) + ); + assert_eq!( + map.get(partition_names::CERT), + Some(&PathBuf::from("/dev/sda6")) + ); + assert_eq!( + map.get(partition_names::ETC), + Some(&PathBuf::from("/dev/sda7")) + ); + assert_eq!( + map.get(partition_names::DATA), + Some(&PathBuf::from("/dev/sda8")) + ); + } + + #[test] + fn test_partition_map_gpt_nvme() { + let device = create_test_device_nvme(); + let map = build_partition_map(&device, PartitionTableType::Gpt); + + assert_eq!( + map.get(partition_names::BOOT), + Some(&PathBuf::from("/dev/nvme0n1p1")) + ); + assert_eq!( + map.get(partition_names::ROOT_A), + Some(&PathBuf::from("/dev/nvme0n1p2")) + ); + assert_eq!( + map.get(partition_names::DATA), + Some(&PathBuf::from("/dev/nvme0n1p7")) + ); + } + + #[test] + fn test_partition_map_dos_mmc() { + let device = create_test_device_mmc(); + let map = build_partition_map(&device, PartitionTableType::Dos); + + assert_eq!( + map.get(partition_names::BOOT), + Some(&PathBuf::from("/dev/mmcblk0p1")) + ); + assert_eq!( + map.get(partition_names::ROOT_A), + Some(&PathBuf::from("/dev/mmcblk0p2")) + ); + assert_eq!( + map.get(partition_names::ROOT_B), + Some(&PathBuf::from("/dev/mmcblk0p3")) + ); + assert_eq!( + map.get(partition_names::DATA), + Some(&PathBuf::from("/dev/mmcblk0p8")) + ); + } + + #[test] + fn test_partition_table_type_display() { + assert_eq!(PartitionTableType::Gpt.to_string(), "GPT"); + assert_eq!(PartitionTableType::Dos.to_string(), "DOS/MBR"); + } + + #[test] + fn test_root_current_root_a() { + let device = create_test_device_sda(); // root_partition ends with 2 (rootA) + let layout = PartitionLayout { + table_type: PartitionTableType::Gpt, + partitions: build_partition_map(&device, PartitionTableType::Gpt), + device, + }; + + assert_eq!(layout.root_current(), PathBuf::from("/dev/sda2")); + } + + #[test] + fn test_root_current_root_b() { + let device = create_test_device_mmc(); // root_partition ends with 3 (rootB) + let layout = PartitionLayout { + table_type: PartitionTableType::Dos, + partitions: build_partition_map(&device, PartitionTableType::Dos), + device, + }; + + assert_eq!(layout.root_current(), PathBuf::from("/dev/mmcblk0p3")); + } + + #[test] + fn test_partition_suffix() { + // Plain SATA: digit at end + assert_eq!(partition_suffix(&PathBuf::from("/dev/sda2")), 2); + // MMC: base name contains a digit (mmcblk0), partition suffix is after 'p' + assert_eq!(partition_suffix(&PathBuf::from("/dev/mmcblk0p2")), 2); + assert_eq!(partition_suffix(&PathBuf::from("/dev/mmcblk0p3")), 3); + // NVMe: base name contains multiple digits, partition number > 9 + assert_eq!(partition_suffix(&PathBuf::from("/dev/nvme0n1p12")), 12); + // No suffix + assert_eq!(partition_suffix(&PathBuf::from("/dev/sda")), 0); + } +} diff --git a/src/partition/mod.rs b/src/partition/mod.rs new file mode 100644 index 0000000..d8ebe48 --- /dev/null +++ b/src/partition/mod.rs @@ -0,0 +1,18 @@ +//! Partition management for omnect-os initramfs. +//! +//! Handles root device detection, partition layout, and symlink creation. + +pub mod device; +pub mod layout; +pub mod symlinks; + +// Re-export error type from crate::error +pub use crate::error::PartitionError; + +/// Result type for partition operations. +pub type Result = std::result::Result; + +// Re-export main types +pub use device::{RootDevice, detect_root_device}; +pub use layout::{PartitionLayout, PartitionTableType, partition_names}; +pub use symlinks::{create_omnect_symlinks, verify_symlinks}; diff --git a/src/partition/symlinks.rs b/src/partition/symlinks.rs new file mode 100644 index 0000000..fe677be --- /dev/null +++ b/src/partition/symlinks.rs @@ -0,0 +1,246 @@ +//! Symlink creation for /dev/omnect/* +//! +//! Creates symbolic links to partition devices for consistent access +//! regardless of underlying device type. + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use crate::error::PartitionError; +use crate::partition::layout::partition_names; +use crate::partition::{PartitionLayout, Result}; + +/// Base directory for omnect device symlinks +const OMNECT_DEV_DIR: &str = "/dev/omnect"; + +/// Create all /dev/omnect/* symlinks for the given partition layout. +pub fn create_omnect_symlinks(layout: &PartitionLayout) -> Result<()> { + create_symlink_dir()?; + + // Create symlink to the base block device + create_symlink(&layout.device.base, &symlink_path(partition_names::ROOTBLK))?; + + // Create partition symlinks — rootCurrent is already in layout.partitions + // with the correct active-partition target, so no explicit creation needed. + for (name, device_path) in &layout.partitions { + create_symlink(device_path, &symlink_path(name))?; + } + + log::info!( + "Created /dev/omnect symlinks for {} device {}, rootCurrent -> {}", + layout.table_type, + layout.device.base.display(), + layout.root_current().display() + ); + + Ok(()) +} + +/// Remove all /dev/omnect/* symlinks +/// +/// Useful for cleanup on error or re-detection. +pub fn remove_omnect_symlinks() -> Result<()> { + let omnect_dir = Path::new(OMNECT_DEV_DIR); + + if !omnect_dir.exists() { + return Ok(()); + } + + for entry in fs::read_dir(omnect_dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_symlink() { + fs::remove_file(&path).map_err(|e| PartitionError::SymlinkRemoveFailed { + path: path.clone(), + reason: e.to_string(), + })?; + } + } + + Ok(()) +} + +/// Create the /dev/omnect directory if it doesn't exist +fn create_symlink_dir() -> Result<()> { + let omnect_dir = Path::new(OMNECT_DEV_DIR); + + if !omnect_dir.exists() { + fs::create_dir_all(omnect_dir).map_err(|e| PartitionError::SymlinkFailed { + link: omnect_dir.to_path_buf(), + target: PathBuf::new(), + reason: format!("Failed to create directory: {}", e), + })?; + } + + Ok(()) +} + +/// Get the full path for a symlink in /dev/omnect +fn symlink_path(name: &str) -> PathBuf { + PathBuf::from(OMNECT_DEV_DIR).join(name) +} + +/// Create a symlink, removing any existing symlink first +fn create_symlink(target: &Path, link: &Path) -> Result<()> { + // Remove existing symlink if present. Plain directories cannot be replaced + // by a symlink — flag them clearly. Symlinks pointing to directories are fine. + if link.is_symlink() || link.exists() { + if !link.is_symlink() && link.is_dir() { + return Err(PartitionError::SymlinkRemoveFailed { + path: link.to_path_buf(), + reason: "path is a directory, cannot replace with symlink".to_string(), + }); + } + fs::remove_file(link).map_err(|e| PartitionError::SymlinkRemoveFailed { + path: link.to_path_buf(), + reason: e.to_string(), + })?; + } + + // Create the symlink + symlink(target, link).map_err(|e| PartitionError::SymlinkFailed { + link: link.to_path_buf(), + target: target.to_path_buf(), + reason: e.to_string(), + })?; + + log::debug!( + "Created symlink: {} -> {}", + link.display(), + target.display() + ); + + Ok(()) +} + +/// Verify that all expected symlinks exist and are valid +pub fn verify_symlinks(layout: &PartitionLayout) -> Result<()> { + // Check rootblk + verify_symlink(&symlink_path(partition_names::ROOTBLK), &layout.device.base)?; + + // Check all partitions — rootCurrent is already in layout.partitions so + // no explicit check needed (mirrors create_omnect_symlinks). + for (name, device_path) in &layout.partitions { + verify_symlink(&symlink_path(name), device_path)?; + } + + Ok(()) +} + +/// Verify a single symlink points to the expected target +fn verify_symlink(link: &Path, expected_target: &Path) -> Result<()> { + if !link.is_symlink() { + return Err(PartitionError::SymlinkFailed { + link: link.to_path_buf(), + target: expected_target.to_path_buf(), + reason: "Symlink does not exist".to_string(), + }); + } + + let actual_target = fs::read_link(link)?; + if actual_target != expected_target { + return Err(PartitionError::SymlinkFailed { + link: link.to_path_buf(), + target: expected_target.to_path_buf(), + reason: format!( + "Symlink points to {} instead of {}", + actual_target.display(), + expected_target.display() + ), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_symlink_path() { + assert_eq!(symlink_path("boot"), PathBuf::from("/dev/omnect/boot")); + assert_eq!(symlink_path("rootA"), PathBuf::from("/dev/omnect/rootA")); + assert_eq!( + symlink_path("rootblk"), + PathBuf::from("/dev/omnect/rootblk") + ); + } + + #[test] + fn test_create_symlink_in_temp_dir() { + let temp_dir = TempDir::new().unwrap(); + let target = temp_dir.path().join("target_file"); + let link = temp_dir.path().join("link"); + + // Create a target file + fs::write(&target, "test").unwrap(); + + // Create symlink + let result = create_symlink(&target, &link); + assert!(result.is_ok()); + + // Verify symlink exists and points to target + assert!(link.is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), target); + } + + #[test] + fn test_create_symlink_replaces_existing() { + let temp_dir = TempDir::new().unwrap(); + let target1 = temp_dir.path().join("target1"); + let target2 = temp_dir.path().join("target2"); + let link = temp_dir.path().join("link"); + + fs::write(&target1, "test1").unwrap(); + fs::write(&target2, "test2").unwrap(); + + // Create first symlink + create_symlink(&target1, &link).unwrap(); + assert_eq!(fs::read_link(&link).unwrap(), target1); + + // Replace with second symlink + create_symlink(&target2, &link).unwrap(); + assert_eq!(fs::read_link(&link).unwrap(), target2); + } + + #[test] + fn test_verify_symlink_success() { + let temp_dir = TempDir::new().unwrap(); + let target = temp_dir.path().join("target"); + let link = temp_dir.path().join("link"); + + fs::write(&target, "test").unwrap(); + symlink(&target, &link).unwrap(); + + let result = verify_symlink(&link, &target); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_symlink_wrong_target() { + let temp_dir = TempDir::new().unwrap(); + let target1 = temp_dir.path().join("target1"); + let target2 = temp_dir.path().join("target2"); + let link = temp_dir.path().join("link"); + + fs::write(&target1, "test1").unwrap(); + fs::write(&target2, "test2").unwrap(); + symlink(&target1, &link).unwrap(); + + let result = verify_symlink(&link, &target2); + assert!(result.is_err()); + } + + #[test] + fn test_verify_symlink_not_exists() { + let temp_dir = TempDir::new().unwrap(); + let link = temp_dir.path().join("nonexistent"); + let target = temp_dir.path().join("target"); + + let result = verify_symlink(&link, &target); + assert!(result.is_err()); + } +} diff --git a/src/runtime/fs_link.rs b/src/runtime/fs_link.rs new file mode 100644 index 0000000..aa281d5 --- /dev/null +++ b/src/runtime/fs_link.rs @@ -0,0 +1,283 @@ +//! Filesystem link creation from configuration +//! +//! Creates symbolic links based on fs-link configuration files. + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::{InitramfsError, Result}; + +/// Configuration file path for fs-link +const FS_LINK_CONFIG_PATH: &str = "etc/omnect/fs-link.json"; + +/// Fallback config path +const FS_LINK_CONFIG_PATH_D: &str = "etc/omnect/fs-link.d"; + +/// Configuration for fs-link +#[derive(Debug, Clone, Deserialize)] +pub struct FsLinkConfig { + /// List of links to create + pub links: Vec, +} + +/// A single link entry +#[derive(Debug, Clone, Deserialize)] +pub struct LinkEntry { + /// Target of the symlink (what it points to) + pub target: String, + /// Path where the symlink is created + pub link: String, +} + +/// Create symbolic links based on fs-link configuration +pub fn create_fs_links(rootfs_dir: &Path) -> Result<()> { + let config = load_fs_link_config(rootfs_dir)?; + + for entry in &config.links { + create_link(rootfs_dir, entry)?; + } + + if !config.links.is_empty() { + log::info!("Created {} fs-links", config.links.len()); + } + + Ok(()) +} + +/// Load fs-link configuration from all sources +fn load_fs_link_config(rootfs_dir: &Path) -> Result { + let mut all_links = Vec::new(); + + // Load main config file + let main_config_path = rootfs_dir.join(FS_LINK_CONFIG_PATH); + if main_config_path.exists() { + let config = load_config_file(&main_config_path)?; + all_links.extend(config.links); + } + + // Load config.d directory + let config_d_path = rootfs_dir.join(FS_LINK_CONFIG_PATH_D); + if config_d_path.is_dir() { + let mut entries: Vec<_> = fs::read_dir(&config_d_path)? + .filter_map(|e| match e { + Ok(entry) => Some(entry), + Err(err) => { + log::warn!("Failed to read entry in {}: {err}", config_d_path.display()); + None + } + }) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "json")) + .collect(); + + // Sort for deterministic order + entries.sort_by_key(|e| e.path()); + + for entry in entries { + let config = load_config_file(&entry.path())?; + all_links.extend(config.links); + } + } + + Ok(FsLinkConfig { links: all_links }) +} + +/// Load a single config file +fn load_config_file(path: &Path) -> Result { + let content = fs::read_to_string(path)?; + let config: FsLinkConfig = serde_json::from_str(&content).map_err(|e| { + InitramfsError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to parse fs-link config {}: {}", path.display(), e), + )) + })?; + + log::debug!( + "Loaded {} links from {}", + config.links.len(), + path.display() + ); + + Ok(config) +} + +/// Validate that a config-supplied path is a safe relative path. +/// +/// Rejects absolute paths (Path::join would silently discard rootfs_dir) and +/// paths containing `..` components (directory traversal outside rootfs). +fn validate_relative_path(path: &str) -> Result<()> { + let p = Path::new(path); + if p.is_absolute() { + return Err(InitramfsError::Io(std::io::Error::other(format!( + "fs-link path must be relative, got absolute path: {path}" + )))); + } + if p.components().any(|c| c == std::path::Component::ParentDir) { + return Err(InitramfsError::Io(std::io::Error::other(format!( + "fs-link path must not contain '..': {path}" + )))); + } + Ok(()) +} + +/// Create a single symbolic link +fn create_link(rootfs_dir: &Path, entry: &LinkEntry) -> Result<()> { + validate_relative_path(&entry.link)?; + let link_path = rootfs_dir.join(&entry.link); + let target = PathBuf::from(&entry.target); + + // Ensure parent directory exists + if let Some(parent) = link_path.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + + // Remove existing link/file if present + if link_path.exists() || link_path.is_symlink() { + // A plain directory cannot be replaced by a symlink — flag it clearly. + // Symlinks pointing to directories are fine and must be replaceable. + if !link_path.is_symlink() && link_path.is_dir() { + return Err(InitramfsError::Io(std::io::Error::other(format!( + "Cannot replace directory with symlink: {}", + link_path.display() + )))); + } + fs::remove_file(&link_path).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to remove existing file {}: {}", + link_path.display(), + e + ))) + })?; + } + + // Create the symlink + symlink(&target, &link_path).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to create symlink {} -> {}: {}", + link_path.display(), + target.display(), + e + ))) + })?; + + log::debug!( + "Created symlink: {} -> {}", + link_path.display(), + target.display() + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_link_entry_deserialize() { + let json = r#"{"target": "/data/app", "link": "opt/app"}"#; + let entry: LinkEntry = serde_json::from_str(json).unwrap(); + + assert_eq!(entry.target, "/data/app"); + assert_eq!(entry.link, "opt/app"); + } + + #[test] + fn test_fs_link_config_deserialize() { + let json = r#"{ + "links": [ + {"target": "/data/app", "link": "opt/app"}, + {"target": "/data/config", "link": "etc/app"} + ] + }"#; + + let config: FsLinkConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.links.len(), 2); + } + + #[test] + fn test_create_link() { + let temp = TempDir::new().unwrap(); + let target_dir = temp.path().join("target"); + fs::create_dir_all(&target_dir).unwrap(); + + let entry = LinkEntry { + target: target_dir.to_string_lossy().to_string(), + link: "link".to_string(), + }; + + create_link(temp.path(), &entry).unwrap(); + + let link_path = temp.path().join("link"); + assert!(link_path.is_symlink()); + assert_eq!(fs::read_link(&link_path).unwrap(), target_dir); + } + + #[test] + fn test_create_link_replaces_existing() { + let temp = TempDir::new().unwrap(); + let target1 = temp.path().join("target1"); + let target2 = temp.path().join("target2"); + fs::create_dir_all(&target1).unwrap(); + fs::create_dir_all(&target2).unwrap(); + + let link_path = temp.path().join("link"); + + // Create first link + let entry1 = LinkEntry { + target: target1.to_string_lossy().to_string(), + link: "link".to_string(), + }; + create_link(temp.path(), &entry1).unwrap(); + + // Replace with second link + let entry2 = LinkEntry { + target: target2.to_string_lossy().to_string(), + link: "link".to_string(), + }; + create_link(temp.path(), &entry2).unwrap(); + + assert_eq!(fs::read_link(&link_path).unwrap(), target2); + } + + #[test] + fn test_create_link_nested_path() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("target"); + fs::create_dir_all(&target).unwrap(); + + let entry = LinkEntry { + target: target.to_string_lossy().to_string(), + link: "nested/deep/link".to_string(), + }; + + create_link(temp.path(), &entry).unwrap(); + + let link_path = temp.path().join("nested/deep/link"); + assert!(link_path.is_symlink()); + } + + #[test] + fn test_load_empty_config() { + let temp = TempDir::new().unwrap(); + let config = load_fs_link_config(temp.path()).unwrap(); + assert!(config.links.is_empty()); + } + + #[test] + fn test_load_config_file() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("config.json"); + + let json = r#"{"links": [{"target": "/data", "link": "opt"}]}"#; + fs::write(&config_path, json).unwrap(); + + let config = load_config_file(&config_path).unwrap(); + assert_eq!(config.links.len(), 1); + } +} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..d3174b0 --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,14 @@ +//! Runtime setup and integration modules +//! +//! This module handles: +//! - omnect-device-service runtime file creation +//! - fs-link symbolic link creation +//! - switch_root to final rootfs + +mod fs_link; +mod omnect_device_service; +mod switch_root; + +pub use self::fs_link::create_fs_links; +pub use self::omnect_device_service::{OdsStatus, create_ods_runtime_files}; +pub use self::switch_root::switch_root; diff --git a/src/runtime/omnect_device_service.rs b/src/runtime/omnect_device_service.rs new file mode 100644 index 0000000..164ec5e --- /dev/null +++ b/src/runtime/omnect_device_service.rs @@ -0,0 +1,375 @@ +//! omnect-device-service integration +//! +//! Creates runtime files that omnect-device-service reads at startup. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use crate::bootloader::Bootloader; +use crate::bootloader::vars; +use crate::error::{InitramfsError, Result}; + +/// Directory for ODS runtime files. +/// Written to the initramfs /run tmpfs; switch_root moves /run into the new +/// root via MS_MOVE, so these files appear at the same path after boot. +const ODS_RUNTIME_DIR: &str = "/run/omnect-device-service"; + +/// Main status file name +const ODS_STATUS_FILE: &str = "omnect-os-initramfs.json"; + +/// Update validation trigger file +const UPDATE_VALIDATE_FILE: &str = "omnect_validate_update"; + +/// Failed update validation marker +const UPDATE_VALIDATE_FAILED_FILE: &str = "omnect_validate_update_failed"; + +/// Bootloader updated marker +const BOOTLOADER_UPDATED_FILE: &str = "omnect_bootloader_updated"; + +/// Factory reset status file (in /tmp) +const FACTORY_RESET_STATUS_FILE: &str = "/tmp/factory-reset.json"; + +/// Status information for omnect-device-service +#[derive(Debug, Clone, Default, Serialize)] +pub struct OdsStatus { + /// Fsck results for each partition + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub fsck: HashMap, + + /// Factory reset status (if performed) + #[serde(skip_serializing_if = "Option::is_none")] + pub factory_reset: Option, +} + +/// Fsck status for a single partition +#[derive(Debug, Clone, Serialize)] +pub struct FsckStatus { + /// Exit code from fsck + pub code: i32, + /// Output from fsck (may be compressed in bootloader) + pub output: String, +} + +/// Factory reset execution status +#[derive(Debug, Clone, Serialize)] +pub struct FactoryResetStatus { + /// Status code: 0=success, 1=invalid, 2=error, 3=config_error + pub status: u32, + /// Error message if failed + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Additional context + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, + /// Paths that were preserved + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paths: Vec, +} + +impl OdsStatus { + /// Create a new empty status + pub fn new() -> Self { + Self::default() + } + + /// Add fsck result for a partition + pub fn add_fsck_result(&mut self, partition: &str, code: i32, output: String) { + self.fsck + .insert(partition.to_string(), FsckStatus { code, output }); + } + + /// Set factory reset status + pub fn set_factory_reset(&mut self, status: FactoryResetStatus) { + self.factory_reset = Some(status); + } +} + +/// Create all runtime files for omnect-device-service +/// +/// Files are written directly to the initramfs `/run` tmpfs. `switch_root` +/// moves that mount into the new root via `MS_MOVE`, so they remain visible +/// to ODS at the same path after the root pivot. +pub fn create_ods_runtime_files(status: &OdsStatus, bootloader: &dyn Bootloader) -> Result<()> { + let ods_dir = Path::new(ODS_RUNTIME_DIR); + + // Ensure directory exists + fs::create_dir_all(ods_dir).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to create ODS runtime dir: {}", + e + ))) + })?; + + // Write main status file + write_status_file(ods_dir, status)?; + + // Handle update validation + handle_update_validation(ods_dir, bootloader)?; + + // Copy factory reset status if exists + copy_factory_reset_status(ods_dir)?; + + log::info!("Created ODS runtime files in {}", ods_dir.display()); + + Ok(()) +} + +/// Write the main status JSON file +fn write_status_file(ods_dir: &Path, status: &OdsStatus) -> Result<()> { + let status_path = ods_dir.join(ODS_STATUS_FILE); + let json = serde_json::to_string_pretty(status).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to serialize ODS status: {}", + e + ))) + })?; + + fs::write(&status_path, json).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to write ODS status to {}: {}", + status_path.display(), + e + ))) + })?; + log::debug!("Wrote ODS status to {}", status_path.display()); + + Ok(()) +} + +/// Handle update validation workflow +fn handle_update_validation(ods_dir: &Path, bootloader: &dyn Bootloader) -> Result<()> { + // Check if update validation is requested + let validate_update = match bootloader.get_env(vars::OMNECT_VALIDATE_UPDATE) { + Ok(val) => val, + Err(e) => { + log::warn!( + "failed to read omnect_validate_update from bootloader: {}", + e + ); + None + } + }; + + if let Some(value) = validate_update { + if value == "1" || value.to_lowercase() == "true" { + // Create trigger file for ODS + let trigger_path = ods_dir.join(UPDATE_VALIDATE_FILE); + fs::write(&trigger_path, "1").map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to write {}: {}", + trigger_path.display(), + e + ))) + })?; + log::info!("Update validation requested - created trigger file"); + } else if value == "failed" { + // Mark validation as failed + let failed_path = ods_dir.join(UPDATE_VALIDATE_FAILED_FILE); + fs::write(&failed_path, "1").map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to write {}: {}", + failed_path.display(), + e + ))) + })?; + log::warn!("Update validation failed marker created"); + } + } + + // Check for bootloader updated flag + let bootloader_updated = match bootloader.get_env(vars::OMNECT_BOOTLOADER_UPDATED) { + Ok(val) => val, + Err(e) => { + log::warn!( + "failed to read omnect_bootloader_updated from bootloader: {}", + e + ); + None + } + }; + + if let Some(value) = bootloader_updated + && (value == "1" || value.to_lowercase() == "true") + { + let marker_path = ods_dir.join(BOOTLOADER_UPDATED_FILE); + fs::write(&marker_path, "1").map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to write {}: {}", + marker_path.display(), + e + ))) + })?; + log::info!("Bootloader update marker created"); + } + + Ok(()) +} + +/// Copy factory reset status from /tmp if it exists +fn copy_factory_reset_status(ods_dir: &Path) -> Result<()> { + let src = PathBuf::from(FACTORY_RESET_STATUS_FILE); + + if !src.exists() { + return Ok(()); + } + + let dst = ods_dir.join("factory-reset.json"); + fs::copy(&src, &dst).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to copy {} to {}: {}", + src.display(), + dst.display(), + e + ))) + })?; + log::debug!("Copied factory reset status to ODS dir"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_ods_status_default() { + let status = OdsStatus::default(); + assert!(status.fsck.is_empty()); + assert!(status.factory_reset.is_none()); + } + + #[test] + fn test_ods_status_add_fsck() { + let mut status = OdsStatus::new(); + status.add_fsck_result("boot", 0, "clean".to_string()); + status.add_fsck_result("data", 1, "errors corrected".to_string()); + + assert_eq!(status.fsck.len(), 2); + assert_eq!(status.fsck.get("boot").unwrap().code, 0); + assert_eq!(status.fsck.get("data").unwrap().code, 1); + } + + #[test] + fn test_ods_status_serialization() { + let mut status = OdsStatus::new(); + status.add_fsck_result("boot", 0, "clean".to_string()); + + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"boot\"")); + assert!(json.contains("\"code\":0")); + } + + #[test] + fn test_write_status_file() { + let temp = TempDir::new().unwrap(); + let status = OdsStatus::new(); + + write_status_file(temp.path(), &status).unwrap(); + + let status_path = temp.path().join(ODS_STATUS_FILE); + assert!(status_path.exists()); + + let content = fs::read_to_string(status_path).unwrap(); + assert!(content.contains("{")); + } + + #[test] + fn test_factory_reset_status_serialization() { + let status = FactoryResetStatus { + status: 0, + error: None, + context: Some("normal".to_string()), + paths: vec!["/etc/hostname".to_string()], + }; + + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"status\":0")); + assert!(json.contains("\"paths\"")); + } + + #[test] + fn test_handle_update_validation_value_1() { + let temp = TempDir::new().unwrap(); + let bl = + crate::bootloader::create_mock_bootloader().with_env(vars::OMNECT_VALIDATE_UPDATE, "1"); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(temp.path().join(UPDATE_VALIDATE_FILE).exists()); + assert!(!temp.path().join(UPDATE_VALIDATE_FAILED_FILE).exists()); + assert!(!temp.path().join(BOOTLOADER_UPDATED_FILE).exists()); + } + + #[test] + fn test_handle_update_validation_value_true() { + let temp = TempDir::new().unwrap(); + let bl = crate::bootloader::create_mock_bootloader() + .with_env(vars::OMNECT_VALIDATE_UPDATE, "true"); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(temp.path().join(UPDATE_VALIDATE_FILE).exists()); + } + + #[test] + fn test_handle_update_validation_failed() { + let temp = TempDir::new().unwrap(); + let bl = crate::bootloader::create_mock_bootloader() + .with_env(vars::OMNECT_VALIDATE_UPDATE, "failed"); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(!temp.path().join(UPDATE_VALIDATE_FILE).exists()); + assert!(temp.path().join(UPDATE_VALIDATE_FAILED_FILE).exists()); + } + + #[test] + fn test_handle_update_validation_unexpected_value_creates_nothing() { + let temp = TempDir::new().unwrap(); + let bl = crate::bootloader::create_mock_bootloader() + .with_env(vars::OMNECT_VALIDATE_UPDATE, "unexpected"); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(!temp.path().join(UPDATE_VALIDATE_FILE).exists()); + assert!(!temp.path().join(UPDATE_VALIDATE_FAILED_FILE).exists()); + } + + #[test] + fn test_handle_update_validation_bootloader_updated() { + let temp = TempDir::new().unwrap(); + let bl = crate::bootloader::create_mock_bootloader() + .with_env(vars::OMNECT_BOOTLOADER_UPDATED, "1"); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(temp.path().join(BOOTLOADER_UPDATED_FILE).exists()); + } + + #[test] + fn test_handle_update_validation_bootloader_updated_false_creates_nothing() { + let temp = TempDir::new().unwrap(); + let bl = crate::bootloader::create_mock_bootloader() + .with_env(vars::OMNECT_BOOTLOADER_UPDATED, "0"); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(!temp.path().join(BOOTLOADER_UPDATED_FILE).exists()); + } + + #[test] + fn test_handle_update_validation_no_env_creates_nothing() { + let temp = TempDir::new().unwrap(); + let bl = crate::bootloader::create_mock_bootloader(); + + handle_update_validation(temp.path(), &bl).unwrap(); + + assert!(!temp.path().join(UPDATE_VALIDATE_FILE).exists()); + assert!(!temp.path().join(UPDATE_VALIDATE_FAILED_FILE).exists()); + assert!(!temp.path().join(BOOTLOADER_UPDATED_FILE).exists()); + } +} diff --git a/src/runtime/switch_root.rs b/src/runtime/switch_root.rs new file mode 100644 index 0000000..451f250 --- /dev/null +++ b/src/runtime/switch_root.rs @@ -0,0 +1,245 @@ +//! Switch root to final rootfs and exec init +//! +//! Implements the switch_root operation using MS_MOVE + chroot to transition +//! from initramfs to the real rootfs. pivot_root(2) is not used because ramfs +//! does not support it (returns EINVAL). + +use std::fs; +use std::os::unix::process::CommandExt; +use std::path::Path; +use std::process::Command; + +use nix::mount::{MsFlags, mount}; +use nix::unistd::{chdir, chroot}; + +use crate::error::{InitramfsError, Result}; + +/// Default init path +const DEFAULT_INIT: &str = "/sbin/init"; + +/// Alternative init paths to try +const INIT_PATHS: &[&str] = &[ + "/sbin/init", + "/usr/sbin/init", + "/lib/systemd/systemd", + "/usr/lib/systemd/systemd", +]; + +/// Switch root to the new rootfs and exec init +pub fn switch_root(new_root: &Path, init: Option<&str>) -> Result<()> { + let init_path = init.unwrap_or(DEFAULT_INIT); + + log::info!( + "Switching root to {} with init {}", + new_root.display(), + init_path + ); + if !new_root.exists() { + return Err(InitramfsError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("New root does not exist: {}", new_root.display()), + ))); + } + + // Verify the init binary exists BEFORE moving any mounts. If init is + // missing we want to fail while /dev, /proc, /sys, /run are still on + // the initramfs so the debug shell / fatal-error path still works. + let init_full_path = find_init(new_root, init_path)?; + + // Ensure target mountpoint directories exist under new_root. + // MS_MOVE fails with ENOENT if the target directory is missing. + for dir in &["dev", "proc", "sys", "run"] { + fs::create_dir_all(new_root.join(dir)).map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to create mountpoint {}/{}: {}", + new_root.display(), + dir, + e + ))) + })?; + } + + // Move critical mounts to new root before switching. + // Track which mounts succeeded so any intermediate failure can be rolled back, + // preserving the debug/emergency shell environment on the initramfs. + let critical_mounts = [ + ("/dev", "dev"), + ("/proc", "proc"), + ("/sys", "sys"), + ("/run", "run"), // moved so ODS can read its runtime state after root switch + ]; + let mut moved: Vec<&str> = Vec::new(); + for (src, name) in &critical_mounts { + if let Err(e) = move_mount(src, &new_root.join(name)) { + rollback_critical_mounts(&moved, new_root); + return Err(e); + } + moved.push(name); + } + + chdir(new_root).map_err(|e| { + rollback_critical_mounts(&moved, new_root); + InitramfsError::Io(std::io::Error::other(format!( + "Failed to chdir to new root: {}", + e + ))) + })?; + + // MS_MOVE re-mounts the new root at /. This is the correct approach for + // initramfs: ramfs does not support pivot_root (EINVAL). busybox and + // systemd use the same MS_MOVE + chroot pattern. + // + // On failure: restore all moved mounts so the debug/emergency shell still + // has access to /dev, /proc, /sys, /run on the initramfs. + if let Err(e) = mount(Some("."), "/", None::<&str>, MsFlags::MS_MOVE, None::<&str>) { + rollback_critical_mounts(&moved, new_root); + return Err(InitramfsError::Io(std::io::Error::other(format!( + "Failed to MS_MOVE new root to /: {}", + e + )))); + } + + chroot(".").map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!("Failed to chroot: {}", e))) + })?; + + chdir("/").map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to chdir to /: {}", + e + ))) + })?; + + log::info!("Executing init: {}", init_full_path); + + // exec() replaces the current process - does not return on success + let err = Command::new(&init_full_path).exec(); + + // If we get here, exec failed + Err(InitramfsError::Io(std::io::Error::other(format!( + "Failed to exec init: {}", + err + )))) +} + +fn move_mount(source: &str, target: &Path) -> Result<()> { + use nix::mount::{MsFlags, mount}; + + mount( + Some(source), + target, + None::<&str>, + MsFlags::MS_MOVE, + None::<&str>, + ) + .map_err(|e| { + InitramfsError::Io(std::io::Error::other(format!( + "Failed to move {} → {}: {}", + source, + target.display(), + e + ))) + })?; + + Ok(()) +} + +/// Attempt to restore already-moved critical mounts back to the initramfs. +/// +/// Called on any failure before MS_MOVE succeeds — at that point `/` is still +/// the initramfs root, so moving from `new_root/{name}` back to `/{name}` +/// restores the debug/emergency shell environment. Best-effort: individual +/// failures are logged and do not abort. +fn rollback_critical_mounts(moved: &[&str], new_root: &Path) { + for name in moved.iter().rev() { + let src = new_root.join(name); + let dst = format!("/{name}"); + if let Err(e) = move_mount(&src.to_string_lossy(), Path::new(&dst)) { + log::warn!("Failed to restore /{name} to initramfs during rollback: {e}"); + } else { + log::debug!("Restored /{name} to initramfs"); + } + } +} + +/// Find the init binary in the new root. +/// +/// Always returns an absolute path string (starts with `/`) so that +/// `Command::new` on PID 1 does not fall back to PATH lookup. +fn find_init(new_root: &Path, requested_init: &str) -> Result { + // Reject paths containing ".." to prevent escaping new_root before chroot. + if requested_init.split('/').any(|c| c == "..") { + return Err(InitramfsError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("init path must not contain '..': {requested_init}"), + ))); + } + + // Ensure the caller-supplied path is absolute to avoid PATH lookup on exec. + let requested_init = if requested_init.starts_with('/') { + requested_init.to_string() + } else { + format!("/{}", requested_init) + }; + + let requested_path = new_root.join(requested_init.trim_start_matches('/')); + if requested_path.is_file() { + return Ok(requested_init); + } + + for init_path in INIT_PATHS { + let full_path = new_root.join(init_path.trim_start_matches('/')); + if full_path.is_file() { + log::debug!("Found init at {}", init_path); + return Ok((*init_path).to_string()); + } + } + + Err(InitramfsError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!( + "Init binary not found in {}. Tried: {}, {:?}", + new_root.display(), + requested_init, + INIT_PATHS + ), + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_find_init_default() { + let temp = TempDir::new().unwrap(); + let sbin = temp.path().join("sbin"); + fs::create_dir_all(&sbin).unwrap(); + fs::write(sbin.join("init"), "#!/bin/sh").unwrap(); + + let result = find_init(temp.path(), "/sbin/init"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "/sbin/init"); + } + + #[test] + fn test_find_init_systemd() { + let temp = TempDir::new().unwrap(); + let systemd_dir = temp.path().join("lib/systemd"); + fs::create_dir_all(&systemd_dir).unwrap(); + fs::write(systemd_dir.join("systemd"), "#!/bin/sh").unwrap(); + + let result = find_init(temp.path(), "/sbin/init"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "/lib/systemd/systemd"); + } + + #[test] + fn test_find_init_not_found() { + let temp = TempDir::new().unwrap(); + let result = find_init(temp.path(), "/sbin/init"); + assert!(result.is_err()); + } +}