1use std::os::fd::{AsFd, AsRawFd};
16
17use anyhow::{Context, Result, bail, ensure};
18use bootc_initramfs_setup::{
19 Config as SetupRootConfig, MountType, SETUP_ROOT_CONF_PATH, mount_subdir,
20};
21use camino::{Utf8Path, Utf8PathBuf};
22use cap_std_ext::{
23 cap_std::{ambient_authority, fs::Dir},
24 dirext::CapStdExtDirExt,
25};
26use clap::Args;
27use ostree::gio;
28use ostree_ext::keyfileext::KeyFileExt;
29use ostree_ext::{ostree, ostree_prepareroot};
30use rustix::mount::{MoveMountFlags, OpenTreeFlags, move_mount, open_tree};
31
32use crate::composefs_consts::STATE_DIR_RELATIVE;
33
34const ETC: &str = "etc";
35const VAR: &str = "var";
36
37#[derive(Debug, Args, PartialEq, Eq)]
38pub(crate) struct MountOpts {
39 #[clap(long, value_parser = crate::cli::parse_absolute_path)]
41 pub(crate) sysroot: Utf8PathBuf,
42
43 #[clap(long, required = true)]
48 pub(crate) latest: bool,
49
50 #[clap(long)]
52 pub(crate) read_only: bool,
53
54 #[clap(value_parser = crate::cli::parse_absolute_path)]
56 pub(crate) target: Utf8PathBuf,
57}
58
59fn open_mount_target(target: &Utf8Path) -> Result<Dir> {
62 let target_dir = Dir::open_ambient_dir(target, ambient_authority())
63 .with_context(|| format!("Opening mount target {target}"))?;
64 let mut entries = target_dir
65 .entries()
66 .with_context(|| format!("Reading mount target {target}"))?;
67 if entries.next().is_some() {
68 eprintln!("warning: mount target {target} is not empty; its contents will be hidden");
69 }
70 Ok(target_dir)
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum DeploymentBackend {
75 Ostree,
76 Composefs,
77}
78
79fn select_backend(
80 ostree_deployments: usize,
81 composefs_deployments: usize,
82) -> Result<DeploymentBackend> {
83 match (ostree_deployments, composefs_deployments) {
84 (1, 0) => Ok(DeploymentBackend::Ostree),
85 (0, 1) => Ok(DeploymentBackend::Composefs),
86 (0, 0) => bail!("target contains no deployment"),
87 (o, c) => bail!(
88 "target must contain exactly one deployment (found {o} OSTree, {c} composefs); refusing ambiguous selection"
89 ),
90 }
91}
92
93pub(crate) async fn mount(opts: MountOpts) -> Result<()> {
94 ensure!(opts.latest, "no deployment selected; pass --latest");
96 let target = &opts.target;
97 let target_dir = open_mount_target(target)?;
98 let sysroot_dir = Dir::open_ambient_dir(&opts.sysroot, ambient_authority())
99 .with_context(|| format!("Opening target sysroot {}", opts.sysroot))?;
100
101 let ostree_repo = sysroot_dir.open_dir_optional("ostree/repo")?;
105 let ostree_sysroot = if ostree_repo.is_some() {
106 let path = format!("/proc/self/fd/{}", sysroot_dir.as_raw_fd());
108 let sysroot = ostree::Sysroot::new(Some(&gio::File::for_path(path)));
109 sysroot
110 .load(gio::Cancellable::NONE)
111 .context("Loading target OSTree sysroot")?;
112 Some(ostree_ext::sysroot::SysrootLock::new_from_sysroot(&sysroot).await?)
113 } else {
114 None
115 };
116 let ostree_deployments = ostree_sysroot
117 .as_ref()
118 .map(|s| s.deployments())
119 .unwrap_or_default();
120 let composefs_deployments = sysroot_dir
123 .open_dir_optional(STATE_DIR_RELATIVE)?
124 .map(|state| crate::bootc_composefs::gc::list_deployment_state_dirs(&state))
125 .transpose()?
126 .unwrap_or_default();
127
128 let (root_tree, state) =
129 match select_backend(ostree_deployments.len(), composefs_deployments.len())? {
130 DeploymentBackend::Composefs => {
131 let id = &composefs_deployments[0];
132 let state = sysroot_dir
133 .open_dir(format!("{STATE_DIR_RELATIVE}/{id}"))
134 .with_context(|| format!("Opening composefs deployment state {id}"))?;
135 let repo = crate::bootc_composefs::repo::open_composefs_repo(&sysroot_dir)?;
136 let image = repo.mount(id).context("Mounting composefs image")?;
137 (image, DeploymentState::Composefs(state))
138 }
139 DeploymentBackend::Ostree => {
140 let sysroot = ostree_sysroot.as_deref().expect("OSTree backend selected");
141 let repo = ostree_repo.as_ref().expect("OSTree backend selected");
142 let deployment = &ostree_deployments[0];
143 let source = sysroot.deployment_dirpath(deployment);
144 let deployment_dir = sysroot_dir
145 .open_dir(source.as_str())
146 .with_context(|| format!("Opening OSTree deployment {source}"))?;
147 let var = sysroot_dir
148 .open_dir(format!("ostree/deploy/{}/{VAR}", deployment.stateroot()))
149 .context("Opening OSTree stateroot /var")?;
150 let config = ostree_prepareroot::load_config_from_root(&deployment_dir)
151 .context("Loading the deployment's prepare-root.conf")?;
152 let etc_transient = config
153 .as_ref()
154 .map(|config| config.optional_bool("etc", "transient"))
155 .transpose()
156 .context("Parsing etc.transient")?
157 .flatten()
158 .unwrap_or_default();
159 let composefs = ostree_prepareroot::mount_composefs(
160 &deployment_dir,
161 repo,
162 deployment.csum().as_str(),
163 config.as_ref(),
164 )?;
165 let root_tree = match composefs {
166 Some(root_tree) => root_tree,
167 None => open_tree(
169 &sysroot_dir,
170 source.as_str(),
171 OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
172 )
173 .context("Cloning OSTree deployment tree")?,
174 };
175 (
176 root_tree,
177 DeploymentState::Ostree {
178 deployment: deployment_dir,
179 var,
180 etc_transient,
181 },
182 )
183 }
184 };
185
186 bootc_initramfs_setup::set_mount_readonly(&root_tree)
187 .context("Making detached deployment root read-only")?;
188 move_mount(
189 &root_tree,
190 "",
191 &target_dir,
192 ".",
193 MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
194 )
195 .context("Attaching deployment root")?;
196 let assembly = (|| -> Result<()> {
197 let target_root = Dir::open_ambient_dir(target, ambient_authority())
199 .context("Opening mounted deployment root")?;
200 match &state {
201 DeploymentState::Composefs(state) => mount_composefs_state(&target_root, state)?,
202 DeploymentState::Ostree {
203 deployment,
204 var,
205 etc_transient,
206 } => mount_ostree_state(&target_root, deployment, var, *etc_transient)?,
207 }
208 if opts.read_only {
209 bootc_initramfs_setup::set_mount_tree_readonly(&target_root)
210 .context("Making /etc and /var read-only")?;
211 }
212 Ok(())
213 })();
214 if let Err(error) = assembly {
215 return Err(match bootc_mount::unmount_recursive(target) {
216 Ok(()) => error,
217 Err(cleanup_error) => {
218 error.context(format!("cleanup of {target} also failed: {cleanup_error}"))
219 }
220 })
221 .context("Assembling offline deployment mount");
222 }
223 Ok(())
224}
225
226enum DeploymentState {
228 Composefs(Dir),
230 Ostree {
233 deployment: Dir,
234 var: Dir,
235 etc_transient: bool,
236 },
237}
238
239fn mount_composefs_state(root: &Dir, state: &Dir) -> Result<()> {
242 let config_path = SETUP_ROOT_CONF_PATH.trim_start_matches('/');
243 let config: SetupRootConfig = root
244 .read_to_string_optional(config_path)
245 .with_context(|| format!("Reading {SETUP_ROOT_CONF_PATH}"))?
246 .map(|text| toml::from_str(&text))
247 .transpose()
248 .with_context(|| format!("Parsing {SETUP_ROOT_CONF_PATH}"))?
249 .unwrap_or_default();
250 mount_subdir(root, state, ETC, config.etc, MountType::Bind)?;
251 mount_subdir(root, state, VAR, config.var, MountType::Bind)?;
252 Ok(())
253}
254
255fn mount_ostree_state(root: &Dir, deployment: &Dir, var: &Dir, etc_transient: bool) -> Result<()> {
259 if etc_transient {
260 let usr_etc = root.open_dir("usr/etc").context("Opening /usr/etc")?;
261 let overlay = bootc_initramfs_setup::overlay_transient(&usr_etc, "transient", None)?;
262 attach(&overlay, root, ETC)?;
263 } else {
264 let etc = deployment
265 .open_dir(ETC)
266 .context("Opening OSTree deployment /etc")?;
267 bind(&etc, root, ETC)?;
268 }
269 bind(var, root, VAR)
270}
271
272fn bind(source: &Dir, target: &Dir, name: &str) -> Result<()> {
273 let tree = open_tree(
274 source,
275 ".",
276 OpenTreeFlags::OPEN_TREE_CLONE | OpenTreeFlags::OPEN_TREE_CLOEXEC,
277 )
278 .with_context(|| format!("Cloning /{name}"))?;
279 attach(&tree, target, name)
280}
281
282fn attach(tree: impl AsFd, target: &Dir, name: &str) -> Result<()> {
283 move_mount(
284 tree,
285 "",
286 target,
287 name,
288 MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH,
289 )
290 .with_context(|| format!("Attaching /{name}"))
291}
292
293#[cfg(test)]
294mod tests {
295 use super::{DeploymentBackend, open_mount_target, select_backend};
296 use crate::cli::{InstallOpts, Opt};
297 use camino::Utf8Path;
298 use clap::Parser;
299
300 #[test]
301 fn requires_deployment_selector() {
302 let cases: &[(&[&str], bool)] = &[
303 (&["--sysroot=/sysroot", "/mnt"], false),
304 (&["--sysroot=/sysroot", "--latest", "/mnt"], true),
305 (
306 &["--sysroot=/sysroot", "--latest", "--read-only", "/mnt"],
307 true,
308 ),
309 ];
310 for (args, ok) in cases {
311 let argv = ["bootc", "install", "mount"].iter().chain(args.iter());
312 match Opt::try_parse_from(argv) {
313 Ok(Opt::Install(InstallOpts::Mount(opts))) => {
314 assert!(ok, "{args:?} parsed without a selector");
315 assert!(opts.latest);
316 }
317 Ok(o) => panic!("{args:?}: expected install mount, got {o:?}"),
318 Err(e) => {
319 assert!(!ok, "{args:?}: {e}");
320 assert_eq!(e.kind(), clap::error::ErrorKind::MissingRequiredArgument);
321 assert!(e.to_string().contains("--latest"), "{e}");
322 }
323 }
324 }
325 }
326
327 #[test]
328 fn selects_only_unambiguous_backend() {
329 let cases = [
330 (0, 0, None),
331 (1, 0, Some(DeploymentBackend::Ostree)),
332 (0, 1, Some(DeploymentBackend::Composefs)),
333 (1, 1, None),
334 (2, 0, None),
335 (0, 2, None),
336 ];
337 for (ostree, composefs, expected) in cases {
338 assert_eq!(select_backend(ostree, composefs).ok(), expected);
339 }
340 }
341
342 #[test]
343 fn accepts_any_target_directory() {
344 let temp = tempfile::tempdir().unwrap();
345 let temp = Utf8Path::from_path(temp.path()).unwrap();
346 let target = temp.join("target");
347 std::fs::create_dir(&target).unwrap();
348 open_mount_target(&target).unwrap();
349 std::fs::create_dir(target.join("nested")).unwrap();
351 open_mount_target(&target).unwrap();
352 assert!(open_mount_target(&temp.join("missing")).is_err());
353 }
354}