1use 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
46pub struct MshvImage<'a> {
49 pub shim: &'a Path,
51 pub grub: &'a Path,
54 pub payload: &'a Path,
57 pub kernel: &'a Path,
59 pub initrd: &'a Path,
61 pub kernel_cmdline: &'a str,
63}
64
65impl MshvImage<'_> {
66 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 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 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 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}