use scheme_edit::{list, quoted_sym, string_lit, sym, Document, Item, Node};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsFlavor {
Guix,
Nonguix,
Panther,
}
#[derive(Debug, Clone)]
pub enum FsDevice {
Label(String),
Uuid(String),
UuidWithType(String, String),
Path(String),
}
#[derive(Debug, Clone)]
enum LuksSource {
Path(String),
Uuid(String),
}
#[derive(Debug, Clone)]
enum Bootloader {
Efi(String),
Bios(String),
}
#[derive(Debug, Clone)]
struct FileSystemSpec {
mount_point: String,
device: FsDevice,
fs_type: String,
dependencies_mapped: bool,
}
#[derive(Debug, Clone)]
struct UserSpec {
name: String,
comment: String,
groups: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct OsBuilder {
flavor: OsFlavor,
host_name: String,
timezone: String,
locale: String,
keyboard_layout: Option<(String, Option<String>)>,
bootloader: Option<Bootloader>,
luks: Option<(LuksSource, String)>,
file_systems: Vec<FileSystemSpec>,
swap_space_file: Option<String>,
users: Vec<UserSpec>,
packages: Option<String>,
services: Option<String>,
extra_fields: Vec<(String, String)>,
extra_modules: Vec<String>,
}
impl OsBuilder {
pub fn new(flavor: OsFlavor, host_name: &str, timezone: &str, locale: &str) -> Self {
Self {
flavor,
host_name: host_name.to_string(),
timezone: timezone.to_string(),
locale: locale.to_string(),
keyboard_layout: None,
bootloader: None,
luks: None,
file_systems: Vec::new(),
swap_space_file: None,
users: Vec::new(),
packages: None,
services: None,
extra_fields: Vec::new(),
extra_modules: Vec::new(),
}
}
pub fn keyboard_layout(mut self, layout: &str, variant: Option<&str>) -> Self {
self.keyboard_layout = Some((layout.to_string(), variant.map(str::to_string)));
self
}
pub fn bootloader_efi(mut self, esp_mount: &str) -> Self {
self.bootloader = Some(Bootloader::Efi(esp_mount.to_string()));
self
}
pub fn bootloader_bios(mut self, device: &str) -> Self {
self.bootloader = Some(Bootloader::Bios(device.to_string()));
self
}
pub fn luks_root(mut self, partition: &str, mapped_name: &str) -> Self {
self.luks = Some((
LuksSource::Path(partition.to_string()),
mapped_name.to_string(),
));
self
}
pub fn luks_root_uuid(mut self, uuid: &str, mapped_name: &str) -> Self {
self.luks = Some((LuksSource::Uuid(uuid.to_string()), mapped_name.to_string()));
self
}
pub fn file_system(
mut self,
mount_point: &str,
device: FsDevice,
fs_type: &str,
dependencies_mapped: bool,
) -> Self {
self.file_systems.push(FileSystemSpec {
mount_point: mount_point.to_string(),
device,
fs_type: fs_type.to_string(),
dependencies_mapped,
});
self
}
pub fn swap_space_file(mut self, target: &str) -> Self {
self.swap_space_file = Some(target.to_string());
self
}
pub fn user(mut self, name: &str, comment: &str, groups: &[&str]) -> Self {
self.users.push(UserSpec {
name: name.to_string(),
comment: comment.to_string(),
groups: groups.iter().map(|g| (*g).to_string()).collect(),
});
self
}
pub fn packages_field(mut self, expr_source: &str) -> Self {
self.packages = Some(expr_source.to_string());
self
}
pub fn services_field(mut self, expr_source: &str) -> Self {
self.services = Some(expr_source.to_string());
self
}
pub fn extra_field(mut self, field: &str, expr_source: &str) -> Self {
self.extra_fields
.push((field.to_string(), expr_source.to_string()));
self
}
pub fn use_modules(mut self, modules: &[&str]) -> Self {
self.extra_modules
.extend(modules.iter().map(|m| (*m).to_string()));
self
}
pub fn try_build(&self) -> Result<String, crate::Error> {
let mut out = String::new();
for line in self.preamble_lines() {
out.push_str(&line);
out.push('\n');
}
out.push('\n');
out.push_str(&self.try_os_node()?.to_pretty(0));
out.push('\n');
Ok(out)
}
pub fn build(self) -> String {
self.try_build().unwrap_or_else(|e| panic!("{e}"))
}
fn preamble_lines(&self) -> Vec<String> {
let mut lines = vec![
"(use-modules (gnu))".to_string(),
"(use-service-modules networking ssh desktop)".to_string(),
];
match self.flavor {
OsFlavor::Guix => {}
OsFlavor::Nonguix => {
lines.push("(use-modules (nongnu packages linux))".to_string());
lines.push("(use-modules (nongnu system linux-initrd))".to_string());
}
OsFlavor::Panther => {
lines.push("(use-modules (px system os))".to_string());
}
}
for m in &self.extra_modules {
lines.push(format!("(use-modules {m})"));
}
lines
}
fn try_os_node(&self) -> Result<Node, crate::Error> {
let mut fields = vec![sym("operating-system")];
if self.flavor == OsFlavor::Panther {
fields.push(list(vec![sym("inherit"), sym("%os-base")]));
}
fields.push(list(vec![sym("host-name"), string_lit(&self.host_name)]));
fields.push(list(vec![sym("timezone"), string_lit(&self.timezone)]));
fields.push(list(vec![sym("locale"), string_lit(&self.locale)]));
if let Some((layout, variant)) = &self.keyboard_layout {
let mut kb = vec![sym("keyboard-layout"), string_lit(layout)];
if let Some(v) = variant {
kb.push(string_lit(v));
}
fields.push(list(vec![sym("keyboard-layout"), list(kb)]));
}
if let Some(bl) = &self.bootloader {
fields.push(self.bootloader_node(bl));
}
if self.flavor == OsFlavor::Nonguix {
fields.push(list(vec![sym("kernel"), sym("linux")]));
fields.push(list(vec![sym("initrd"), sym("microcode-initrd")]));
fields.push(list(vec![
sym("firmware"),
list(vec![sym("list"), sym("linux-firmware")]),
]));
}
if let Some((source, name)) = &self.luks {
let source_node = match source {
LuksSource::Path(p) => string_lit(p),
LuksSource::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
};
fields.push(list(vec![
sym("mapped-devices"),
list(vec![
sym("list"),
list(vec![
sym("mapped-device"),
list(vec![sym("source"), source_node]),
list(vec![sym("target"), string_lit(name)]),
list(vec![sym("type"), sym("luks-device-mapping")]),
]),
]),
]));
}
if !self.file_systems.is_empty() {
let mut fs_list = vec![sym("list")];
fs_list.extend(self.file_systems.iter().map(file_system_node));
fields.push(list(vec![
sym("file-systems"),
list(vec![
sym("append"),
list(fs_list),
sym("%base-file-systems"),
]),
]));
}
if let Some(target) = &self.swap_space_file {
fields.push(list(vec![
sym("swap-devices"),
list(vec![
sym("list"),
list(vec![
sym("swap-space"),
list(vec![sym("target"), string_lit(target)]),
]),
]),
]));
}
if !self.users.is_empty() {
let mut accounts = vec![sym("list")];
accounts.extend(self.users.iter().map(user_account_node));
fields.push(list(vec![
sym("users"),
list(vec![
sym("append"),
list(accounts),
sym("%base-user-accounts"),
]),
]));
} else if !self.extra_fields.iter().any(|(f, _)| f == "users") {
fields.push(list(vec![sym("users"), sym("%base-user-accounts")]));
}
if let Some(p) = &self.packages {
fields.push(list(vec![sym("packages"), try_parse_expr("packages", p)?]));
}
if let Some(s) = &self.services {
fields.push(list(vec![sym("services"), try_parse_expr("services", s)?]));
}
for (field, expr) in &self.extra_fields {
fields.push(list(vec![sym(field), try_parse_expr(field, expr)?]));
}
Ok(list(fields))
}
fn bootloader_node(&self, bl: &Bootloader) -> Node {
let (bl_sym, target) = match bl {
Bootloader::Efi(esp) => ("grub-efi-bootloader", esp),
Bootloader::Bios(dev) => ("grub-bootloader", dev),
};
let mut cfg = vec![
sym("bootloader-configuration"),
list(vec![sym("bootloader"), sym(bl_sym)]),
list(vec![
sym("targets"),
list(vec![sym("list"), string_lit(target)]),
]),
];
if self.keyboard_layout.is_some() {
cfg.push(list(vec![sym("keyboard-layout"), sym("keyboard-layout")]));
}
list(vec![sym("bootloader"), list(cfg)])
}
}
fn file_system_node(fs: &FileSystemSpec) -> Node {
let device = match &fs.device {
FsDevice::Label(l) => list(vec![sym("file-system-label"), string_lit(l)]),
FsDevice::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
FsDevice::UuidWithType(u, tag) => list(vec![sym("uuid"), string_lit(u), quoted_sym(tag)]),
FsDevice::Path(p) => string_lit(p),
};
let mut parts = vec![
sym("file-system"),
list(vec![sym("mount-point"), string_lit(&fs.mount_point)]),
list(vec![sym("device"), device]),
list(vec![sym("type"), string_lit(&fs.fs_type)]),
];
if fs.dependencies_mapped {
parts.push(list(vec![sym("dependencies"), sym("mapped-devices")]));
}
list(parts)
}
fn user_account_node(u: &UserSpec) -> Node {
let mut groups = vec![sym("list")];
groups.extend(u.groups.iter().map(|g| string_lit(g)));
list(vec![
sym("user-account"),
list(vec![sym("name"), string_lit(&u.name)]),
list(vec![sym("comment"), string_lit(&u.comment)]),
list(vec![sym("group"), string_lit("users")]),
list(vec![sym("supplementary-groups"), list(groups)]),
])
}
fn try_parse_expr(field: &str, src: &str) -> Result<Node, crate::Error> {
let doc = Document::parse(src).map_err(|e| crate::Error::Invalid {
field: field.into(),
reason: format!("invalid scheme expression `{src}`: {e}"),
})?;
doc.items
.into_iter()
.find_map(|i| match i {
Item::Node(n) => Some(n),
_ => None,
})
.ok_or_else(|| crate::Error::Invalid {
field: field.into(),
reason: format!("empty scheme expression `{src}`"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Error;
#[test]
fn try_build_ok_for_valid_fields() {
let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.packages_field("%base-packages")
.services_field("(list)")
.try_build()
.expect("valid fields should build");
assert!(out.contains("(packages %base-packages)"));
assert!(out.contains("(services (list))"));
}
#[test]
fn try_build_err_on_invalid_packages() {
let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.packages_field("(unclosed")
.try_build()
.unwrap_err();
assert!(matches!(err, Error::Invalid { field, .. } if field == "packages"));
}
#[test]
fn try_build_err_on_invalid_services() {
let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.services_field("(oops")
.try_build()
.unwrap_err();
assert!(matches!(err, Error::Invalid { field, .. } if field == "services"));
}
#[test]
fn try_build_err_on_invalid_extra_field() {
let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.extra_field("swap-devices", "(bad")
.try_build()
.unwrap_err();
assert!(matches!(err, Error::Invalid { field, .. } if field == "swap-devices"));
}
#[test]
fn try_build_err_on_empty_expression() {
let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.packages_field(" ")
.try_build()
.unwrap_err();
match err {
Error::Invalid { field, reason } => {
assert_eq!(field, "packages");
assert!(reason.contains("empty"), "reason was: {reason}");
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn build_produces_expected_string_for_valid_builder() {
let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.packages_field("%base-packages")
.build();
assert!(out.starts_with("(use-modules (gnu))\n"));
assert!(out.contains("(operating-system"));
assert!(out.contains("(host-name \"h\")"));
assert!(out.contains("(packages %base-packages)"));
assert!(out.ends_with('\n'));
}
#[test]
fn swap_space_file_emits_swap_space_target() {
let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.file_system("/", FsDevice::Label("root".into()), "ext4", false)
.swap_space_file("/swapfile")
.build();
assert!(
out.contains("(swap-devices (list (swap-space (target \"/swapfile\"))))"),
"output was:\n{out}"
);
}
#[test]
fn swap_positioned_between_file_systems_and_users() {
let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.file_system("/", FsDevice::Label("root".into()), "ext4", false)
.swap_space_file("/swapfile")
.user("alice", "Alice", &["wheel"])
.build();
let fs = out.find("file-systems").expect("file-systems present");
let swap = out.find("swap-devices").expect("swap-devices present");
let users = out.find("users").expect("users present");
assert!(fs < swap && swap < users, "ordering wrong in:\n{out}");
}
#[test]
#[should_panic(expected = "invalid packages")]
fn build_panics_on_invalid_field() {
let _ = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
.packages_field("(unclosed")
.build();
}
}