Skip to main content

guix_scheme/
os_builder.rs

1//! Builder for `(operating-system ...)` system configurations, mirroring
2//! what guix-install renders.
3
4use scheme_edit::{list, quoted_sym, string_lit, sym, Document, Item, Node};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum OsFlavor {
8    Guix,
9    Nonguix,
10    /// `(inherit %os-base)` via `(px system os)`.
11    Panther,
12}
13
14#[derive(Debug, Clone)]
15pub enum FsDevice {
16    /// `(file-system-label "x")`
17    Label(String),
18    /// `(uuid "x")`
19    Uuid(String),
20    /// `(uuid "x" 'tag)`, e.g. `(uuid "ABCD-1234" 'fat32)`
21    UuidWithType(String, String),
22    /// `"x"`
23    Path(String),
24}
25
26#[derive(Debug, Clone)]
27enum LuksSource {
28    Path(String),
29    Uuid(String),
30}
31
32#[derive(Debug, Clone)]
33enum Bootloader {
34    Efi(String),
35    Bios(String),
36}
37
38#[derive(Debug, Clone)]
39struct FileSystemSpec {
40    mount_point: String,
41    device: FsDevice,
42    fs_type: String,
43    dependencies_mapped: bool,
44}
45
46#[derive(Debug, Clone)]
47struct UserSpec {
48    name: String,
49    comment: String,
50    groups: Vec<String>,
51}
52
53#[derive(Debug, Clone)]
54pub struct OsBuilder {
55    flavor: OsFlavor,
56    host_name: String,
57    timezone: String,
58    locale: String,
59    keyboard_layout: Option<(String, Option<String>)>,
60    bootloader: Option<Bootloader>,
61    luks: Option<(LuksSource, String)>,
62    file_systems: Vec<FileSystemSpec>,
63    swap_space_file: Option<String>,
64    users: Vec<UserSpec>,
65    packages: Option<String>,
66    services: Option<String>,
67    extra_fields: Vec<(String, String)>,
68    extra_modules: Vec<String>,
69}
70
71impl OsBuilder {
72    pub fn new(flavor: OsFlavor, host_name: &str, timezone: &str, locale: &str) -> Self {
73        Self {
74            flavor,
75            host_name: host_name.to_string(),
76            timezone: timezone.to_string(),
77            locale: locale.to_string(),
78            keyboard_layout: None,
79            bootloader: None,
80            luks: None,
81            file_systems: Vec::new(),
82            swap_space_file: None,
83            users: Vec::new(),
84            packages: None,
85            services: None,
86            extra_fields: Vec::new(),
87            extra_modules: Vec::new(),
88        }
89    }
90
91    pub fn keyboard_layout(mut self, layout: &str, variant: Option<&str>) -> Self {
92        self.keyboard_layout = Some((layout.to_string(), variant.map(str::to_string)));
93        self
94    }
95
96    /// grub-efi-bootloader with `(targets (list esp))`.
97    pub fn bootloader_efi(mut self, esp_mount: &str) -> Self {
98        self.bootloader = Some(Bootloader::Efi(esp_mount.to_string()));
99        self
100    }
101
102    /// grub-bootloader targeting the whole device.
103    pub fn bootloader_bios(mut self, device: &str) -> Self {
104        self.bootloader = Some(Bootloader::Bios(device.to_string()));
105        self
106    }
107
108    /// LUKS mapping with a device-path source: `(source "<partition>")`.
109    pub fn luks_root(mut self, partition: &str, mapped_name: &str) -> Self {
110        self.luks = Some((
111            LuksSource::Path(partition.to_string()),
112            mapped_name.to_string(),
113        ));
114        self
115    }
116
117    /// LUKS mapping with a UUID source: `(source (uuid "<uuid>"))`.
118    pub fn luks_root_uuid(mut self, uuid: &str, mapped_name: &str) -> Self {
119        self.luks = Some((LuksSource::Uuid(uuid.to_string()), mapped_name.to_string()));
120        self
121    }
122
123    pub fn file_system(
124        mut self,
125        mount_point: &str,
126        device: FsDevice,
127        fs_type: &str,
128        dependencies_mapped: bool,
129    ) -> Self {
130        self.file_systems.push(FileSystemSpec {
131            mount_point: mount_point.to_string(),
132            device,
133            fs_type: fs_type.to_string(),
134            dependencies_mapped,
135        });
136        self
137    }
138
139    /// Swap on a regular file: `(swap-space (target "<target>"))`.
140    pub fn swap_space_file(mut self, target: &str) -> Self {
141        self.swap_space_file = Some(target.to_string());
142        self
143    }
144
145    pub fn user(mut self, name: &str, comment: &str, groups: &[&str]) -> Self {
146        self.users.push(UserSpec {
147            name: name.to_string(),
148            comment: comment.to_string(),
149            groups: groups.iter().map(|g| (*g).to_string()).collect(),
150        });
151        self
152    }
153
154    /// Raw packages expression the caller assembles (e.g. the whole
155    /// `(list ...)` or `%base-packages`); invalid Scheme surfaces via
156    /// [`OsBuilder::try_build`]. This is the intended packages seam.
157    pub fn packages_field(mut self, expr_source: &str) -> Self {
158        self.packages = Some(expr_source.to_string());
159        self
160    }
161
162    /// Raw services expression the caller assembles (e.g. the whole
163    /// `(list ...)` of services); invalid Scheme surfaces via
164    /// [`OsBuilder::try_build`]. This is the intended services seam.
165    pub fn services_field(mut self, expr_source: &str) -> Self {
166        self.services = Some(expr_source.to_string());
167        self
168    }
169
170    /// Escape hatch: caller-assembled raw expression parsed via
171    /// `Document::parse` at build time; invalid Scheme surfaces via
172    /// [`OsBuilder::try_build`].
173    pub fn extra_field(mut self, field: &str, expr_source: &str) -> Self {
174        self.extra_fields
175            .push((field.to_string(), expr_source.to_string()));
176        self
177    }
178
179    /// Additional `(use-modules ...)` lines.
180    pub fn use_modules(mut self, modules: &[&str]) -> Self {
181        self.extra_modules
182            .extend(modules.iter().map(|m| (*m).to_string()));
183        self
184    }
185
186    /// Preamble + pretty-printed os form + trailing newline.
187    ///
188    /// # Errors
189    ///
190    /// Returns [`Error::Invalid`] when a stored raw expression
191    /// (`packages_field`, `services_field`, `extra_field`) is not valid
192    /// Scheme or carries no datum.
193    pub fn try_build(&self) -> Result<String, crate::Error> {
194        let mut out = String::new();
195        for line in self.preamble_lines() {
196            out.push_str(&line);
197            out.push('\n');
198        }
199        out.push('\n');
200        out.push_str(&self.try_os_node()?.to_pretty(0));
201        out.push('\n');
202        Ok(out)
203    }
204
205    /// Preamble + pretty-printed os form + trailing newline.
206    ///
207    /// # Panics
208    ///
209    /// Panics when an `expr_source` is not valid Scheme. Use
210    /// [`OsBuilder::try_build`] for a non-panicking alternative.
211    pub fn build(self) -> String {
212        self.try_build().unwrap_or_else(|e| panic!("{e}"))
213    }
214
215    fn preamble_lines(&self) -> Vec<String> {
216        let mut lines = vec![
217            "(use-modules (gnu))".to_string(),
218            "(use-service-modules networking ssh desktop)".to_string(),
219        ];
220        match self.flavor {
221            OsFlavor::Guix => {}
222            OsFlavor::Nonguix => {
223                lines.push("(use-modules (nongnu packages linux))".to_string());
224                lines.push("(use-modules (nongnu system linux-initrd))".to_string());
225            }
226            OsFlavor::Panther => {
227                lines.push("(use-modules (px system os))".to_string());
228            }
229        }
230        for m in &self.extra_modules {
231            lines.push(format!("(use-modules {m})"));
232        }
233        lines
234    }
235
236    fn try_os_node(&self) -> Result<Node, crate::Error> {
237        let mut fields = vec![sym("operating-system")];
238        if self.flavor == OsFlavor::Panther {
239            fields.push(list(vec![sym("inherit"), sym("%os-base")]));
240        }
241        fields.push(list(vec![sym("host-name"), string_lit(&self.host_name)]));
242        fields.push(list(vec![sym("timezone"), string_lit(&self.timezone)]));
243        fields.push(list(vec![sym("locale"), string_lit(&self.locale)]));
244        if let Some((layout, variant)) = &self.keyboard_layout {
245            let mut kb = vec![sym("keyboard-layout"), string_lit(layout)];
246            if let Some(v) = variant {
247                kb.push(string_lit(v));
248            }
249            fields.push(list(vec![sym("keyboard-layout"), list(kb)]));
250        }
251        if let Some(bl) = &self.bootloader {
252            fields.push(self.bootloader_node(bl));
253        }
254        if self.flavor == OsFlavor::Nonguix {
255            fields.push(list(vec![sym("kernel"), sym("linux")]));
256            fields.push(list(vec![sym("initrd"), sym("microcode-initrd")]));
257            fields.push(list(vec![
258                sym("firmware"),
259                list(vec![sym("list"), sym("linux-firmware")]),
260            ]));
261        }
262        if let Some((source, name)) = &self.luks {
263            let source_node = match source {
264                LuksSource::Path(p) => string_lit(p),
265                LuksSource::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
266            };
267            fields.push(list(vec![
268                sym("mapped-devices"),
269                list(vec![
270                    sym("list"),
271                    list(vec![
272                        sym("mapped-device"),
273                        list(vec![sym("source"), source_node]),
274                        list(vec![sym("target"), string_lit(name)]),
275                        list(vec![sym("type"), sym("luks-device-mapping")]),
276                    ]),
277                ]),
278            ]));
279        }
280        if !self.file_systems.is_empty() {
281            let mut fs_list = vec![sym("list")];
282            fs_list.extend(self.file_systems.iter().map(file_system_node));
283            fields.push(list(vec![
284                sym("file-systems"),
285                list(vec![
286                    sym("append"),
287                    list(fs_list),
288                    sym("%base-file-systems"),
289                ]),
290            ]));
291        }
292        if let Some(target) = &self.swap_space_file {
293            fields.push(list(vec![
294                sym("swap-devices"),
295                list(vec![
296                    sym("list"),
297                    list(vec![
298                        sym("swap-space"),
299                        list(vec![sym("target"), string_lit(target)]),
300                    ]),
301                ]),
302            ]));
303        }
304        if !self.users.is_empty() {
305            let mut accounts = vec![sym("list")];
306            accounts.extend(self.users.iter().map(user_account_node));
307            fields.push(list(vec![
308                sym("users"),
309                list(vec![
310                    sym("append"),
311                    list(accounts),
312                    sym("%base-user-accounts"),
313                ]),
314            ]));
315        } else if !self.extra_fields.iter().any(|(f, _)| f == "users") {
316            fields.push(list(vec![sym("users"), sym("%base-user-accounts")]));
317        }
318        if let Some(p) = &self.packages {
319            fields.push(list(vec![sym("packages"), try_parse_expr("packages", p)?]));
320        }
321        if let Some(s) = &self.services {
322            fields.push(list(vec![sym("services"), try_parse_expr("services", s)?]));
323        }
324        for (field, expr) in &self.extra_fields {
325            fields.push(list(vec![sym(field), try_parse_expr(field, expr)?]));
326        }
327        Ok(list(fields))
328    }
329
330    fn bootloader_node(&self, bl: &Bootloader) -> Node {
331        let (bl_sym, target) = match bl {
332            Bootloader::Efi(esp) => ("grub-efi-bootloader", esp),
333            Bootloader::Bios(dev) => ("grub-bootloader", dev),
334        };
335        let mut cfg = vec![
336            sym("bootloader-configuration"),
337            list(vec![sym("bootloader"), sym(bl_sym)]),
338            list(vec![
339                sym("targets"),
340                list(vec![sym("list"), string_lit(target)]),
341            ]),
342        ];
343        if self.keyboard_layout.is_some() {
344            cfg.push(list(vec![sym("keyboard-layout"), sym("keyboard-layout")]));
345        }
346        list(vec![sym("bootloader"), list(cfg)])
347    }
348}
349
350fn file_system_node(fs: &FileSystemSpec) -> Node {
351    let device = match &fs.device {
352        FsDevice::Label(l) => list(vec![sym("file-system-label"), string_lit(l)]),
353        FsDevice::Uuid(u) => list(vec![sym("uuid"), string_lit(u)]),
354        FsDevice::UuidWithType(u, tag) => list(vec![sym("uuid"), string_lit(u), quoted_sym(tag)]),
355        FsDevice::Path(p) => string_lit(p),
356    };
357    let mut parts = vec![
358        sym("file-system"),
359        list(vec![sym("mount-point"), string_lit(&fs.mount_point)]),
360        list(vec![sym("device"), device]),
361        list(vec![sym("type"), string_lit(&fs.fs_type)]),
362    ];
363    if fs.dependencies_mapped {
364        parts.push(list(vec![sym("dependencies"), sym("mapped-devices")]));
365    }
366    list(parts)
367}
368
369fn user_account_node(u: &UserSpec) -> Node {
370    let mut groups = vec![sym("list")];
371    groups.extend(u.groups.iter().map(|g| string_lit(g)));
372    list(vec![
373        sym("user-account"),
374        list(vec![sym("name"), string_lit(&u.name)]),
375        list(vec![sym("comment"), string_lit(&u.comment)]),
376        list(vec![sym("group"), string_lit("users")]),
377        list(vec![sym("supplementary-groups"), list(groups)]),
378    ])
379}
380
381fn try_parse_expr(field: &str, src: &str) -> Result<Node, crate::Error> {
382    let doc = Document::parse(src).map_err(|e| crate::Error::Invalid {
383        field: field.into(),
384        reason: format!("invalid scheme expression `{src}`: {e}"),
385    })?;
386    doc.items
387        .into_iter()
388        .find_map(|i| match i {
389            Item::Node(n) => Some(n),
390            _ => None,
391        })
392        .ok_or_else(|| crate::Error::Invalid {
393            field: field.into(),
394            reason: format!("empty scheme expression `{src}`"),
395        })
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::Error;
402
403    #[test]
404    fn try_build_ok_for_valid_fields() {
405        let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
406            .packages_field("%base-packages")
407            .services_field("(list)")
408            .try_build()
409            .expect("valid fields should build");
410        assert!(out.contains("(packages %base-packages)"));
411        assert!(out.contains("(services (list))"));
412    }
413
414    #[test]
415    fn try_build_err_on_invalid_packages() {
416        let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
417            .packages_field("(unclosed")
418            .try_build()
419            .unwrap_err();
420        assert!(matches!(err, Error::Invalid { field, .. } if field == "packages"));
421    }
422
423    #[test]
424    fn try_build_err_on_invalid_services() {
425        let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
426            .services_field("(oops")
427            .try_build()
428            .unwrap_err();
429        assert!(matches!(err, Error::Invalid { field, .. } if field == "services"));
430    }
431
432    #[test]
433    fn try_build_err_on_invalid_extra_field() {
434        let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
435            .extra_field("swap-devices", "(bad")
436            .try_build()
437            .unwrap_err();
438        assert!(matches!(err, Error::Invalid { field, .. } if field == "swap-devices"));
439    }
440
441    #[test]
442    fn try_build_err_on_empty_expression() {
443        let err = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
444            .packages_field("   ")
445            .try_build()
446            .unwrap_err();
447        match err {
448            Error::Invalid { field, reason } => {
449                assert_eq!(field, "packages");
450                assert!(reason.contains("empty"), "reason was: {reason}");
451            }
452            other => panic!("expected Invalid, got {other:?}"),
453        }
454    }
455
456    #[test]
457    fn build_produces_expected_string_for_valid_builder() {
458        let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
459            .packages_field("%base-packages")
460            .build();
461        assert!(out.starts_with("(use-modules (gnu))\n"));
462        assert!(out.contains("(operating-system"));
463        assert!(out.contains("(host-name \"h\")"));
464        assert!(out.contains("(packages %base-packages)"));
465        assert!(out.ends_with('\n'));
466    }
467
468    #[test]
469    fn swap_space_file_emits_swap_space_target() {
470        let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
471            .file_system("/", FsDevice::Label("root".into()), "ext4", false)
472            .swap_space_file("/swapfile")
473            .build();
474        assert!(
475            out.contains("(swap-devices (list (swap-space (target \"/swapfile\"))))"),
476            "output was:\n{out}"
477        );
478    }
479
480    #[test]
481    fn swap_positioned_between_file_systems_and_users() {
482        let out = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
483            .file_system("/", FsDevice::Label("root".into()), "ext4", false)
484            .swap_space_file("/swapfile")
485            .user("alice", "Alice", &["wheel"])
486            .build();
487        let fs = out.find("file-systems").expect("file-systems present");
488        let swap = out.find("swap-devices").expect("swap-devices present");
489        let users = out.find("users").expect("users present");
490        assert!(fs < swap && swap < users, "ordering wrong in:\n{out}");
491    }
492
493    #[test]
494    #[should_panic(expected = "invalid packages")]
495    fn build_panics_on_invalid_field() {
496        let _ = OsBuilder::new(OsFlavor::Guix, "h", "UTC", "en_US.utf8")
497            .packages_field("(unclosed")
498            .build();
499    }
500}