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