Skip to main content

petri/
mshv_image.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Compose an experimental x86-64 mshv boot disk from prebuilt artifacts.
5//!
6//! The caller supplies compatible shim, GRUB, and hypervisor payloads. This
7//! module neither downloads artifacts nor invokes host image-building tools.
8
9use anyhow::Context;
10use guid::Guid;
11use std::collections::BTreeMap;
12use std::io::Seek;
13use std::io::Write;
14use std::path::Path;
15use std::path::PathBuf;
16
17const SECTOR_SIZE: u64 = 512;
18const MIB: u64 = 1024 * 1024;
19const ESP_GUID: Guid = guid::guid!("C12A7328-F81F-11D2-BA4B-00A0C93EC93B");
20
21#[derive(Debug)]
22struct ImageTimeProvider;
23
24impl fatfs::TimeProvider for ImageTimeProvider {
25    fn get_current_date(&self) -> fatfs::Date {
26        fatfs::Date {
27            year: 1980,
28            month: 1,
29            day: 1,
30        }
31    }
32
33    fn get_current_date_time(&self) -> fatfs::DateTime {
34        fatfs::DateTime {
35            date: self.get_current_date(),
36            time: fatfs::Time {
37                hour: 0,
38                min: 0,
39                sec: 0,
40                millis: 0,
41            },
42        }
43    }
44}
45
46/// Inputs for a disposable boot disk. No installed Linux root filesystem is
47/// required; the initramfs must supply `/init`.
48pub struct MshvImage<'a> {
49    /// x64 shim, configured to load `grubx64.efi` beside itself.
50    pub shim: &'a Path,
51    /// x64 GRUB with embedded FAT/GPT, chain, Linux, and normal-mode modules,
52    /// and an embedded prefix of `/boot/grub2`.
53    pub grub: &'a Path,
54    /// Boot payload root containing `HvLoader.efi`, `lxhvloader.dll`, and
55    /// `Windows/System32`. All files under `Windows` are preserved.
56    pub payload: &'a Path,
57    /// mshv-enabled Linux kernel, booted through its EFI stub.
58    pub kernel: &'a Path,
59    /// Initramfs containing the runtime.
60    pub initrd: &'a Path,
61    /// Literal Linux command line.
62    pub kernel_cmdline: &'a str,
63}
64
65impl MshvImage<'_> {
66    /// Build a raw GPT disk with one FAT32 EFI System Partition.
67    ///
68    /// Publication is no-clobber and occurs only after successful construction.
69    /// Inputs are streamed into a temporary file beside the destination.
70    pub fn build(&self, output: &Path) -> anyhow::Result<()> {
71        let mut files = BTreeMap::new();
72        for (destination, source) in [
73            ("EFI/BOOT/BOOTX64.EFI", self.shim.to_path_buf()),
74            ("EFI/BOOT/grubx64.efi", self.grub.to_path_buf()),
75            ("HvLoader.efi", self.payload.join("HvLoader.efi")),
76            ("lxhvloader.dll", self.payload.join("lxhvloader.dll")),
77            ("bzImage", self.kernel.to_path_buf()),
78            ("initramfs.cpio", self.initrd.to_path_buf()),
79        ] {
80            insert_file(&mut files, destination.to_owned(), source)?;
81        }
82        collect_files(&mut files, self.payload, Path::new("Windows"))?;
83        for required in ["windows/system32/hvix64.exe", "windows/system32/hvax64.exe"] {
84            anyhow::ensure!(
85                files.keys().any(|path| path.eq_ignore_ascii_case(required)),
86                "missing hypervisor payload file {required}"
87            );
88        }
89        let config = grub_config(self.kernel_cmdline)?;
90        let payload_size = files.values().try_fold(config.len() as u64, |size, path| {
91            size.checked_add(fs_err::metadata(path)?.len())
92                .context("image size overflow")
93        })?;
94        // Leave room for FATs, directories, cluster rounding, and GPT headers.
95        // Keep small payloads above FAT32's minimum cluster-count threshold.
96        let size = payload_size
97            .checked_add(payload_size / 8)
98            .and_then(|size| size.checked_add(64 * MIB))
99            .context("image size overflow")?;
100        let sectors = size.div_ceil(SECTOR_SIZE);
101        anyhow::ensure!(
102            sectors <= u32::MAX as u64,
103            "image exceeds the FAT32 sector-count limit"
104        );
105        let parent = output
106            .parent()
107            .filter(|path| !path.as_os_str().is_empty())
108            .unwrap_or(Path::new("."));
109        let mut image = tempfile::NamedTempFile::new_in(parent)?;
110        image.as_file().set_len(sectors * SECTOR_SIZE)?;
111
112        let mut gpt = gptman::GPT::new_from(&mut image, SECTOR_SIZE, Guid::new_random().into())?;
113        gptman::GPT::write_protective_mbr_into(&mut image, SECTOR_SIZE)?;
114        gpt[1] = gptman::GPTPartitionEntry {
115            partition_type_guid: ESP_GUID.into(),
116            unique_partition_guid: Guid::new_random().into(),
117            starting_lba: 2048,
118            ending_lba: gpt.header.last_usable_lba,
119            attribute_bits: 0,
120            partition_name: "MSHV".into(),
121        };
122        gpt.write_into(&mut image)?;
123        let mut partition = fscommon::StreamSlice::new(
124            &mut image,
125            gpt[1].starting_lba * SECTOR_SIZE,
126            (gpt[1].ending_lba + 1) * SECTOR_SIZE,
127        )?;
128        fatfs::format_volume(
129            &mut partition,
130            fatfs::FormatVolumeOptions::new()
131                .fat_type(fatfs::FatType::Fat32)
132                .volume_label(*b"MSHV_BOOT  "),
133        )?;
134        partition.rewind()?;
135        // Without chrono, fatfs defaults to invalid month/day zero. Use a valid
136        // fixed FAT epoch for all files and directories instead of host time.
137        let fs = fatfs::FileSystem::new(
138            partition,
139            fatfs::FsOptions::new().time_provider(&ImageTimeProvider),
140        )?;
141        {
142            let root = fs.root_dir();
143            for (destination, source) in &files {
144                let mut dest = create_file(&root, destination)?;
145                std::io::copy(&mut fs_err::File::open(source)?, &mut dest)
146                    .with_context(|| format!("copying {} to {destination}", source.display()))?;
147                dest.flush()?;
148            }
149            let mut dest = create_file(&root, "boot/grub2/grub.cfg")?;
150            dest.write_all(config.as_bytes())?;
151            dest.flush()?;
152        }
153        fs.unmount()?;
154        image.as_file().sync_all()?;
155        image
156            .persist_noclobber(output)
157            .with_context(|| format!("publishing {}", output.display()))?;
158        Ok(())
159    }
160}
161
162fn insert_file(
163    files: &mut BTreeMap<String, PathBuf>,
164    destination: String,
165    source: PathBuf,
166) -> anyhow::Result<()> {
167    let metadata = fs_err::symlink_metadata(&source)?;
168    anyhow::ensure!(
169        metadata.file_type().is_file(),
170        "expected a regular file, not a symlink or special file: {}",
171        source.display()
172    );
173    anyhow::ensure!(
174        metadata.len() <= u32::MAX as u64,
175        "file exceeds the FAT32 file-size limit: {}",
176        source.display()
177    );
178    anyhow::ensure!(
179        !files
180            .keys()
181            .any(|path| path.eq_ignore_ascii_case(&destination)),
182        "duplicate FAT path: {destination}"
183    );
184    files.insert(destination, source);
185    Ok(())
186}
187
188fn collect_files(
189    files: &mut BTreeMap<String, PathBuf>,
190    root: &Path,
191    relative: &Path,
192) -> anyhow::Result<()> {
193    let path = root.join(relative);
194    let metadata = fs_err::symlink_metadata(&path)?;
195    anyhow::ensure!(
196        metadata.is_dir(),
197        "expected payload directory: {}",
198        path.display()
199    );
200    for entry in fs_err::read_dir(&path)? {
201        let entry = entry?;
202        let name = entry.file_name();
203        let name = name.to_str().context("non-UTF-8 payload filename")?;
204        anyhow::ensure!(
205            !name.is_empty()
206                && name.is_ascii()
207                && !name
208                    .chars()
209                    .any(|c| c.is_control() || "\"*/:<>?\\|".contains(c))
210                && !name.ends_with([' ', '.']),
211            "unsupported FAT filename: {name:?}"
212        );
213        let relative = relative.join(name);
214        let file_type = entry.file_type()?;
215        if file_type.is_dir() {
216            collect_files(files, root, &relative)?;
217        } else {
218            let destination = relative
219                .components()
220                .map(|component| component.as_os_str().to_str().context("non-UTF-8 path"))
221                .collect::<anyhow::Result<Vec<_>>>()?
222                .join("/");
223            insert_file(files, destination, entry.path())?;
224        }
225    }
226    Ok(())
227}
228
229fn create_file<'a, T: fatfs::ReadWriteSeek>(
230    root: &fatfs::Dir<'a, T>,
231    path: &str,
232) -> anyhow::Result<fatfs::File<'a, T>> {
233    if let Some((parents, _)) = path.rsplit_once('/') {
234        let mut directory = root.clone();
235        for component in parents.split('/') {
236            directory = directory
237                .create_dir(component)
238                .with_context(|| format!("creating directory for {path}"))?;
239        }
240    }
241    root.create_file(path)
242        .with_context(|| format!("creating {path}"))
243}
244
245fn grub_config(cmdline: &str) -> anyhow::Result<String> {
246    anyhow::ensure!(
247        !cmdline
248            .chars()
249            .any(|c| c.is_control() || matches!(c, '\'' | '"')),
250        "kernel command line must not contain control characters or quotes"
251    );
252    // GRUB re-quotes arguments containing spaces when building the Linux
253    // command line. Pass each option separately, not the entire line as one.
254    let cmdline = cmdline
255        .split(' ')
256        .filter(|arg| !arg.is_empty())
257        .map(|arg| format!("'{arg}'"))
258        .collect::<Vec<_>>()
259        .join(" ");
260    Ok(format!(
261        "set timeout=0\n\
262         set default=0\n\
263         menuentry 'mshv' {{\n\
264         search --no-floppy --set=root --file /HvLoader.efi\n\
265         if chainloader /HvLoader.efi lxhvloader.dll 'MSHV_ROOT=\\Windows' MSHV_ENABLE=TRUE MSHV_SCHEDULER_TYPE=ROOT MSHV_X2APIC_POLICY=ENABLE MSHV_FORCE_NESTED=TRUE; then\n\
266           if boot; then\n\
267             if linux /bzImage {cmdline}; then\n\
268               if initrd /initramfs.cpio; then\n\
269                 boot\n\
270               fi\n\
271             fi\n\
272           fi\n\
273         fi\n\
274         echo 'mshv boot failed'\n\
275         halt\n\
276         }}\n"
277    ))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::io::Read;
284    use test_with_tracing::test;
285
286    #[test]
287    fn image_contents_and_no_clobber() -> anyhow::Result<()> {
288        let dir = tempfile::tempdir()?;
289        let payload = dir.path().join("payload");
290        fs_err::create_dir_all(payload.join("Windows/System32"))?;
291        for name in [
292            "HvLoader.efi",
293            "lxhvloader.dll",
294            "Windows/System32/hvix64.exe",
295            "Windows/System32/hvax64.exe",
296            "Windows/System32/kdstub.dll",
297        ] {
298            fs_err::write(payload.join(name), name)?;
299        }
300        let artifact = dir.path().join("artifact");
301        fs_err::write(&artifact, b"artifact")?;
302        let inputs = MshvImage {
303            shim: &artifact,
304            grub: &artifact,
305            payload: &payload,
306            kernel: &artifact,
307            initrd: &artifact,
308            kernel_cmdline: "rdinit=/init console=ttyS0",
309        };
310        let output = dir.path().join("boot.img");
311        inputs.build(&output)?;
312        let mut image = fs_err::File::open(&output)?;
313        let gpt = gptman::GPT::read_from(&mut image, SECTOR_SIZE)?;
314        assert_eq!(gpt[1].partition_type_guid, <[u8; 16]>::from(ESP_GUID));
315        assert_eq!(gpt[1].starting_lba, 2048);
316        let partition = fscommon::StreamSlice::new(
317            image,
318            gpt[1].starting_lba * SECTOR_SIZE,
319            (gpt[1].ending_lba + 1) * SECTOR_SIZE,
320        )?;
321        let fs = fatfs::FileSystem::new(partition, fatfs::FsOptions::new())?;
322        assert_eq!(fs.fat_type(), fatfs::FatType::Fat32);
323        assert_valid_timestamps(&fs.root_dir())?;
324        for path in [
325            "EFI/BOOT/BOOTX64.EFI",
326            "EFI/BOOT/grubx64.efi",
327            "bzImage",
328            "initramfs.cpio",
329        ] {
330            let mut bytes = Vec::new();
331            fs.root_dir().open_file(path)?.read_to_end(&mut bytes)?;
332            assert_eq!(bytes, b"artifact");
333        }
334        let mut config = String::new();
335        fs.root_dir()
336            .open_file("boot/grub2/grub.cfg")?
337            .read_to_string(&mut config)?;
338        assert_eq!(config, grub_config(inputs.kernel_cmdline)?);
339        let mut extra = String::new();
340        fs.root_dir()
341            .open_file("Windows/System32/kdstub.dll")?
342            .read_to_string(&mut extra)?;
343        assert_eq!(extra, "Windows/System32/kdstub.dll");
344        fs.unmount()?;
345        fs_err::write(&output, b"do not overwrite")?;
346        assert!(inputs.build(&output).is_err());
347        assert_eq!(fs_err::read(&output)?, b"do not overwrite");
348        fs_err::remove_file(payload.join("Windows/System32/hvix64.exe"))?;
349        assert!(inputs.build(&dir.path().join("missing.img")).is_err());
350        assert!(!dir.path().join("missing.img").exists());
351        Ok(())
352    }
353
354    fn assert_valid_timestamps<T: fatfs::ReadWriteSeek>(
355        directory: &fatfs::Dir<'_, T>,
356    ) -> anyhow::Result<()> {
357        use fatfs::TimeProvider;
358
359        for entry in directory.iter() {
360            let entry = entry?;
361            let expected = ImageTimeProvider.get_current_date_time();
362            assert_eq!(entry.created(), expected, "{}", entry.file_name());
363            assert_eq!(entry.modified(), expected, "{}", entry.file_name());
364            assert_eq!(entry.accessed(), expected.date, "{}", entry.file_name());
365            if entry.is_dir() && !matches!(entry.file_name().as_str(), "." | "..") {
366                assert_valid_timestamps(&entry.to_dir())?;
367            }
368        }
369        Ok(())
370    }
371
372    #[test]
373    fn command_line_is_literal() -> anyhow::Result<()> {
374        let config = grub_config("rdinit=/init x=$root;halt")?;
375        assert!(config.contains("linux /bzImage 'rdinit=/init' 'x=$root;halt'"));
376        for invalid in ["x\nhalt", "x\rhalt", "x\0halt", "x'", "x=\"a b\""] {
377            assert!(grub_config(invalid).is_err());
378        }
379        Ok(())
380    }
381
382    #[test]
383    fn rejects_case_collisions_and_oversized_files() -> anyhow::Result<()> {
384        let dir = tempfile::tempdir()?;
385        let source = dir.path().join("source");
386        let file = fs_err::File::create(&source)?;
387        let mut files = BTreeMap::new();
388        insert_file(&mut files, "Windows/a.dll".into(), source.clone())?;
389        assert!(insert_file(&mut files, "windows/A.DLL".into(), source.clone()).is_err());
390        file.set_len(u32::MAX as u64 + 1)?;
391        assert!(insert_file(&mut files, "Windows/b.dll".into(), source).is_err());
392        Ok(())
393    }
394
395    #[cfg(unix)]
396    #[test]
397    fn rejects_payload_symlinks() -> anyhow::Result<()> {
398        let dir = tempfile::tempdir()?;
399        let windows = dir.path().join("Windows");
400        fs_err::create_dir(&windows)?;
401        std::os::unix::fs::symlink(dir.path(), windows.join("loop"))?;
402        assert!(collect_files(&mut BTreeMap::new(), dir.path(), Path::new("Windows")).is_err());
403        Ok(())
404    }
405}