diff --git a/Cargo.lock b/Cargo.lock index d7fe137..9071c0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1366,6 +1366,13 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "naros-init" +version = "0.1.0" +dependencies = [ + "libc", +] + [[package]] name = "nash" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2effce0..e91ceb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["nash", "nash-observe"] +members = ["nash", "nash-observe", "naros-init"] [profile.release] strip = true diff --git a/corpus/DIVERGENCES.md b/corpus/DIVERGENCES.md index cad970b..1ffe9e6 100644 --- a/corpus/DIVERGENCES.md +++ b/corpus/DIVERGENCES.md @@ -6,7 +6,27 @@ stays open until fixed in the fork (or upstream) and re-verified by the corpus. ## Open -(none) +### D2 — non-ASCII bytes re-encoded through `read`/`echo` under C/empty locale + +- **Found**: narOS N0 rootfs validation (first real-world install run under the + divert): with `/bin/sh → nash`, `ca-certificates`' postinst + (`update-ca-certificates`, a `while read`-over-conf loop) mangled the UTF-8 + filename `NetLock_Arany_=Class_Gold=_Főtanúsítvány.crt` and failed the + whole image build. Worked around structurally in `os/mkimage/build-rootfs.sh` + (the divert is now always the image's last configure step), but any + *runtime* `apt install` inside narOS still runs postinsts under nash, so this + blocks narOS/M3 forcing gates until fixed. +- **Repro** (nash `0.4.0` musl arm64, empty locale): + `echo 'Főtanúsítvány' | nash -c 'while read x; do echo "$x"; done'` — + bash emits the input bytes unchanged (`F \305\221 t a n \303\272 …`); nash + emits each byte Latin-1→UTF-8 double-encoded (`F \303\205 \302\221 …`). +- **Suspected root cause**: brush decodes input bytes to `String` with a lossy/ + Latin-1 assumption on the `read` path (or at word-splitting) instead of + keeping raw bytes; on output the char sequence is re-encoded as UTF-8. + POSIX shells treat variable values as byte strings. +- **Severity**: silent data corruption (not a parse failure, so the §4.1 + bash-fallback cannot catch it). Needs a corpus case (`utf8-bytes-passthru`) + and a byte-preservation sweep of read/expansion/heredoc paths. ## Closed diff --git a/naros-init/Cargo.toml b/naros-init/Cargo.toml new file mode 100644 index 0000000..3a10ec9 --- /dev/null +++ b/naros-init/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "naros-init" +version = "0.1.0" +edition = "2021" +description = "narOS PID-1 supervisor for container surfaces (docs/NAROS.md §5)" +license = "MIT" + +[dependencies] +libc = "0.2" diff --git a/naros-init/src/main.rs b/naros-init/src/main.rs new file mode 100644 index 0000000..3b93ce1 --- /dev/null +++ b/naros-init/src/main.rs @@ -0,0 +1,154 @@ +//! naros-init — narOS PID-1 supervisor for container surfaces (docs/NAROS.md §5). +//! +//! Supervision and plumbing only, no policy: +//! - reaps zombies (the historical job of PID 1); +//! - forwards SIGTERM/SIGINT to supervised children; +//! - `naros-init -- CMD ARGS…` supervises a primary command and exits with its status +//! (129+signum on signal death), replacing `sh -c` wrappers as the container entrypoint; +//! - with no primary command it is the keepalive that replaces ContainerEngine's +//! sleep loop, staying alive until signalled; +//! - NAROS_BRIDGE=1 additionally supervises the control bridge +//! (`node /opt/nucleic/control-bridge.js`), restarting it with backoff — the bridge is +//! best-effort transport, so its failures never affect the primary command or init. +//! +//! In the VM desktop flavor systemd stays PID 1 and this binary runs as a role unit. + +use std::env; +use std::process::{exit, Command}; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::time::{Duration, Instant}; + +static PENDING_SIGNAL: AtomicI32 = AtomicI32::new(0); + +extern "C" fn on_signal(sig: libc::c_int) { + PENDING_SIGNAL.store(sig, Ordering::SeqCst); +} + +fn install_handlers() { + unsafe { + let handler = on_signal as *const () as usize; + for sig in [libc::SIGTERM, libc::SIGINT] { + libc::signal(sig, handler); + } + // SIG_DFL for SIGCHLD keeps children reapable via waitpid below. + } +} + +/// Reap every exited child; returns the primary's status when it is among them. +fn reap(primary: Option, bridge: Option) -> (Option, bool) { + let mut primary_status = None; + let mut bridge_died = false; + loop { + let mut status: libc::c_int = 0; + let pid = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) }; + if pid <= 0 { + break; + } + let code = if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) + } else { + 1 + }; + if Some(pid) == primary { + primary_status = Some(code); + } else if Some(pid) == bridge { + bridge_died = true; + } + } + (primary_status, bridge_died) +} + +fn forward(sig: i32, pids: &[Option]) { + for pid in pids.iter().flatten() { + unsafe { + libc::kill(*pid, sig); + } + } +} + +fn spawn_bridge() -> Option { + const BRIDGE: &str = "/opt/nucleic/control-bridge.js"; + if !std::path::Path::new(BRIDGE).exists() { + return None; + } + match Command::new("node").arg(BRIDGE).spawn() { + Ok(child) => Some(child.id() as libc::pid_t), + Err(err) => { + eprintln!("naros-init: bridge spawn failed: {err}"); + None + } + } +} + +fn main() { + let args: Vec = env::args().collect(); + if args.get(1).map(String::as_str) == Some("--version") { + println!("naros-init {}", env!("CARGO_PKG_VERSION")); + return; + } + + install_handlers(); + + let cmd: Vec<&String> = match args.iter().position(|a| a == "--") { + Some(i) => args[i + 1..].iter().collect(), + None => Vec::new(), + }; + + let mut primary: Option = None; + if let Some((prog, rest)) = cmd.split_first() { + match Command::new(prog).args(rest).spawn() { + Ok(child) => primary = Some(child.id() as libc::pid_t), + Err(err) => { + eprintln!("naros-init: exec {prog}: {err}"); + exit(127); + } + } + } + + let want_bridge = env::var("NAROS_BRIDGE").ok().as_deref() == Some("1"); + let mut bridge = if want_bridge { spawn_bridge() } else { None }; + let mut bridge_backoff = Duration::from_millis(500); + let mut bridge_retry_at: Option = None; + + loop { + let sig = PENDING_SIGNAL.swap(0, Ordering::SeqCst); + if sig != 0 { + forward(sig, &[primary, bridge]); + if primary.is_none() { + // Keepalive role: the signal is our own shutdown request. + exit(128 + sig); + } + } + + let (primary_status, bridge_died) = reap(primary, bridge); + if let Some(code) = primary_status { + forward(libc::SIGTERM, &[bridge]); + reap(None, bridge); + exit(code); + } + if bridge_died { + bridge = None; + bridge_retry_at = Some(Instant::now() + bridge_backoff); + bridge_backoff = (bridge_backoff * 2).min(Duration::from_secs(30)); + } + if want_bridge && bridge.is_none() { + if let Some(at) = bridge_retry_at { + if Instant::now() >= at { + bridge = spawn_bridge(); + bridge_retry_at = None; + if bridge.is_some() { + bridge_backoff = Duration::from_millis(500); + } else { + bridge_retry_at = Some(Instant::now() + bridge_backoff); + } + } + } else { + bridge_retry_at = Some(Instant::now()); + } + } + + std::thread::sleep(Duration::from_millis(100)); + } +}