src/platform.rs
//! AIRGAP3 (target half) — per-OS specialization + unpack.
//!
//! Design (`.nornir/airgap-install.md` §2 Phase 1): per-platform prep (e.g.
//! optionally disable SELinux on RHEL), then unpack the verified bundle into the
//! software dir. The SELinux toggle is a real (annotated) deploy subprocess; the
//! unpack reuses [`crate::archive`]. Detection is from `/etc/os-release` (no
//! network).
use std::path::Path;
use anyhow::{Context, Result};
/// Target platform family for specialization.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Rhel,
Debian,
Other,
}
impl Platform {
/// Detect the platform from `/etc/os-release` (offline; no network).
pub fn detect() -> Platform {
let os = std::fs::read_to_string("/etc/os-release").unwrap_or_default();
Platform::from_os_release(&os)
}
/// Parse a platform family from `/etc/os-release` contents (pure → testable).
pub fn from_os_release(contents: &str) -> Platform {
let lc = contents.to_lowercase();
// ID_LIKE / ID covers the common families.
if lc.contains("rhel")
|| lc.contains("centos")
|| lc.contains("fedora")
|| lc.contains("rocky")
|| lc.contains("almalinux")
{
Platform::Rhel
} else if lc.contains("debian") || lc.contains("ubuntu") {
Platform::Debian
} else {
Platform::Other
}
}
/// Whether this family uses SELinux (the toggle only applies to RHEL-likes).
pub fn uses_selinux(self) -> bool {
matches!(self, Platform::Rhel)
}
}
/// Apply per-platform prep. On RHEL-likes, optionally set SELinux to permissive
/// (`setenforce 0`) when `disable_selinux` is set — an annotated deploy
/// subprocess, idempotent and non-fatal if the tool is absent. Other families
/// are a no-op. Returns the actions taken (for telemetry / tests).
pub fn specialize(p: Platform, disable_selinux: bool) -> Result<Vec<String>> {
let mut actions = Vec::new();
if p.uses_selinux() && disable_selinux {
// 100% Rust (no `setenforce` subprocess): `setenforce 0` is exactly
// `echo 0 > /sys/fs/selinux/enforce`. Non-fatal — a minimal airgap image
// may not mount selinuxfs.
let ran = std::fs::write("/sys/fs/selinux/enforce", b"0").is_ok();
actions.push(if ran {
"selinux:permissive".to_string()
} else {
"selinux:skip(no setenforce)".to_string()
});
}
Ok(actions)
}
/// Unpack the (already-verified) bundle into the software dir. Thin wrapper over
/// [`crate::archive::Archive::extract_all`] so platform prep + unpack read as one
/// Phase 1 step.
pub fn unpack(
archive: &dyn crate::archive::Archive,
bundle: &Path,
software_dir: &Path,
) -> Result<()> {
archive
.extract_all(bundle, software_dir)
.with_context(|| format!("unpack bundle into {}", software_dir.display()))
}
#[cfg(test)]
mod tests {
use super::*;
/// LAW 1 (inject-assert): os-release parsing maps real distro strings to the
/// right family, and only RHEL-likes report SELinux.
#[test]
fn os_release_detects_family_and_selinux() {
let rhel = "ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.3\"\n";
assert_eq!(Platform::from_os_release(rhel), Platform::Rhel);
assert!(Platform::from_os_release(rhel).uses_selinux());
let ubuntu = "ID=ubuntu\nID_LIKE=debian\n";
assert_eq!(Platform::from_os_release(ubuntu), Platform::Debian);
assert!(!Platform::from_os_release(ubuntu).uses_selinux());
assert_eq!(Platform::from_os_release("ID=alpine\n"), Platform::Other);
}
/// `specialize` is a no-op (no actions) on non-SELinux platforms, and on RHEL
/// it records a selinux action (the tool itself is non-fatal in test envs).
#[test]
fn specialize_only_touches_selinux_on_rhel() {
assert!(specialize(Platform::Debian, true).unwrap().is_empty());
assert!(specialize(Platform::Rhel, false).unwrap().is_empty());
let acts = specialize(Platform::Rhel, true).unwrap();
assert_eq!(acts.len(), 1);
assert!(acts[0].starts_with("selinux:"));
}
/// LAW 1: unpack actually extracts the bundle tree into the software dir.
#[test]
fn unpack_extracts_bundle_into_software_dir() {
use crate::archive::{Archive, TarZstd};
use std::io::Write as _;
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("stage");
std::fs::create_dir_all(staging.join("linux-x86_64")).unwrap();
std::fs::File::create(staging.join("linux-x86_64/holger"))
.unwrap()
.write_all(b"bin")
.unwrap();
let bundle = dir.path().join("b.tar.zst");
let eng = TarZstd::new();
eng.pack(&staging, "v", &bundle).unwrap();
let sw = dir.path().join("software");
unpack(&eng, &bundle, &sw).unwrap();
assert_eq!(
std::fs::read(sw.join("linux-x86_64/holger")).unwrap(),
b"bin"
);
}
}