Skip to main content

consortium_cli/sandbox/
vfkit.rs

1//! macOS sandbox backend built on `vfkit` (Virtualization.framework).
2//!
3//! `vfkit` is a command-line VMM, so this shells out rather than linking a C
4//! API. That is deliberate: a process spawn is cheap next to a VM boot, and it
5//! keeps `unsafe` FFI out of a published crate.
6//!
7//! Device syntax is vfkit's own `type,key=value` grammar from its
8//! `doc/usage.md`: `-d virtio-fs,sharedDir=<host>,mountTag=<tag>`, mounted in
9//! the guest as `mount -t virtiofs <tag> <dir>`.
10//!
11//! Like libkrun, `vfkit` has no snapshot/restore, so every invocation pays a
12//! full guest boot. Read-only grants cannot be expressed (a virtio-fs share is
13//! writable), so this backend reports that rather than widening the policy.
14
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use super::{NetworkPolicy, Sandbox, SandboxCommand, SandboxError, SandboxOutput, SandboxSpec};
19
20/// A macOS backend that runs each command in its own vfkit guest.
21#[derive(Debug, Clone)]
22pub struct VfkitSandbox {
23    binary: PathBuf,
24    kernel: PathBuf,
25    initrd: PathBuf,
26    cmdline: String,
27    memory_mib: u32,
28    cpus: u32,
29}
30
31impl VfkitSandbox {
32    /// Build a backend from an explicit `vfkit` binary and a Linux guest.
33    pub fn new(
34        binary: impl Into<PathBuf>,
35        kernel: impl Into<PathBuf>,
36        initrd: impl Into<PathBuf>,
37        cmdline: impl Into<String>,
38    ) -> Self {
39        Self {
40            binary: binary.into(),
41            kernel: kernel.into(),
42            initrd: initrd.into(),
43            cmdline: cmdline.into(),
44            memory_mib: 512,
45            cpus: 1,
46        }
47    }
48
49    /// Set guest memory, in MiB.
50    #[must_use]
51    pub fn with_memory_mib(mut self, mib: u32) -> Self {
52        self.memory_mib = mib;
53        self
54    }
55
56    /// Set guest vCPU count.
57    #[must_use]
58    pub fn with_cpus(mut self, cpus: u32) -> Self {
59        self.cpus = cpus;
60        self
61    }
62
63    /// Render the `vfkit` device string for one share.
64    fn device_arg(host_path: &Path, mount_tag: &str) -> String {
65        format!(
66            "virtio-fs,sharedDir={},mountTag={mount_tag}",
67            host_path.display()
68        )
69    }
70
71    /// Build the full argv, or refuse a policy this backend cannot express.
72    fn build_argv(
73        &self,
74        spec: &SandboxSpec,
75        command: &SandboxCommand,
76    ) -> Result<Vec<String>, SandboxError> {
77        if let Some(path) = spec.read_only_paths.first() {
78            return Err(SandboxError::UnsupportedReadOnlyShare { path: path.clone() });
79        }
80
81        // vfkit has no separate flag for appending to the kernel command line:
82        // the only way to pass a command is inside the bootloader's cmdline=.
83        let payload = std::iter::once(command.program.as_str())
84            .chain(command.args.iter().map(String::as_str))
85            .collect::<Vec<_>>()
86            .join(" ");
87        let cmdline = format!("{} -- {payload}", self.cmdline);
88
89        let mut argv = vec![
90            "--bootloader".to_string(),
91            format!(
92                "linux,kernel={},initrd={},cmdline=\"{}\"",
93                self.kernel.display(),
94                self.initrd.display(),
95                cmdline
96            ),
97            "--memory".to_string(),
98            self.memory_mib.to_string(),
99            "--cpus".to_string(),
100            self.cpus.to_string(),
101        ];
102
103        for (index, path) in spec.writable_paths.iter().enumerate() {
104            argv.push("--device".to_string());
105            argv.push(Self::device_arg(path, &format!("share{index}")));
106        }
107
108        if spec.network == NetworkPolicy::Deny && !spec.writable_paths.is_empty() {
109            return Err(SandboxError::Unsupported("a network-denying policy"));
110        }
111        // vfkit requires exactly one of nat/fd/unixSocketPath; a bare
112        // "virtio-net" is rejected with "one of 'nat' or 'fd' or
113        // 'unixSocketPath' must be set". nat is vfkit's own default.
114        argv.push("--device".to_string());
115        argv.push("virtio-net,nat".to_string());
116
117        Ok(argv)
118    }
119}
120
121impl Sandbox for VfkitSandbox {
122    fn name(&self) -> &'static str {
123        "vfkit"
124    }
125
126    fn is_isolated(&self) -> bool {
127        // A real hypervisor boundary: a separate guest kernel under
128        // Virtualization.framework, unlike DirectSandbox which shares ours.
129        true
130    }
131
132    fn exec(
133        &self,
134        spec: &SandboxSpec,
135        command: &SandboxCommand,
136    ) -> Result<SandboxOutput, SandboxError> {
137        spec.validate()?;
138        let argv = self.build_argv(spec, command)?;
139        let output = Command::new(&self.binary)
140            .args(&argv)
141            .stdin(std::process::Stdio::null())
142            .output()
143            .map_err(SandboxError::Spawn)?;
144        Ok(SandboxOutput {
145            status: output.status.code().unwrap_or(-1),
146            stdout: output.stdout,
147            stderr: output.stderr,
148        })
149    }
150}
151
152/// Find `vfkit` on `PATH`.
153pub fn which() -> Option<PathBuf> {
154    let path = std::env::var_os("PATH")?;
155    std::env::split_paths(&path)
156        .map(|dir| dir.join("vfkit"))
157        .find(|candidate| candidate.is_file())
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn backend() -> VfkitSandbox {
165        VfkitSandbox::new("vfkit", "bzImage", "initrd", "console=ttyS0")
166    }
167
168    #[test]
169    fn device_string_matches_vfkit_grammar() {
170        assert_eq!(
171            VfkitSandbox::device_arg(Path::new("/srv/data"), "share0"),
172            "virtio-fs,sharedDir=/srv/data,mountTag=share0"
173        );
174    }
175
176    #[test]
177    fn reports_real_isolation() {
178        assert!(backend().is_isolated());
179        assert_eq!(backend().name(), "vfkit");
180    }
181
182    #[test]
183    fn read_only_grant_is_refused_not_widened() {
184        let spec = SandboxSpec::deny_all().with_read_only("/usr");
185        let err = backend()
186            .build_argv(&spec, &SandboxCommand::new("/bin/true"))
187            .expect_err("must refuse a read-only grant");
188        assert!(matches!(err, SandboxError::UnsupportedReadOnlyShare { .. }));
189        assert!(err.to_string().contains("read-only"));
190    }
191
192    #[test]
193    fn writable_share_becomes_a_virtio_fs_device() {
194        let spec = SandboxSpec::deny_all()
195            .with_writable("/srv/data")
196            .with_network(NetworkPolicy::Allow);
197        let argv = backend()
198            .build_argv(&spec, &SandboxCommand::new("/bin/true"))
199            .expect("should build");
200        assert!(argv.iter().any(|a| a == "--device"));
201        assert!(argv
202            .iter()
203            .any(|a| a == "virtio-fs,sharedDir=/srv/data,mountTag=share0"));
204    }
205
206    #[test]
207    fn command_payload_lands_in_the_bootloader_cmdline() {
208        // Regression: an invented `--cmdline-append` flag was used here once.
209        // vfkit only accepts `--bootloader linux,...,cmdline="..."`, so the
210        // command must ride in that value or every invocation fails with
211        // "unknown flag".
212        let spec = SandboxSpec::deny_all().with_network(NetworkPolicy::Allow);
213        let cmd = SandboxCommand::new("/bin/echo").arg("hello");
214        let argv = backend().build_argv(&spec, &cmd).expect("should build");
215        let bootloader = argv.get(1).expect("bootloader arg");
216        assert!(bootloader.starts_with("linux,"), "{bootloader}");
217        assert!(
218            bootloader.contains("cmdline=\"console=ttyS0 -- /bin/echo hello\""),
219            "{bootloader}"
220        );
221        // Nothing outside the documented flag set may appear.
222        for arg in &argv {
223            if arg.starts_with("--") {
224                assert!(
225                    matches!(
226                        arg.as_str(),
227                        "--bootloader" | "--memory" | "--cpus" | "--device"
228                    ),
229                    "unexpected flag {arg}"
230                );
231            }
232        }
233    }
234
235    #[test]
236    fn network_denial_is_refused_rather_than_approximated() {
237        let spec = SandboxSpec::deny_all().with_writable("/srv/data");
238        let err = backend()
239            .build_argv(&spec, &SandboxCommand::new("/bin/true"))
240            .expect_err("must refuse");
241        assert!(matches!(err, SandboxError::Unsupported(_)));
242    }
243}