bootc_lib/
bootc_kargs.rs

1//! This module handles the bootc-owned kernel argument lists in `/usr/lib/bootc/kargs.d`.
2use anyhow::{Context, Result};
3use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
4use camino::Utf8Path;
5use cap_std_ext::cap_std::fs::Dir;
6use cap_std_ext::cap_std::fs_utf8::Dir as DirUtf8;
7use cap_std_ext::dirext::CapStdExtDirExt;
8use cap_std_ext::dirext::CapStdExtDirExtUtf8;
9use ostree::gio;
10use ostree_ext::ostree;
11use ostree_ext::ostree::Deployment;
12use ostree_ext::prelude::Cast;
13use ostree_ext::prelude::FileEnumeratorExt;
14use ostree_ext::prelude::FileExt;
15use serde::Deserialize;
16
17use crate::deploy::ImageState;
18use crate::store::Storage;
19
20/// The relative path to the kernel arguments which may be embedded in an image.
21const KARGS_PATH: &str = "usr/lib/bootc/kargs.d";
22
23/// The default root filesystem mount specification.
24pub(crate) const ROOT_KEY: &str = "root";
25/// This is used by dracut.
26pub(crate) const INITRD_ARG_PREFIX: &str = "rd.";
27/// The kernel argument for configuring the rootfs flags.
28pub(crate) const ROOTFLAGS_KEY: &str = "rootflags";
29
30/// The kargs.d configuration file.
31#[derive(Deserialize)]
32#[serde(rename_all = "kebab-case", deny_unknown_fields)]
33struct Config {
34    /// Ordered list of kernel arguments.
35    kargs: Vec<String>,
36    /// Optional list of architectures (using the Rust naming conventions);
37    /// if present and the current architecture doesn't match, the file is skipped.
38    match_architectures: Option<Vec<String>>,
39}
40
41impl Config {
42    /// Return true if the filename is one we should parse.
43    fn filename_matches(name: &str) -> bool {
44        matches!(Utf8Path::new(name).extension(), Some("toml"))
45    }
46}
47
48/// Compute the diff between existing and remote kargs
49/// Apply the diff to the new kargs
50fn compute_apply_kargs_diff(
51    existing_kargs: &Cmdline,
52    remote_kargs: &Cmdline,
53    new_kargs: &mut Cmdline,
54) {
55    // Calculate the diff between the existing and remote kargs
56    let added_kargs: Vec<_> = remote_kargs
57        .iter()
58        .filter(|item| !existing_kargs.iter().any(|existing| *item == existing))
59        .collect();
60    let removed_kargs: Vec<_> = existing_kargs
61        .iter()
62        .filter(|item| !remote_kargs.iter().any(|remote| *item == remote))
63        .collect();
64
65    tracing::debug!("kargs: added={:?} removed={:?}", added_kargs, removed_kargs);
66
67    // Apply the diff to the system kargs
68    for arg in &removed_kargs {
69        new_kargs.remove_exact(arg);
70    }
71    for arg in &added_kargs {
72        new_kargs.add(arg);
73    }
74}
75
76/// Computes new kernel arguments by applying the diff between existing and new kargs.d files.
77///
78/// This function:
79/// 1. Loads kernel arguments from `usr/lib/bootc/kargs.d` in the new filesystem
80/// 2. Loads kernel arguments from the current root (if present)
81/// 3. Computes the difference between them (added/removed arguments)
82/// 4. Applies that difference to the provided `new_kargs` cmdline
83///
84/// This allows bootc to maintain user customizations while applying changes from
85/// updated container images.
86///
87/// # Arguments
88/// * `new_fs`       - Directory handle to the new filesystem containing kargs.d files
89/// * `current_root` - Optional directory handle to current root filesystem
90/// * `new_kargs`    - Mutable cmdline that will be modified with the computed diff
91pub(crate) fn compute_new_kargs(
92    new_fs: &Dir,
93    current_root: Option<&Dir>,
94    new_kargs: &mut Cmdline,
95) -> Result<()> {
96    let remote_kargs = get_kargs_in_root(new_fs, std::env::consts::ARCH)?;
97
98    let existing_kargs = match current_root {
99        Some(root) => get_kargs_in_root(root, std::env::consts::ARCH)?,
100        None => Cmdline::new(),
101    };
102
103    compute_apply_kargs_diff(&existing_kargs, &remote_kargs, new_kargs);
104
105    Ok(())
106}
107
108/// Load and parse all bootc kargs.d files in the specified root, returning
109/// a combined list.
110pub(crate) fn get_kargs_in_root(d: &Dir, sys_arch: &str) -> Result<CmdlineOwned> {
111    // If the directory doesn't exist, that's OK.
112    let Some(d) = d.open_dir_optional(KARGS_PATH)?.map(DirUtf8::from_cap_std) else {
113        return Ok(Default::default());
114    };
115    let mut ret = Cmdline::new();
116    let entries = d.filenames_filtered_sorted(|_, name| Config::filename_matches(name))?;
117    for name in entries {
118        let buf = d.read_to_string(&name)?;
119        if let Some(kargs) =
120            parse_kargs_toml(&buf, sys_arch).with_context(|| format!("Parsing {name}"))?
121        {
122            ret.extend(&kargs)
123        }
124    }
125    Ok(ret)
126}
127
128pub(crate) fn root_args_from_cmdline(cmdline: &Cmdline) -> CmdlineOwned {
129    let mut result = Cmdline::new();
130    for param in cmdline {
131        let key = param.key();
132        if key == ROOT_KEY.into()
133            || key == ROOTFLAGS_KEY.into()
134            || key.starts_with(INITRD_ARG_PREFIX)
135        {
136            result.add(&param);
137        }
138    }
139    result
140}
141
142/// Load kargs.d files from the target ostree commit root
143pub(crate) fn get_kargs_from_ostree_root(
144    repo: &ostree::Repo,
145    root: &ostree::RepoFile,
146    sys_arch: &str,
147) -> Result<CmdlineOwned> {
148    let kargsd = root.resolve_relative_path(KARGS_PATH);
149    let kargsd = kargsd.downcast_ref::<ostree::RepoFile>().expect("downcast");
150    if !kargsd.query_exists(gio::Cancellable::NONE) {
151        return Ok(Default::default());
152    }
153    get_kargs_from_ostree(repo, kargsd, sys_arch)
154}
155
156/// Load kargs.d files from the target dir
157fn get_kargs_from_ostree(
158    repo: &ostree::Repo,
159    fetched_tree: &ostree::RepoFile,
160    sys_arch: &str,
161) -> Result<CmdlineOwned> {
162    let cancellable = gio::Cancellable::NONE;
163    let queryattrs = "standard::name,standard::type";
164    let queryflags = gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS;
165    let fetched_iter = fetched_tree.enumerate_children(queryattrs, queryflags, cancellable)?;
166    let mut ret = Cmdline::new();
167    while let Some(fetched_info) = fetched_iter.next_file(cancellable)? {
168        // only read and parse the file if it is a toml file
169        let name = fetched_info.name();
170        let Some(name) = name.to_str() else {
171            continue;
172        };
173        if !Config::filename_matches(name) {
174            continue;
175        }
176
177        let fetched_child = fetched_iter.child(&fetched_info);
178        let fetched_child = fetched_child
179            .downcast::<ostree::RepoFile>()
180            .expect("downcast");
181        fetched_child.ensure_resolved()?;
182        let fetched_contents_checksum = fetched_child.checksum();
183        let f = ostree::Repo::load_file(repo, fetched_contents_checksum.as_str(), cancellable)?;
184        let file_content = f.0;
185        let mut reader =
186            ostree_ext::prelude::InputStreamExtManual::into_read(file_content.unwrap());
187        let s = std::io::read_to_string(&mut reader)?;
188        if let Some(parsed_kargs) =
189            parse_kargs_toml(&s, sys_arch).with_context(|| format!("Parsing {name}"))?
190        {
191            ret.extend(&parsed_kargs);
192        }
193    }
194    Ok(ret)
195}
196
197/// Compute the kernel arguments for the new deployment. This starts from the booted
198/// karg, but applies the diff between the bootc karg files in /usr/lib/bootc/kargs.d
199/// between the booted deployment and the new one.
200pub(crate) fn get_kargs(
201    sysroot: &Storage,
202    merge_deployment: &Deployment,
203    fetched: &ImageState,
204) -> Result<CmdlineOwned> {
205    let cancellable = gio::Cancellable::NONE;
206    let ostree = sysroot.get_ostree()?;
207    let repo = &ostree.repo();
208    let sys_arch = std::env::consts::ARCH;
209
210    // Get the kargs used for the merge in the bootloader config
211    let mut kargs = ostree::Deployment::bootconfig(merge_deployment)
212        .and_then(|bootconfig| {
213            ostree::BootconfigParser::get(&bootconfig, "options")
214                .map(|options| Cmdline::from(options.to_string()))
215        })
216        .unwrap_or_default();
217
218    // Get the kargs in kargs.d of the merge
219    let merge_root = &crate::utils::deployment_fd(ostree, merge_deployment)?;
220    let existing_kargs = get_kargs_in_root(merge_root, sys_arch)?;
221
222    // Get the kargs in kargs.d of the pending image
223    let (fetched_tree, _) = repo.read_commit(fetched.ostree_commit.as_str(), cancellable)?;
224    let fetched_tree = fetched_tree.resolve_relative_path(KARGS_PATH);
225    let fetched_tree = fetched_tree
226        .downcast::<ostree::RepoFile>()
227        .expect("downcast");
228    // A special case: if there's no kargs.d directory in the pending (fetched) image,
229    // then we can just use the combined current kargs + kargs from booted
230    if !fetched_tree.query_exists(cancellable) {
231        kargs.extend(&existing_kargs);
232        return Ok(kargs);
233    }
234
235    // Fetch the kernel arguments from the new root
236    let remote_kargs = get_kargs_from_ostree(repo, &fetched_tree, sys_arch)?;
237
238    compute_apply_kargs_diff(&existing_kargs, &remote_kargs, &mut kargs);
239
240    Ok(kargs)
241}
242
243/// This parses a bootc kargs.d toml file, returning the resulting
244/// vector of kernel arguments. Architecture matching is performed using
245/// `sys_arch`.
246fn parse_kargs_toml(contents: &str, sys_arch: &str) -> Result<Option<CmdlineOwned>> {
247    let de: Config = toml::from_str(contents)?;
248    // if arch specified, apply kargs only if the arch matches
249    // if arch not specified, apply kargs unconditionally
250    let matched = de
251        .match_architectures
252        .map(|arches| arches.iter().any(|s| s == sys_arch))
253        .unwrap_or(true);
254    let r = if matched {
255        Some(Cmdline::from(de.kargs.join(" ")))
256    } else {
257        None
258    };
259    Ok(r)
260}
261
262#[cfg(test)]
263mod tests {
264    use cap_std_ext::cap_std;
265    use fn_error_context::context;
266    use rustix::fd::{AsFd, AsRawFd};
267
268    use super::*;
269
270    fn assert_cmdline_eq(cmdline: &Cmdline, expected_params: &[&str]) {
271        let actual_params: Vec<_> = cmdline.iter_str().collect();
272        assert_eq!(actual_params, expected_params);
273    }
274
275    #[test]
276    /// Verify that kargs are only applied to supported architectures
277    fn test_arch() {
278        // no arch specified, kargs ensure that kargs are applied unconditionally
279        let sys_arch = "x86_64";
280        let file_content = r##"kargs = ["console=tty0", "nosmt"]"##.to_string();
281        let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap();
282        assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]);
283
284        let sys_arch = "aarch64";
285        let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap();
286        assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]);
287
288        // one arch matches and one doesn't, ensure that kargs are only applied for the matching arch
289        let sys_arch = "aarch64";
290        let file_content = r##"kargs = ["console=tty0", "nosmt"]
291match-architectures = ["x86_64"]
292"##
293        .to_string();
294        let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap();
295        assert!(parsed_kargs.is_none());
296        let file_content = r##"kargs = ["console=tty0", "nosmt"]
297match-architectures = ["aarch64"]
298"##
299        .to_string();
300        let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap();
301        assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]);
302
303        // multiple arch specified, ensure that kargs are applied to both archs
304        let sys_arch = "x86_64";
305        let file_content = r##"kargs = ["console=tty0", "nosmt"]
306match-architectures = ["x86_64", "aarch64"]
307"##
308        .to_string();
309        let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap();
310        assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]);
311
312        let sys_arch = "aarch64";
313        let parsed_kargs = parse_kargs_toml(&file_content, sys_arch).unwrap().unwrap();
314        assert_cmdline_eq(&parsed_kargs, &["console=tty0", "nosmt"]);
315    }
316
317    #[test]
318    /// Verify some error cases
319    fn test_invalid() {
320        let test_invalid_extra = r#"kargs = ["console=tty0", "nosmt"]\nfoo=bar"#;
321        assert!(parse_kargs_toml(test_invalid_extra, "x86_64").is_err());
322
323        let test_missing = r#"foo=bar"#;
324        assert!(parse_kargs_toml(test_missing, "x86_64").is_err());
325    }
326
327    #[context("writing test kargs")]
328    fn write_test_kargs(td: &Dir) -> Result<()> {
329        td.write(
330            "usr/lib/bootc/kargs.d/01-foo.toml",
331            r##"kargs = ["console=tty0", "nosmt"]"##,
332        )?;
333        td.write(
334            "usr/lib/bootc/kargs.d/02-bar.toml",
335            r##"kargs = ["console=ttyS1"]"##,
336        )?;
337
338        Ok(())
339    }
340
341    #[test]
342    fn test_get_kargs_in_root() -> Result<()> {
343        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
344
345        // No directory
346        assert_eq!(get_kargs_in_root(&td, "x86_64").unwrap().iter().count(), 0);
347        // Empty directory
348        td.create_dir_all("usr/lib/bootc/kargs.d")?;
349        assert_eq!(get_kargs_in_root(&td, "x86_64").unwrap().iter().count(), 0);
350        // Non-toml file
351        td.write("usr/lib/bootc/kargs.d/somegarbage", "garbage")?;
352        assert_eq!(get_kargs_in_root(&td, "x86_64").unwrap().iter().count(), 0);
353
354        write_test_kargs(&td)?;
355
356        let args = get_kargs_in_root(&td, "x86_64").unwrap();
357        assert_cmdline_eq(&args, &["console=tty0", "nosmt", "console=ttyS1"]);
358
359        Ok(())
360    }
361
362    #[context("ostree commit")]
363    fn ostree_commit(
364        repo: &ostree::Repo,
365        d: &Dir,
366        path: &Utf8Path,
367        ostree_ref: &str,
368    ) -> Result<()> {
369        let cancellable = gio::Cancellable::NONE;
370        let txn = repo.auto_transaction(cancellable)?;
371
372        let mt = ostree::MutableTree::new();
373        let commitmod_flags = ostree::RepoCommitModifierFlags::SKIP_XATTRS;
374        let commitmod = ostree::RepoCommitModifier::new(commitmod_flags, None);
375        repo.write_dfd_to_mtree(
376            d.as_fd().as_raw_fd(),
377            path.as_str(),
378            &mt,
379            Some(&commitmod),
380            cancellable,
381        )
382        .context("Writing merged filesystem to mtree")?;
383
384        let merged_root = repo
385            .write_mtree(&mt, cancellable)
386            .context("Writing mtree")?;
387        let merged_root = merged_root.downcast::<ostree::RepoFile>().unwrap();
388        let merged_commit = repo
389            .write_commit(None, None, None, None, &merged_root, cancellable)
390            .context("Writing commit")?;
391        repo.transaction_set_ref(None, &ostree_ref, Some(merged_commit.as_str()));
392        txn.commit(cancellable)?;
393        Ok(())
394    }
395
396    #[test]
397    fn test_get_kargs_in_ostree() -> Result<()> {
398        let cancellable = gio::Cancellable::NONE;
399        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
400
401        td.create_dir("repo")?;
402        let repo = &ostree::Repo::create_at(
403            td.as_fd().as_raw_fd(),
404            "repo",
405            ostree::RepoMode::Bare,
406            None,
407            gio::Cancellable::NONE,
408        )?;
409
410        td.create_dir("rootfs")?;
411        let test_rootfs = &td.open_dir("rootfs")?;
412
413        ostree_commit(repo, &test_rootfs, ".".into(), "testref")?;
414        // Helper closure to read the kargs
415        let get_kargs = |sys_arch: &str| -> Result<CmdlineOwned> {
416            let rootfs = repo.read_commit("testref", cancellable)?.0;
417            let rootfs = rootfs.downcast_ref::<ostree::RepoFile>().unwrap();
418            let fetched_tree = rootfs.resolve_relative_path("/usr/lib/bootc/kargs.d");
419            let fetched_tree = fetched_tree
420                .downcast::<ostree::RepoFile>()
421                .expect("downcast");
422            if !fetched_tree.query_exists(cancellable) {
423                return Ok(Default::default());
424            }
425            get_kargs_from_ostree(repo, &fetched_tree, sys_arch)
426        };
427
428        // rootfs is empty
429        assert_eq!(get_kargs("x86_64").unwrap().iter().count(), 0);
430
431        test_rootfs.create_dir_all("usr/lib/bootc/kargs.d")?;
432        write_test_kargs(&test_rootfs).unwrap();
433        ostree_commit(repo, &test_rootfs, ".".into(), "testref")?;
434
435        let args = get_kargs("x86_64").unwrap();
436        assert_cmdline_eq(&args, &["console=tty0", "nosmt", "console=ttyS1"]);
437
438        Ok(())
439    }
440}