diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef6fa4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# 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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cec6025 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,538 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[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 = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[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 = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[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 = "loopdev-3" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3f91f417dba260dff8932ea5ebc1eeaf8c74842555da5eb0ac7ef41176ce2b" +dependencies = [ + "bindgen", + "errno", + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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 = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "omnect-os-init" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "flate2", + "log", + "nix", + "serde", + "serde_json", + "sys-mount", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "sys-mount" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6acb8bb63826062d5a44b68298cf2e25b84bc151bc0c31c35a83b61f818682a" +dependencies = [ + "bitflags", + "libc", + "loopdev-3", + "smart-default", + "thiserror 1.0.69", + "tracing", +] + +[[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 = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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..72ae586 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,60 @@ +[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 +anyhow = { version = "1.0", default-features = false, features = ["std"] } +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"] } + +# Mount operations (high-level API, RAII unmount) +sys-mount = "3.0" + +# Syscalls (stat for device detection, symlink, etc.) +nix = { version = "0.29", default-features = false, features = ["fs", "mount", "process", "reboot"] } + +# Logging +log = { version = "0.4", default-features = false, features = ["std"] } + +# Base64 encoding/decoding for fsck status +base64 = { version = "0.22", default-features = false, features = ["std"] } + +# Compression for fsck status +flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } + +[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..27d3412 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`) +- **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/fs-link.conf` and `/etc/fs-link.conf.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, gzip+base64 helpers +├── 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 (sda/nvme/mmcblk) +│ ├── 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/project-context.md b/project-context.md new file mode 100644 index 0000000..94422c7 --- /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 output is gzip+base64 encoded for bootloader storage +- **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..acdb349 --- /dev/null +++ b/src/bootloader/grub.rs @@ -0,0 +1,129 @@ +//! GRUB bootloader implementation +//! +//! This module provides access to GRUB bootloader environment variables +//! using the `grub-editenv` command. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::bootloader::{Bootloader, BootloaderType, FSCK_VAR_PREFIX, Result}; +use crate::error::BootloaderError; + +/// Command name for GRUB environment manipulation +const GRUB_EDITENV_CMD: &str = "grub-editenv"; + +/// Path to grubenv file relative to boot partition +const GRUBENV_RELATIVE_PATH: &str = "EFI/BOOT/grubenv"; + +/// GRUB bootloader implementation +/// +/// Uses `grub-editenv` to read/write environment variables from the grubenv file. +pub struct GrubBootloader { + grubenv_path: 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.exists() { + return Err(BootloaderError::EnvFileNotFound { path: grubenv_path }); + } + + Ok(Self { grubenv_path }) + } + + /// 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, output: &str, code: i32) -> Result<()> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + let value = format!("{}:{}", code, output); + self.set_env(&var_name, Some(&value)) + } + + fn get_fsck_status(&self, partition: &str) -> Result> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + self.get_env(&var_name) + } + + fn clear_fsck_status(&mut self, partition: &str) -> Result<()> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + self.set_env(&var_name, None) + } + + 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..febcd3a --- /dev/null +++ b/src/bootloader/mod.rs @@ -0,0 +1,207 @@ +//! 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 OMNECT_ROOTBLK: &str = "omnect_rootblk"; + pub const RESIZED_DATA: &str = "resized-data"; + pub const OMNECT_VALIDATE_UPDATE: &str = "omnect_validate_update"; + 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 status to bootloader environment + /// + /// The status is compressed (gzip) and base64 encoded before storage. + fn save_fsck_status(&mut self, partition: &str, output: &str, code: i32) -> Result<()>; + + /// Get fsck status from bootloader environment + /// + /// Returns the decompressed fsck output if it exists. + 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` exists in the rootfs, use GRUB +/// - Otherwise, use U-Boot (assumes fw_printenv/fw_setenv available) +pub fn create_bootloader(rootfs_dir: &Path) -> Result> { + const GRUB_EDITENV_PATH: &str = "usr/bin/grub-editenv"; + + if rootfs_dir.join(GRUB_EDITENV_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, output: &str, _code: i32) -> Result<()> { + let key = format!("omnect_fsck_{}", partition); + let encoded = types::compress_and_encode(output)?; + self.env.insert(key, encoded); + Ok(()) + } + + fn get_fsck_status(&self, partition: &str) -> Result> { + let key = format!("omnect_fsck_{}", partition); + match self.env.get(&key) { + Some(encoded) => Ok(Some(types::decode_and_decompress(encoded)?)), + None => Ok(None), + } + } + + 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(); + + let fsck_output = "fsck from util-linux 2.37.2\n/dev/sda1: clean"; + bl.save_fsck_status("boot", fsck_output, 0).unwrap(); + + let retrieved = bl.get_fsck_status("boot").unwrap(); + assert_eq!(retrieved, Some(fsck_output.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..aa8c942 --- /dev/null +++ b/src/bootloader/types.rs @@ -0,0 +1,112 @@ +//! Common types for bootloader implementations + +use std::fmt; +use std::io::{Read, Write}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; + +use crate::bootloader::Result; +use crate::error::BootloaderError; + +/// Compression level for fsck output +const COMPRESSION_LEVEL: u32 = 6; + +/// 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"), + } + } +} + +/// Compress and base64 encode data for storage +/// +/// Used by U-Boot implementation and mock bootloader. +pub fn compress_and_encode(data: &str) -> Result { + let mut encoder = GzEncoder::new(Vec::new(), Compression::new(COMPRESSION_LEVEL)); + encoder + .write_all(data.as_bytes()) + .map_err(|e| BootloaderError::CompressionFailed(e.to_string()))?; + + let compressed = encoder + .finish() + .map_err(|e| BootloaderError::CompressionFailed(e.to_string()))?; + + Ok(BASE64_STANDARD.encode(&compressed)) +} + +/// Decode and decompress base64-encoded data +/// +/// Used by U-Boot implementation and mock bootloader. +pub fn decode_and_decompress(encoded: &str) -> Result { + let compressed = BASE64_STANDARD + .decode(encoded) + .map_err(|e| BootloaderError::DecompressionFailed(e.to_string()))?; + + let mut decoder = GzDecoder::new(&compressed[..]); + let mut decompressed = String::new(); + decoder + .read_to_string(&mut decompressed) + .map_err(|e| BootloaderError::DecompressionFailed(e.to_string()))?; + + Ok(decompressed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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_compress_decompress_roundtrip() { + let original = "fsck from util-linux 2.37.2\n/dev/sda1: clean, 100/1000 files"; + + let encoded = compress_and_encode(original).unwrap(); + let decoded = decode_and_decompress(&encoded).unwrap(); + + assert_eq!(original, decoded); + } + + #[test] + fn test_encoded_is_valid_base64() { + let original = "test data for encoding"; + let encoded = compress_and_encode(original).unwrap(); + + assert!(BASE64_STANDARD.decode(&encoded).is_ok()); + } + + #[test] + fn test_compress_reduces_size_for_repetitive_data() { + let original = "a".repeat(1000); + + let encoded = compress_and_encode(&original).unwrap(); + + // Compressed + base64 should still be smaller than original for repetitive data + assert!(encoded.len() < original.len()); + } +} diff --git a/src/bootloader/uboot.rs b/src/bootloader/uboot.rs new file mode 100644 index 0000000..40c8b7f --- /dev/null +++ b/src/bootloader/uboot.rs @@ -0,0 +1,140 @@ +//! 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::{compress_and_encode, decode_and_decompress}; +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 = "fw_printenv"; + +/// Command to write U-Boot environment variables +const FW_SETENV_CMD: &str = "fw_setenv"; + +/// U-Boot bootloader implementation +/// +/// Uses `fw_printenv` and `fw_setenv` to access environment variables. +/// Fsck status is compressed (gzip) and base64 encoded to fit in the +/// limited U-Boot environment space. +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 typically means variable not found + if !output.status.success() { + return Ok(None); + } + + 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, output: &str, code: i32) -> Result<()> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + let value = format!("{}:{}", code, output); + let encoded = compress_and_encode(&value)?; + self.run_fw_setenv(&var_name, Some(&encoded)) + } + + fn get_fsck_status(&self, partition: &str) -> Result> { + let var_name = format!("{}{}", FSCK_VAR_PREFIX, partition); + match self.run_fw_printenv(&var_name)? { + Some(encoded) => Ok(Some(decode_and_decompress(&encoded)?)), + None => Ok(None), + } + } + + 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 { + use super::*; + + #[test] + fn test_compress_decompress_roundtrip() { + let original = "fsck from util-linux 2.37.2\n/dev/sda1: clean, 100/1000 files"; + + let encoded = compress_and_encode(original).unwrap(); + let decoded = decode_and_decompress(&encoded).unwrap(); + + assert_eq!(original, decoded); + } + + #[test] + fn test_compress_reduces_size() { + let original = "a]".repeat(1000); + + let encoded = compress_and_encode(&original).unwrap(); + + // Compressed + base64 should still be smaller than original for repetitive data + assert!(encoded.len() < original.len()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..42da988 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,234 @@ +//! Configuration module for omnect-os-init +//! +//! This module handles loading configuration from various sources: +//! - Kernel command line (/proc/cmdline) +//! - Environment variables +//! - /etc/os-release + +use crate::error::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")); + + // Parse os-release if available + let (is_release_image, machine_features, distro_features) = + Self::parse_os_release(&rootfs_dir).unwrap_or((false, vec![], vec![])); + + 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, + machine_features, + distro_features, + cmdline_params, + }) + } + + /// Parse kernel command line parameters + fn parse_cmdline() -> Result> { + let cmdline = fs::read_to_string("/proc/cmdline").unwrap_or_default(); + let mut params = 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)> { + // Try rootfs first, then fall back to initramfs /etc/os-release + let os_release_path = rootfs_dir.join("etc/os-release"); + let content = if os_release_path.exists() { + fs::read_to_string(&os_release_path)? + } else { + fs::read_to_string("/etc/os-release").unwrap_or_default() + }; + + 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..03175e6 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,207 @@ +//! 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("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 (code 2)", device.display())] + FsckRequiresReboot { device: PathBuf }, + + #[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..f1f6b75 --- /dev/null +++ b/src/filesystem/fsck.rs @@ -0,0 +1,314 @@ +//! 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 or 1) + pub success: bool, + /// Whether a reboot is required (code 2) + 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 +/// * `Err(FilesystemError::FsckRequiresReboot)` - If reboot is required (exit code 2) +/// * `Err(FilesystemError::FsckFailed)` - If check failed with 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 + // This ensures all fsck output is visible in dmesg + disable_kmsg_ratelimit(); + + let mut cmd = Command::new(FSCK_CMD); + + if auto_repair { + cmd.arg("-y"); // Automatically repair + } + + cmd.arg("-C0"); // Progress to fd 0 (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); + + // Re-enable rate limiting + enable_kmsg_ratelimit(); + + let result = FsckResult { + device: device.to_path_buf(), + exit_code, + output: combined_output.clone(), + success: exit_code == exit_code::OK || exit_code == exit_code::CORRECTED, + reboot_required: exit_code & exit_code::REBOOT_REQUIRED != 0, + }; + + // Log the result + if result.success { + if exit_code == exit_code::CORRECTED { + log::info!("fsck corrected errors on {}", device.display()); + } else { + log::debug!("fsck: {} is clean", device.display()); + } + } else if result.reboot_required { + log::warn!("fsck on {} requires reboot", device.display()); + } else { + log::error!( + "fsck failed on {} with code {}: {}", + device.display(), + exit_code, + combined_output.lines().next().unwrap_or("(no output)") + ); + } + + // Handle reboot requirement + if result.reboot_required { + return Err(FilesystemError::FsckRequiresReboot { + device: device.to_path_buf(), + }); + } + + // Return error for serious failures, but include the result + 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 }) => { + Err(FilesystemError::FsckRequiresReboot { device }) + } + 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), + } +} + +/// Path to kernel printk settings +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 = std::fs::read_to_string(PRINTK_RATELIMIT_PATH).unwrap_or_default(); + let burst = std::fs::read_to_string(PRINTK_RATELIMIT_BURST_PATH).unwrap_or_default(); + + if let Ok(mut saved) = SAVED_RATELIMIT.lock() { + *saved = Some((ratelimit.trim().to_string(), burst.trim().to_string())); + } + + 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..6f841c8 --- /dev/null +++ b/src/filesystem/mount.rs @@ -0,0 +1,500 @@ +//! 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 private bind mount + pub fn bind_private() -> Self { + Self { + fstype: None, + flags: flags::BIND | flags::PRIVATE, + 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").unwrap_or_default(); + 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..d5ba6ee --- /dev/null +++ b/src/filesystem/overlayfs.rs @@ -0,0 +1,446 @@ +//! 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"; + +/// 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") + .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..bb31a4f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,263 @@ +//! 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::process; +use std::thread; +use std::time::Duration; + +use log::{error, info, warn}; + +use omnect_os_init::{ + Result, + bootloader::create_bootloader, + config::Config, + error::{FilesystemError, InitramfsError}, + 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}, + 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; + +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 early for use in error handling + let is_release_image = Config::load().map(|c| c.is_release_image).unwrap_or(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 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)?; + + // Create bootloader abstraction + let bootloader = create_bootloader(&config.rootfs_dir)?; + info!("Bootloader type: {}", bootloader.bootloader_type()); + + // Initialize ODS status + let mut ods_status = OdsStatus::new(); + + // Run fsck on partitions and mount them + mount_partitions(&mut mount_manager, &layout, &config, &mut ods_status)?; + + // 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(&config.rootfs_dir, &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(()) +} + +/// 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 + if let Some(root_dev) = layout.partitions.get("rootCurrent") { + // Run fsck first; FsckRequiresReboot propagates via ? and triggers a reboot + let result = check_filesystem_lenient(root_dev)?; + ods_status.add_fsck_result("root", result.exit_code, result.output); + + mm.mount_readonly(root_dev, rootfs, "ext4")?; + info!("Mounted rootfs at {}", rootfs.display()); + } + + // Mount boot partition + if let Some(boot_dev) = layout.partitions.get("boot") { + let boot_mount = rootfs.join("boot"); + + let result = check_filesystem_lenient(boot_dev)?; + ods_status.add_fsck_result("boot", result.exit_code, result.output); + + mm.mount_readwrite(boot_dev, &boot_mount, "vfat")?; + } + + // Mount factory partition + if let Some(factory_dev) = layout.partitions.get("factory") { + let factory_mount = rootfs.join("mnt/factory"); + + let result = check_filesystem_lenient(factory_dev)?; + ods_status.add_fsck_result("factory", result.exit_code, result.output); + + mm.mount_readonly(factory_dev, &factory_mount, "ext4")?; + } + + // Mount cert partition + if let Some(cert_dev) = layout.partitions.get("cert") { + let cert_mount = rootfs.join("mnt/cert"); + + let result = check_filesystem_lenient(cert_dev)?; + ods_status.add_fsck_result("cert", result.exit_code, result.output); + + mm.mount_readonly(cert_dev, &cert_mount, "ext4")?; + } + + // Mount etc partition (for overlay upper) + if let Some(etc_dev) = layout.partitions.get("etc") { + let etc_mount = rootfs.join("mnt/etc"); + + let result = check_filesystem_lenient(etc_dev)?; + ods_status.add_fsck_result("etc", result.exit_code, result.output); + + mm.mount_readwrite(etc_dev, &etc_mount, "ext4")?; + } + + // Mount data partition + if let Some(data_dev) = layout.partitions.get("data") { + let data_mount = rootfs.join("mnt/data"); + + let result = check_filesystem_lenient(data_dev)?; + ods_status.add_fsck_result("data", result.exit_code, result.output); + + mm.mount_readwrite(data_dev, &data_mount, "ext4")?; + } + + // Mount tmpfs for /run and /var/volatile + let run_mount = rootfs.join("run"); + mm.mount_tmpfs( + &run_mount, + MsFlags::MS_NODEV | MsFlags::MS_NOSUID | MsFlags::MS_STRICTATIME, + Some("mode=0755"), + )?; + + let var_volatile = rootfs.join("var/volatile"); + mm.mount_tmpfs(&var_volatile, MsFlags::empty(), None)?; + + Ok(()) +} + +/// Handle fatal errors based on image type +fn handle_fatal_error(error: InitramfsError, is_release: bool) -> ! { + // fsck exit code 2 means the filesystem was repaired but a clean reboot + // is required before the OS can safely use it. + 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() -> ! { + let _ = process::Command::new("/bin/sh").status(); + loop { + thread::sleep(Duration::from_secs(FATAL_ERROR_SLEEP_SECS)); + } +} + +/// Spawn debug shell for debugging +fn spawn_debug_shell() -> ! { + let status = process::Command::new("/bin/bash") + .arg("--init-file") + .arg("/dev/null") + .status(); + + match status { + Ok(s) => process::exit(s.code().unwrap_or(1)), + Err(_) => { + let _ = process::Command::new("/bin/sh").status(); + process::exit(1); + } + } +} diff --git a/src/partition/device.rs b/src/partition/device.rs new file mode 100644 index 0000000..30328a6 --- /dev/null +++ b/src/partition/device.rs @@ -0,0 +1,282 @@ +//! Root device detection from kernel command line parameters. +//! +//! Parses `/proc/cmdline` for `root=/dev/` to determine the root block device. + +use std::fs; +use std::path::{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; + +/// 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, "p" for nvme0n1p, mmcblk0p) + 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. +/// +/// # Expected format +/// `root=/dev/` - Direct device path (e.g., `/dev/mmcblk0p2`, `/dev/sda2`) +/// +/// # Errors +/// Returns error if: +/// - Cannot read `/proc/cmdline` +/// - `root=` parameter is missing or malformed +/// - Device path doesn't exist or cannot be resolved +pub fn detect_root_device() -> Result { + detect_root_device_from_cmdline("/proc/cmdline") +} + +/// Internal implementation with configurable 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)) + })?; + + // Parse root= parameter from cmdline + let root_param = parse_cmdline_param(&cmdline, "root")? + .ok_or_else(|| PartitionError::DeviceDetection("missing root= parameter".into()))?; + + // Validate format: must start with /dev/ + if !root_param.starts_with("/dev/") { + return Err(PartitionError::DeviceDetection(format!( + "root= must be a device path starting with /dev/, got: {}", + root_param + ))); + } + + wait_for_device(&PathBuf::from(&root_param))?; + + let partition_path = PathBuf::from(&root_param); + if !partition_path.exists() { + return Err(PartitionError::DeviceDetection(format!( + "root device {} does not exist", + root_param + ))); + } + + // Canonicalize to resolve any symlinks + let partition_path = fs::canonicalize(&partition_path).map_err(|e| { + PartitionError::DeviceDetection(format!("failed to canonicalize {}: {}", root_param, e)) + })?; + + // Derive base block device from partition path + let (base, partition_sep) = derive_base_device(&partition_path)?; + + // Optionally validate against omnect_rootblk hint + if let Some(hint) = parse_cmdline_param(&cmdline, "omnect_rootblk")? { + let hint_path = PathBuf::from(&hint); + if hint_path != base { + log::warn!( + "omnect_rootblk hint {} differs from detected base device {}", + hint, + base.display() + ); + } + } + + Ok(RootDevice { + base, + partition_sep, + root_partition: partition_path, + }) +} + +fn wait_for_device(device: &Path) -> Result<()> { + let timeout = Duration::from_secs(DEVICE_WAIT_TIMEOUT_SECS); + let poll_interval = Duration::from_millis(DEVICE_POLL_INTERVAL_MS); + 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(), + DEVICE_WAIT_TIMEOUT_SECS + ))); + } + thread::sleep(poll_interval); + } +} + +/// Parses a parameter value from kernel command line. +/// +/// Handles both `key=value` and `key="value with spaces"` formats. +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) { + // Handle quoted values + let value = value.trim_matches('"'); + return Ok(Some(value.to_string())); + } + } + + Ok(None) +} + +/// Derives the base block device from a partition device path. +/// +/// Examples: +/// - `/dev/sda2` → (`/dev/sda`, "") +/// - `/dev/nvme0n1p2` → (`/dev/nvme0n1`, "p") +/// - `/dev/mmcblk0p2` → (`/dev/mmcblk0`, "p") +fn derive_base_device(partition_path: &Path) -> Result<(PathBuf, String)> { + let partition_name = partition_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + PartitionError::DeviceDetection(format!( + "invalid partition path: {}", + partition_path.display() + )) + })?; + + let parent = partition_path.parent().unwrap_or_else(|| Path::new("/dev")); + + // Try different partition naming schemes + // NVMe/MMC: nvme0n1p2, mmcblk0p2 - partition number after 'p' + // SATA/virtio: sda2, vda2 - partition number directly appended + + // Check for 'p' separator (NVMe, MMC) + if let Some(pos) = partition_name.rfind('p') { + let suffix = &partition_name[pos + 1..]; + if suffix.chars().all(|c| c.is_ascii_digit()) && !suffix.is_empty() { + let base_name = &partition_name[..pos]; + // Verify this is actually a block device by checking sysfs + let sysfs_path = format!("/sys/block/{}", base_name); + if Path::new(&sysfs_path).exists() { + let base_path = parent.join(base_name); + return Ok((base_path, "p".to_string())); + } + } + } + + // Try direct numeric suffix (SATA, virtio) + let mut base_end = partition_name.len(); + while base_end > 0 && partition_name[..base_end].ends_with(|c: char| c.is_ascii_digit()) { + base_end -= 1; + } + + if base_end < partition_name.len() && base_end > 0 { + let base_name = &partition_name[..base_end]; + let sysfs_path = format!("/sys/block/{}", base_name); + if Path::new(&sysfs_path).exists() { + let base_path = parent.join(base_name); + return Ok((base_path, String::new())); + } + } + + Err(PartitionError::DeviceDetection(format!( + "could not derive base device from {}", + partition_path.display() + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cmdline_param_direct_device() { + let cmdline = "root=/dev/mmcblk0p2 ro quiet"; + assert_eq!( + parse_cmdline_param(cmdline, "root").unwrap(), + Some("/dev/mmcblk0p2".to_string()) + ); + } + + #[test] + fn test_parse_cmdline_param_missing() { + let cmdline = "ro quiet"; + assert_eq!(parse_cmdline_param(cmdline, "root").unwrap(), None); + } + + #[test] + fn test_parse_cmdline_param_complex() { + // Real-world example from Raspberry Pi + let cmdline = "root=/dev/mmcblk0p2 coherent_pool=1M 8250.nr_uarts=1 \ + console=tty0 console=ttyS0,115200 rdinit=/bin/bash"; + assert_eq!( + parse_cmdline_param(cmdline, "root").unwrap(), + Some("/dev/mmcblk0p2".to_string()) + ); + assert_eq!( + parse_cmdline_param(cmdline, "coherent_pool").unwrap(), + Some("1M".to_string()) + ); + assert_eq!( + parse_cmdline_param(cmdline, "rdinit").unwrap(), + Some("/bin/bash".to_string()) + ); + } + + #[test] + fn test_parse_cmdline_omnect_rootblk() { + let cmdline = "root=/dev/sda2 omnect_rootblk=/dev/sda ro"; + assert_eq!( + parse_cmdline_param(cmdline, "omnect_rootblk").unwrap(), + Some("/dev/sda".to_string()) + ); + } + + #[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")); + } +} diff --git a/src/partition/layout.rs b/src/partition/layout.rs new file mode 100644 index 0000000..3f08711 --- /dev/null +++ b/src/partition/layout.rs @@ -0,0 +1,437 @@ +//! 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, + }) + } + + /// Returns the symlink target for rootCurrent based on which root partition is active. + pub fn root_current_target(&self) -> &Path { + if self.is_root_a() { + self.partitions.get(partition_names::ROOT_A).unwrap() + } else { + self.partitions.get(partition_names::ROOT_B).unwrap() + } + } + + /// 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 { + let root_part_str = self + .device + .root_partition + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + root_part_str.ends_with('2') || root_part_str.ends_with("p2") + } + + /// Get the current root partition path (rootA or rootB based on boot) + pub fn root_current(&self) -> PathBuf { + if self.is_root_a() { + self.partitions + .get(partition_names::ROOT_A) + .cloned() + .unwrap_or_else(|| self.device.partition_path(PARTITION_NUM_ROOT_A)) + } else { + self.partitions + .get(partition_names::ROOT_B) + .cloned() + .unwrap_or_else(|| 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; + +/// 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 (rootA=p2 or rootB=p3) + let root_part_str = device + .root_partition + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let is_root_a = root_part_str.ends_with('2') || root_part_str.ends_with("p2"); + + 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")); + } +} diff --git a/src/partition/mod.rs b/src/partition/mod.rs new file mode 100644 index 0000000..018d5f3 --- /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}; +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..132234a --- /dev/null +++ b/src/partition/symlinks.rs @@ -0,0 +1,252 @@ +//! 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 + for (name, device_path) in &layout.partitions { + create_symlink(device_path, &symlink_path(name))?; + } + + // Create rootCurrent symlink pointing to the active root partition + let root_current_target = layout.root_current(); + create_symlink( + &root_current_target, + &symlink_path(partition_names::ROOT_CURRENT), + )?; + + log::info!( + "Created /dev/omnect symlinks for {} device {}, rootCurrent -> {}", + layout.table_type, + layout.device.base.display(), + root_current_target.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::SymlinkFailed { + link: path.clone(), + target: PathBuf::new(), + reason: format!("Failed to remove symlink: {}", e), + })?; + } + } + + 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 + if link.is_symlink() || link.exists() { + fs::remove_file(link).map_err(|e| PartitionError::SymlinkFailed { + link: link.to_path_buf(), + target: target.to_path_buf(), + reason: format!("Failed to remove existing symlink: {}", e), + })?; + } + + // 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 + for (name, device_path) in &layout.partitions { + verify_symlink(&symlink_path(name), device_path)?; + } + + // Check rootCurrent + verify_symlink( + &symlink_path(partition_names::ROOT_CURRENT), + &layout.root_current(), + )?; + + 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..c184fdb --- /dev/null +++ b/src/runtime/fs_link.rs @@ -0,0 +1,249 @@ +//! 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| e.ok()) + .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) +} + +/// Create a single symbolic link +fn create_link(rootfs_dir: &Path, entry: &LinkEntry) -> Result<()> { + 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() { + 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..f246dd8 --- /dev/null +++ b/src/runtime/omnect_device_service.rs @@ -0,0 +1,241 @@ +//! 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::error::{InitramfsError, Result}; + +/// Directory for ODS runtime files +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 +pub fn create_ods_runtime_files( + rootfs_dir: &Path, + status: &OdsStatus, + bootloader: &dyn Bootloader, +) -> Result<()> { + let ods_dir = rootfs_dir.join(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)?; + 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 = bootloader.get_env("omnect_validate_update").unwrap_or(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")?; + 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")?; + log::warn!("Update validation failed marker created"); + } + } + + // Check for bootloader updated flag + let bootloader_updated = bootloader + .get_env("omnect_bootloader_updated") + .unwrap_or(None); + + if bootloader_updated.is_some() { + let marker_path = ods_dir.join(BOOTLOADER_UPDATED_FILE); + fs::write(&marker_path, "1")?; + 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() { + let dst = ods_dir.join("factory-reset.json"); + fs::copy(&src, &dst)?; + 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\"")); + } +} diff --git a/src/runtime/switch_root.rs b/src/runtime/switch_root.rs new file mode 100644 index 0000000..5f226a7 --- /dev/null +++ b/src/runtime/switch_root.rs @@ -0,0 +1,174 @@ +//! 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::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()), + ))); + } + + // Move critical mounts to new root before switching + move_mount("/dev", &new_root.join("dev"))?; + move_mount("/proc", &new_root.join("proc"))?; + move_mount("/sys", &new_root.join("sys"))?; + // /run must be moved so ODS can read its runtime state after root switching + move_mount("/run", &new_root.join("run"))?; + + let init_full_path = find_init(new_root, init_path)?; + + chdir(new_root).map_err(|e| { + 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. + mount(Some("."), "/", None::<&str>, MsFlags::MS_MOVE, None::<&str>).map_err(|e| { + 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(()) +} + +/// Find the init binary in the new root +fn find_init(new_root: &Path, requested_init: &str) -> Result { + let requested_path = new_root.join(requested_init.trim_start_matches('/')); + if requested_path.exists() { + return Ok(requested_init.to_string()); + } + + for init_path in INIT_PATHS { + let full_path = new_root.join(init_path.trim_start_matches('/')); + if full_path.exists() { + 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()); + } + +}