Skip to main content

composefs_ctl/
mountcomposefs.rs

1//! mount.composefs - Mount helper for composefs images.
2//!
3//! This is a Rust reimplementation of the C mount.composefs tool, providing
4//! a compatible command-line interface. When installed as `/usr/sbin/mount.composefs`,
5//! the kernel dispatches to it for `mount -t composefs` commands.
6
7use std::ffi::OsString;
8use std::os::fd::{AsFd, OwnedFd};
9
10use anyhow::{Context, Result, bail};
11use clap::Parser;
12use rustix::fs::{CWD, Mode, OFlags};
13
14use composefs::fsverity::{FsVerityHashValue, MeasureVerityError, Sha256HashValue, measure_verity};
15use composefs::mount::{MountOptions, VerityRequirement, composefs_fsmount, mount_at};
16
17/// Mount helper for composefs images.
18///
19/// Supported -o options: basedir=PATH[:PATH], digest=DIGEST, idmap=PATH,
20/// verity, tryverity, ro, rw, upperdir=PATH, workdir=PATH
21#[derive(Parser, Debug)]
22#[command(name = "mount.composefs")]
23struct MountArgs {
24    /// Filesystem type (must be "composefs")
25    #[arg(short = 't', value_name = "TYPE")]
26    fstype: Option<String>,
27
28    /// Mount options (comma-separated key[=value] pairs)
29    #[arg(short = 'o', value_name = "OPTIONS")]
30    options: Option<String>,
31
32    /// Path to the composefs image
33    image: String,
34
35    /// Mount point
36    mountpoint: String,
37}
38
39fn unescape_option(s: &str) -> String {
40    let mut result = String::with_capacity(s.len());
41    let mut chars = s.chars();
42    while let Some(c) = chars.next() {
43        if c == '\\' {
44            if let Some(next) = chars.next() {
45                result.push(next);
46            }
47        } else {
48            result.push(c);
49        }
50    }
51    result
52}
53
54struct ParsedOption {
55    key: String,
56    value: Option<String>,
57}
58
59fn parse_mount_options(options: &str) -> Vec<ParsedOption> {
60    let mut result = Vec::new();
61    let mut rest = options;
62
63    while !rest.is_empty() {
64        let mut equal_pos = None;
65        let mut end_pos = rest.len();
66        let bytes = rest.as_bytes();
67        let mut i = 0;
68
69        while i < bytes.len() {
70            if bytes[i] == b'=' && equal_pos.is_none() {
71                equal_pos = Some(i);
72            } else if bytes[i] == b'\\' && i + 1 < bytes.len() {
73                i += 1;
74            } else if bytes[i] == b',' {
75                end_pos = i;
76                break;
77            }
78            i += 1;
79        }
80
81        let entry = &rest[..end_pos];
82        rest = if end_pos < rest.len() {
83            &rest[end_pos + 1..]
84        } else {
85            ""
86        };
87
88        let (key, value) = if let Some(eq) = equal_pos {
89            if eq < end_pos {
90                (&entry[..eq], Some(unescape_option(&entry[eq + 1..])))
91            } else {
92                (entry, None)
93            }
94        } else {
95            (entry, None)
96        };
97
98        result.push(ParsedOption {
99            key: key.to_string(),
100            value,
101        });
102    }
103
104    result
105}
106
107fn run_mount(args: impl IntoIterator<Item = OsString>) -> Result<()> {
108    let cli =
109        MountArgs::try_parse_from(std::iter::once(OsString::from("mount.composefs")).chain(args))?;
110
111    if let Some(ref fstype) = cli.fstype
112        && fstype != "composefs"
113    {
114        bail!("Unsupported fs type '{fstype}'");
115    }
116
117    let mut opt_basedir: Option<String> = None;
118    let mut opt_digest: Option<String> = None;
119    let mut opt_upperdir: Option<String> = None;
120    let mut opt_workdir: Option<String> = None;
121    let mut opt_idmap: Option<OwnedFd> = None;
122    let mut opt_verity = false;
123    let mut opt_tryverity = false;
124    let mut opt_ro = false;
125
126    if let Some(ref opts_str) = cli.options {
127        for opt in parse_mount_options(opts_str) {
128            match opt.key.as_str() {
129                "basedir" => {
130                    opt_basedir = Some(opt.value.context("No value specified for basedir option")?);
131                }
132                "digest" => {
133                    opt_digest = Some(opt.value.context("No value specified for digest option")?);
134                }
135                "verity" => opt_verity = true,
136                "tryverity" => opt_tryverity = true,
137                "upperdir" => {
138                    opt_upperdir = Some(
139                        opt.value
140                            .context("No value specified for upperdir option")?,
141                    );
142                }
143                "workdir" => {
144                    opt_workdir = Some(opt.value.context("No value specified for workdir option")?);
145                }
146                "idmap" => {
147                    let idmap_path = opt.value.context("No value specified for idmap option")?;
148                    let idmap_fd = rustix::fs::open(
149                        idmap_path.as_str(),
150                        OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOCTTY,
151                        Mode::empty(),
152                    )
153                    .with_context(|| format!("Failed to open idmap {idmap_path}"))?;
154                    opt_idmap = Some(idmap_fd);
155                }
156                "rw" => opt_ro = false,
157                "ro" => opt_ro = true,
158                other => bail!("Unsupported option: {other}"),
159            }
160        }
161    }
162
163    let basedir_str = match opt_basedir {
164        Some(ref s) => s.as_str(),
165        None => {
166            bail!("No object dirs specified");
167        }
168    };
169
170    let mut basedir_fds: Vec<OwnedFd> = Vec::new();
171    for dir in basedir_str.split(':') {
172        if dir.is_empty() {
173            continue;
174        }
175        let fd = rustix::fs::open(
176            dir,
177            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
178            Mode::empty(),
179        )
180        .with_context(|| format!("Failed to open basedir {dir}"))?;
181        basedir_fds.push(fd);
182    }
183    if basedir_fds.is_empty() {
184        bail!("No object dirs specified");
185    }
186
187    match (&opt_upperdir, &opt_workdir) {
188        (Some(_), None) | (None, Some(_)) => {
189            bail!("Both workdir and upperdir must be specified if used");
190        }
191        _ => {}
192    }
193
194    let verity = if opt_verity || opt_digest.is_some() {
195        VerityRequirement::Required
196    } else if opt_tryverity {
197        VerityRequirement::Try
198    } else {
199        VerityRequirement::Disabled
200    };
201
202    let image_fd = rustix::fs::open(
203        cli.image.as_str(),
204        OFlags::RDONLY | OFlags::CLOEXEC,
205        Mode::empty(),
206    )
207    .with_context(|| format!("Failed to open {}", cli.image))?;
208
209    if let Some(ref digest_hex) = opt_digest {
210        let expected = Sha256HashValue::from_hex(digest_hex).context("Invalid digest value")?;
211        match measure_verity::<Sha256HashValue>(&image_fd) {
212            Ok(measured) => {
213                if measured != expected {
214                    bail!(
215                        "Failed to mount composefs {}: Image has wrong fs-verity",
216                        cli.image
217                    );
218                }
219            }
220            Err(MeasureVerityError::VerityMissing) => {
221                bail!(
222                    "Failed to mount composefs {}: Image has no fs-verity",
223                    cli.image
224                );
225            }
226            Err(e) => {
227                bail!("Failed to mount composefs {}: {e}", cli.image);
228            }
229        }
230    }
231
232    let mut mount_opts = MountOptions::default();
233    if let (Some(upper), Some(work)) = (&opt_upperdir, &opt_workdir) {
234        let upper_fd = rustix::fs::open(
235            upper.as_str(),
236            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
237            Mode::empty(),
238        )
239        .with_context(|| format!("Failed to open upperdir {upper}"))?;
240        let work_fd = rustix::fs::open(
241            work.as_str(),
242            OFlags::PATH | OFlags::DIRECTORY | OFlags::CLOEXEC,
243            Mode::empty(),
244        )
245        .with_context(|| format!("Failed to open workdir {work}"))?;
246        mount_opts.set_overlay(upper_fd, work_fd);
247    }
248    if let Some(idmap_fd) = opt_idmap {
249        mount_opts.set_idmap(idmap_fd);
250    }
251    mount_opts.set_read_write(!opt_ro);
252
253    let borrowed: Vec<_> = basedir_fds.iter().map(|fd| fd.as_fd()).collect();
254    let fs_fd = composefs_fsmount(image_fd, "composefs", &borrowed, verity, &mount_opts)
255        .with_context(|| format!("Failed to mount composefs {}", cli.image))?;
256
257    mount_at(&fs_fd, CWD, cli.mountpoint.as_str())
258        .with_context(|| format!("Failed to mount at {}", cli.mountpoint))?;
259
260    Ok(())
261}
262
263/// Entry point when invoked as `mount.composefs` via argv[0] symlink.
264pub fn run() -> Result<()> {
265    let args: Vec<OsString> = std::env::args_os().skip(1).collect();
266    run_mount(args)
267}
268
269/// Entry point when invoked as `cfsctl mount.composefs ...` subcommand.
270pub fn run_from_args(args: Vec<OsString>) -> Result<()> {
271    run_mount(args)
272}