crates/nornir-build-thing/src/media_gate.rs
//! **The media gate's shared words** (lane B4, A9 rows 10 and 11): what any product's
//! release step needs before it uploads an installer medium, product-neutral.
//!
//! * **The kind of a medium** ([`MediaKind`]): DEV (the boot log narrated) or RELEASE (no
//! sender compiled in). The product's DEV option bakes it onto the medium's command line
//! ([`kind_bake`], key [`MEDIA_KIND_BOOT`]), so the kind is in the medium's own bytes, and
//! [`kind_in_medium`] reads it back: an upload that says "release" over a DEV medium is
//! refused by name ([`refuse_kind`]).
//! * **The pins of the trees a medium is composed from** ([`RepoPin`]): each repository's
//! full HEAD and its dirty paths, untracked files included ([`pin_of`]). A dirty tree, or
//! a repository that is not a checkout, is refused by name ([`refuse_unpinned`]): the
//! bytes a gate proved must be the bytes of these commits and nothing else.
use std::path::Path;
use anyhow::{bail, Result};
use crate::options::OptionBake;
/// **The kind of an installer medium.**
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum MediaKind {
/// The test media: the boot log is narrated (a sender is compiled in).
Dev,
/// The serving media: no sender compiled in.
Release,
}
impl MediaKind {
/// The kind's word (`dev` | `release`).
pub fn word(self) -> &'static str {
match self {
MediaKind::Dev => "dev",
MediaKind::Release => "release",
}
}
/// The kind a word names, or `None`.
pub fn parse(word: &str) -> Option<MediaKind> {
match word.trim() {
"dev" => Some(MediaKind::Dev),
"release" => Some(MediaKind::Release),
_ => None,
}
}
/// Whether media of this kind narrate.
pub fn narrates(self) -> bool {
self == MediaKind::Dev
}
}
/// **The command-line key a medium records its kind under.** `korp.env.` so the installer
/// carries it to the installed disk and the app can read it as its environment.
pub const MEDIA_KIND_BOOT: &str = "korp.env.NORDISK_MEDIA_KIND";
/// **The bake a product's DEV option declares**: ON records `dev`, OFF records `release`.
pub fn kind_bake() -> OptionBake {
OptionBake { boot_param: MEDIA_KIND_BOOT.to_string(), when_on: MediaKind::Dev.word().to_string(), when_off: MediaKind::Release.word().to_string() }
}
/// Pure: **the kind a medium's bytes record** (its command line): `None` when it records
/// none; refused when it records both.
pub fn kind_in_medium(bytes: &[u8]) -> Result<Option<MediaKind>> {
// A token ends at a separator (or the end), never inside a longer word.
let has = |k: MediaKind| {
let needle = format!("{MEDIA_KIND_BOOT}={}", k.word());
let n = needle.len();
(0..bytes.len().saturating_sub(n - 1)).any(|i| &bytes[i..i + n] == needle.as_bytes() && bytes.get(i + n).is_none_or(|b| !b.is_ascii_alphanumeric()))
};
match (has(MediaKind::Dev), has(MediaKind::Release)) {
(true, true) => bail!("[media-kind-twice] the medium records both kinds ({MEDIA_KIND_BOOT}=dev and =release)"),
(true, false) => Ok(Some(MediaKind::Dev)),
(false, true) => Ok(Some(MediaKind::Release)),
(false, false) => Ok(None),
}
}
/// Pure: **a medium is of the kind asked**, read back from its bytes: refused by name when
/// it records another kind (`[media-kind-mismatch]`) or none (`[media-kind-unrecorded]`).
pub fn refuse_kind(word: &str, asked: MediaKind, bytes: &[u8]) -> Result<()> {
match kind_in_medium(bytes)? {
Some(k) if k == asked => Ok(()),
Some(k) => bail!("[media-kind-mismatch] the {word} medium is a {} medium ({MEDIA_KIND_BOOT}={}), and the upload says {}: compose it as {} or upload it as what it is", k.word().to_uppercase(), k.word(), asked.word(), asked.word()),
None => bail!("[media-kind-unrecorded] the {word} medium records no kind ({MEDIA_KIND_BOOT}=dev|release on its command line): compose it again"),
}
}
/// **One repository a medium is composed from**: its full HEAD (`""` when it is not a
/// checkout) and every path `git status` names, untracked files included.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoPin {
pub repo: String,
pub head: String,
pub dirty: Vec<String>,
}
/// **The pin of the checkout at `dir`**: `git rev-parse HEAD` and
/// `git status --porcelain --untracked-files=all`. A directory that is not a checkout pins
/// `""`, which [`refuse_unpinned`] refuses.
pub fn pin_of(repo: &str, dir: &Path) -> RepoPin {
let git = |args: &[&str]| std::process::Command::new("git").arg("-C").arg(dir).args(args).output().ok().filter(|o| o.status.success()).map(|o| String::from_utf8_lossy(&o.stdout).to_string());
let head = git(&["rev-parse", "HEAD"]).map(|h| h.trim().to_string()).filter(|h| h.len() >= 40).unwrap_or_default();
let dirty = if head.is_empty() {
Vec::new()
} else {
match git(&["status", "--porcelain=v1", "--untracked-files=all"]) {
Some(s) => s.lines().map(|l| l.get(3..).unwrap_or(l).trim().to_string()).filter(|p| !p.is_empty()).collect(),
None => vec!["(git status failed)".to_string()],
}
};
RepoPin { repo: repo.to_string(), head, dirty }
}
/// Pure: **every tree is a clean checkout**, refused by name otherwise: a repository that is
/// not a checkout (`[media-not-a-checkout]`), or one with changes or untracked files
/// (`[media-dirty-tree]`, the first paths named).
pub fn refuse_unpinned(pins: &[RepoPin]) -> Result<()> {
if let Some(p) = pins.iter().find(|p| p.head.len() < 40) {
bail!("[media-not-a-checkout] {} is not a checkout here, so no medium can name its commit", p.repo);
}
let dirty: Vec<String> = pins
.iter()
.filter(|p| !p.dirty.is_empty())
.map(|p| {
let shown: Vec<&str> = p.dirty.iter().take(5).map(String::as_str).collect();
let more = p.dirty.len().saturating_sub(shown.len());
format!("{} ({}{})", p.repo, shown.join(", "), if more > 0 { format!(" and {more} more") } else { String::new() })
})
.collect();
if !dirty.is_empty() {
bail!("[media-dirty-tree] a medium is composed from committed trees only, untracked files included: {} — commit or remove them", dirty.join("; "));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ The kind is a word both ways, and the DEV option's bake records it on the medium.
#[test]
fn the_kind_is_baked_onto_the_medium_and_read_back() {
assert_eq!(MediaKind::parse("dev"), Some(MediaKind::Dev));
assert_eq!(MediaKind::parse("release"), Some(MediaKind::Release));
assert_eq!(MediaKind::parse("nightly"), None);
assert!(MediaKind::Dev.narrates() && !MediaKind::Release.narrates());
let b = kind_bake();
assert_eq!((b.boot_param.as_str(), b.when_on.as_str(), b.when_off.as_str()), (MEDIA_KIND_BOOT, "dev", "release"));
let dev = b"...console=ttyS0 korp.env.NORDISK_MEDIA_KIND=dev korp.live_update=on\0...".to_vec();
let rel = b"console=ttyS0 korp.env.NORDISK_MEDIA_KIND=release\n".to_vec();
assert_eq!(kind_in_medium(&dev).unwrap(), Some(MediaKind::Dev));
assert_eq!(kind_in_medium(&rel).unwrap(), Some(MediaKind::Release));
assert_eq!(kind_in_medium(b"console=ttyS0").unwrap(), None);
assert_eq!(kind_in_medium(b"korp.env.NORDISK_MEDIA_KIND=devx ").unwrap(), None, "a longer word is not the kind");
assert_eq!(kind_in_medium(b"x korp.env.NORDISK_MEDIA_KIND=dev").unwrap(), Some(MediaKind::Dev), "at the very end");
let both = [dev.as_slice(), rel.as_slice()].concat();
assert!(format!("{:#}", kind_in_medium(&both).unwrap_err()).contains("[media-kind-twice]"));
}
/// ★ An upload that names another kind than the medium records is refused by name.
#[test]
fn a_medium_of_another_kind_is_refused_by_name() {
let dev = b"korp.env.NORDISK_MEDIA_KIND=dev ";
assert!(refuse_kind("holger", MediaKind::Dev, dev).is_ok());
let e = format!("{:#}", refuse_kind("holger", MediaKind::Release, dev).unwrap_err());
assert!(e.contains("[media-kind-mismatch]") && e.contains("holger") && e.contains("DEV"), "{e}");
let e = format!("{:#}", refuse_kind("holger", MediaKind::Release, b"console=ttyS0").unwrap_err());
assert!(e.contains("[media-kind-unrecorded]"), "{e}");
}
fn git(dir: &Path, args: &[&str]) {
let ok = std::process::Command::new("git").arg("-C").arg(dir).args(["-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"]).args(args).output().unwrap();
assert!(ok.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&ok.stderr));
}
/// ★ A tree is pinned by its full HEAD; a change, or an UNTRACKED file, makes it dirty
/// and is refused by name; a directory that is not a checkout is refused too.
#[test]
fn a_dirty_tree_untracked_files_included_is_refused() {
let d = tempfile::tempdir().unwrap();
git(d.path(), &["init", "-q"]);
std::fs::write(d.path().join("a.txt"), "a").unwrap();
git(d.path(), &["add", "a.txt"]);
git(d.path(), &["commit", "-q", "-m", "a"]);
let clean = pin_of("holger", d.path());
assert_eq!(clean.head.len(), 40, "{clean:?}");
assert!(clean.dirty.is_empty(), "{clean:?}");
assert!(refuse_unpinned(std::slice::from_ref(&clean)).is_ok());
std::fs::write(d.path().join("new.rs"), "untracked").unwrap();
let untracked = pin_of("holger", d.path());
assert_eq!(untracked.dirty, ["new.rs"]);
let e = format!("{:#}", refuse_unpinned(&[clean.clone(), untracked]).unwrap_err());
assert!(e.contains("[media-dirty-tree]") && e.contains("holger (new.rs)"), "{e}");
std::fs::remove_file(d.path().join("new.rs")).unwrap();
std::fs::write(d.path().join("a.txt"), "changed").unwrap();
assert_eq!(pin_of("holger", d.path()).dirty, ["a.txt"]);
let none = tempfile::tempdir().unwrap();
let p = pin_of("tunnr", none.path());
assert_eq!(p.head, "");
assert!(format!("{:#}", refuse_unpinned(&[p]).unwrap_err()).contains("[media-not-a-checkout] tunnr"));
}
}