1use crate::common::CommonArch;
7use flowey::node::prelude::*;
8
9flowey_config! {
10 pub struct Config {
11 pub local_path: Option<ConfigVar<PathBuf>>,
13 }
14}
15
16flowey_request! {
17 pub struct Request {
18 pub arch: CommonArch,
19 pub artifacts: WriteVar<PathBuf>,
20 }
21}
22
23new_flow_node_with_config!(struct Node);
24
25impl FlowNodeWithConfig for Node {
26 type Request = Request;
27 type Config = Config;
28
29 fn imports(ctx: &mut ImportCtx<'_>) {
30 ctx.import::<flowey_lib_common::install_dist_pkg::Node>();
31 }
32
33 fn emit(
34 config: Config,
35 requests: Vec<Self::Request>,
36 ctx: &mut NodeCtx<'_>,
37 ) -> anyhow::Result<()> {
38 let Config { local_path } = config;
39 let mut outputs = Vec::new();
40 for Request { arch, artifacts } in requests {
41 anyhow::ensure!(
42 arch == CommonArch::X86_64,
43 "mshv payloads support only x86-64"
44 );
45 outputs.push(artifacts);
46 }
47 if outputs.is_empty() {
48 return Ok(());
49 }
50
51 #[cfg(not(target_os = "linux"))]
52 anyhow::ensure!(
53 local_path.is_some(),
54 "automatic mshv RPM acquisition requires Linux; supply local boot artifacts instead"
55 );
56
57 let installed = if local_path.is_none()
58 && !matches!(
59 ctx.platform(),
60 FlowPlatform::Linux(FlowPlatformLinuxDistro::Nix)
61 ) {
62 Some(ctx.reqv(
63 |done| flowey_lib_common::install_dist_pkg::Request::Install {
64 package_names: vec!["curl".into()],
65 done,
66 },
67 ))
68 } else {
69 None
70 };
71 #[cfg(target_os = "linux")]
72 let persistent_dir = ctx.persistent_dir();
73 ctx.emit_rust_step("resolve mshv boot payloads", |ctx| {
74 let local_path = local_path.claim(ctx);
75 #[cfg(target_os = "linux")]
76 let persistent_dir = persistent_dir.claim(ctx);
77 let outputs = outputs.claim(ctx);
78 installed.claim(ctx);
79 move |rt| {
80 let path = if let Some(local_path) = local_path {
81 rt.read(local_path)
82 } else {
83 #[cfg(not(target_os = "linux"))]
84 anyhow::bail!("automatic mshv RPM acquisition requires Linux");
85
86 #[cfg(target_os = "linux")]
87 {
88 let cache = persistent_dir
89 .map(|dir| rt.read(dir))
90 .unwrap_or_else(|| rt.sh.current_dir())
91 .join("mshv");
92 rpm_payload::acquire(&cache, |url, output| {
93 flowey::shell_cmd!(
94 rt,
95 "curl --fail --location --proto =https --proto-redir =https
96 --connect-timeout 30 --max-time 300 --output {output} {url}"
97 )
98 .run()?;
99 Ok(())
100 })?
101 }
102 };
103 validate_artifacts(&path)?;
104 let path = path.absolute()?;
105 rt.write_all(outputs, &path);
106 Ok(())
107 }
108 });
109 Ok(())
110 }
111}
112
113fn validate_artifacts(root: &Path) -> anyhow::Result<()> {
114 for file in [
115 "boot/HvLoader.efi",
116 "boot/lxhvloader.dll",
117 "boot/Windows/System32/hvix64.exe",
118 "boot/Windows/System32/hvax64.exe",
119 ] {
120 anyhow::ensure!(
121 root.join(file).is_file(),
122 "missing mshv boot artifact {}",
123 root.join(file).display()
124 );
125 }
126 Ok(())
127}
128
129#[cfg(target_os = "linux")]
130mod rpm_payload {
131 use super::*;
132 use sha2::Digest as _;
133 use std::collections::BTreeMap;
134 use std::io::{Read as _, Write as _};
135
136 const REPOSITORY: &str = "https://packages.microsoft.com/azurelinux/3.0/prod";
137 const RECIPE: &str = "public-26100.9444";
138
139 struct Package {
140 repository: &'static str,
141 filename: &'static str,
142 sha256: &'static str,
143 }
144
145 impl Package {
146 fn url(&self) -> String {
147 format!(
148 "{}/{}/x86_64/Packages/{}/{}",
149 REPOSITORY,
150 self.repository,
151 &self.filename[..1],
152 self.filename
153 )
154 }
155 }
156
157 const PACKAGES: &[Package] = &[
158 Package {
159 repository: "base",
160 filename: "edk2-hvloader-20240524git3e722403cd16-20.azl3.x86_64.rpm",
161 sha256: "8f5dcf56e7a009f5e2a9f594c46c6d6b43345eff7f2ddd4b011095ab95ec40c3",
162 },
163 Package {
164 repository: "ms-non-oss",
165 filename: "mshv-26100.9444.2609032029.1-1.azl3.x86_64.rpm",
166 sha256: "95a95671546fc29488ffd8b61c00d9869928ceca25005f5c1ca3beaaf83d9385",
167 },
168 Package {
169 repository: "ms-non-oss",
170 filename: "mshv-bootloader-lx-26100.9444.2609032029.1-1.azl3.x86_64.rpm",
171 sha256: "cf75cc2633df5d35484ea75aacea7d308a1b18f4064c0e159d99e0e20c48df63",
172 },
173 ];
174
175 fn verify_hash(path: &Path, expected: &str) -> anyhow::Result<()> {
176 anyhow::ensure!(
177 fs_err::symlink_metadata(path)?.file_type().is_file(),
178 "RPM cache entry is not a regular file: {}",
179 path.display()
180 );
181 let mut file = fs_err::File::open(path)?;
182 let mut hash = sha2::Sha256::new();
183 let mut buffer = [0; 64 * 1024];
184 loop {
185 let len = file.read(&mut buffer)?;
186 if len == 0 {
187 break;
188 }
189 hash.update(&buffer[..len]);
190 }
191 let actual = hash
192 .finalize()
193 .iter()
194 .map(|byte| format!("{byte:02x}"))
195 .collect::<String>();
196 anyhow::ensure!(
197 actual == expected,
198 "SHA-256 mismatch for {}: expected {expected}, found {actual}",
199 path.display()
200 );
201 Ok(())
202 }
203
204 fn cached_rpm(
205 cache: &Path,
206 package: &Package,
207 download: &mut impl FnMut(&str, &Path) -> anyhow::Result<()>,
208 ) -> anyhow::Result<PathBuf> {
209 let path = cache.join(package.filename);
210 match fs_err::symlink_metadata(&path) {
211 Ok(_) => {
212 verify_hash(&path, package.sha256)?;
213 return Ok(path);
214 }
215 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
216 Err(error) => return Err(error.into()),
217 }
218 let temp = tempfile::NamedTempFile::new_in(cache)?;
219 download(&package.url(), temp.path())?;
220 verify_hash(temp.path(), package.sha256)?;
221 match temp.persist_noclobber(&path) {
222 Ok(_) => {}
223 Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {
224 log::debug!("another writer populated RPM cache {}", path.display());
225 verify_hash(&path, package.sha256)?;
226 }
227 Err(error) => return Err(error.error.into()),
228 }
229 Ok(path)
230 }
231
232 pub(super) fn acquire(
233 cache: &Path,
234 mut download: impl FnMut(&str, &Path) -> anyhow::Result<()>,
235 ) -> anyhow::Result<PathBuf> {
236 fs_err::create_dir_all(cache)?;
237 let mut files = BTreeMap::new();
238 let mut manifest = String::new();
239 for package in PACKAGES {
240 let path = cached_rpm(cache, package, &mut download)?;
241 let rpm =
242 rpm::Package::open(&path).with_context(|| format!("reading {}", path.display()))?;
243 anyhow::ensure!(
244 rpm.metadata.get_arch()? == "x86_64",
245 "expected an x86_64 RPM"
246 );
247 for entry in rpm.files()? {
248 let entry = entry?;
249 let path = entry.metadata.path();
250 let path = path.to_str().context("non-UTF-8 RPM path")?;
251 let Some(path) = destination(path)? else {
252 continue;
253 };
254 match entry.metadata.file_type() {
255 rpm::FileType::Dir => continue,
256 rpm::FileType::Regular => {}
257 kind => anyhow::bail!("unsupported RPM file type {kind:?}: {path}"),
258 }
259 anyhow::ensure!(
260 !files
261 .keys()
262 .any(|key: &String| key.eq_ignore_ascii_case(&path)),
263 "duplicate extracted path: {path}"
264 );
265 let contents = entry
266 .content()
267 .with_context(|| format!("missing contents for {path}"))?;
268 files.insert(path, contents.to_vec());
269 }
270 manifest.push_str(&format!("{} {}\n", package.sha256, package.url()));
271 }
272 files.insert("SHA256SUMS".into(), manifest.into_bytes());
273 let output = cache.join(RECIPE);
274 if !output.exists() {
275 let scratch = tempfile::Builder::new()
276 .prefix(".mshv-extract-")
277 .tempdir_in(cache)?;
278 for (name, contents) in &files {
279 let path = scratch.path().join(name);
280 fs_err::create_dir_all(path.parent().context("missing output parent")?)?;
281 let mut file = fs_err::OpenOptions::new()
282 .write(true)
283 .create_new(true)
284 .open(path)?;
285 file.write_all(contents)?;
286 }
287 validate_artifacts(scratch.path())?;
288 match fs_err::rename(scratch.path(), &output) {
289 Ok(()) => {}
290 Err(error)
291 if matches!(
292 error.kind(),
293 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty
294 ) =>
295 {
296 log::debug!(
297 "another writer populated mshv payloads {}",
298 output.display()
299 );
300 }
301 Err(error) => return Err(error.into()),
302 }
303 }
304 verify_output(&output, &files)?;
305 Ok(output)
306 }
307
308 fn verify_output(root: &Path, files: &BTreeMap<String, Vec<u8>>) -> anyhow::Result<()> {
309 anyhow::ensure!(
310 fs_err::symlink_metadata(root)?.file_type().is_dir(),
311 "mshv payload cache is not a directory: {}",
312 root.display()
313 );
314 let mut directories = vec![root.to_path_buf()];
315 let mut found = 0;
316 while let Some(dir) = directories.pop() {
317 for entry in fs_err::read_dir(dir)? {
318 let entry = entry?;
319 let path = entry.path();
320 let kind = entry.file_type()?;
321 if kind.is_dir() {
322 directories.push(path);
323 continue;
324 }
325 let relative = path.strip_prefix(root)?;
326 anyhow::ensure!(
327 kind.is_file() && files.keys().any(|name| Path::new(name) == relative),
328 "unexpected mshv payload cache entry: {}",
329 path.display()
330 );
331 found += 1;
332 }
333 }
334 anyhow::ensure!(
335 found == files.len(),
336 "incomplete mshv payload cache: {}",
337 root.display()
338 );
339 for (name, contents) in files {
340 let path = root.join(name);
341 anyhow::ensure!(
342 fs_err::symlink_metadata(&path)?.file_type().is_file(),
343 "cached mshv payload is not a regular file: {}",
344 path.display()
345 );
346 anyhow::ensure!(
347 fs_err::read(&path)? == *contents,
348 "cached mshv payload does not match its pinned RPM: {}",
349 path.display()
350 );
351 }
352 Ok(())
353 }
354
355 fn destination(path: &str) -> anyhow::Result<Option<String>> {
356 anyhow::ensure!(
357 path.starts_with('/')
358 && path[1..].split('/').all(|part| {
359 !part.is_empty()
360 && part != "."
361 && part != ".."
362 && part.is_ascii()
363 && !part.ends_with([' ', '.'])
364 && !part
365 .chars()
366 .any(|c| c.is_control() || "\"*:<>?\\|".contains(c))
367 }),
368 "unsupported RPM path: {path:?}"
369 );
370 if let Some(path) = path.strip_prefix("/boot/efi/") {
371 Ok(Some(format!("boot/{path}")))
372 } else {
373 Ok(path
374 .strip_prefix("/usr/share/licenses/")
375 .map(|path| format!("licenses/{path}")))
376 }
377 }
378
379 #[cfg(test)]
380 mod tests {
381 use super::*;
382 use test_with_tracing::test;
383
384 #[test]
385 fn verifies_cached_rpms_without_downloading_again() -> anyhow::Result<()> {
386 let cache = tempfile::tempdir()?;
387 let package = Package {
388 repository: "base",
389 filename: "test.rpm",
390 sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
391 };
392 let mut unexpected_download =
393 |_: &str, _: &Path| -> anyhow::Result<()> { anyhow::bail!("unexpected download") };
394 let path = cache.path().join(package.filename);
395 fs_err::write(&path, b"abc")?;
396 assert_eq!(
397 cached_rpm(cache.path(), &package, &mut unexpected_download)?,
398 path
399 );
400 fs_err::write(&path, b"bad")?;
401 assert!(cached_rpm(cache.path(), &package, &mut unexpected_download).is_err());
402 Ok(())
403 }
404
405 #[test]
406 fn rejects_unsafe_paths_and_preserves_licenses() -> anyhow::Result<()> {
407 assert_eq!(
408 destination("/boot/efi/HvLoader.efi")?,
409 Some("boot/HvLoader.efi".into())
410 );
411 assert_eq!(
412 destination("/usr/share/licenses/mshv/EULA.txt")?,
413 Some("licenses/mshv/EULA.txt".into())
414 );
415 assert_eq!(destination("/etc/config")?, None);
416 for path in [
417 "/boot/efi/../escape",
418 "/boot/efi//file",
419 "/boot/efi/a\\b",
420 "/boot/efi/C:drive",
421 "/boot/efi/file.",
422 "/boot/efi/x\n",
423 ] {
424 assert!(destination(path).is_err(), "{path:?}");
425 }
426 Ok(())
427 }
428
429 #[test]
430 fn rejects_modified_extracted_payloads() -> anyhow::Result<()> {
431 let root = tempfile::tempdir()?;
432 let expected = BTreeMap::from([("HvLoader.efi".into(), b"pinned".to_vec())]);
433 fs_err::write(root.path().join("HvLoader.efi"), b"pinned")?;
434 verify_output(root.path(), &expected)?;
435 fs_err::write(root.path().join("HvLoader.efi"), b"modified")?;
436 assert!(verify_output(root.path(), &expected).is_err());
437 fs_err::write(root.path().join("HvLoader.efi"), b"pinned")?;
438 fs_err::write(root.path().join("unverified.dll"), b"extra")?;
439 assert!(verify_output(root.path(), &expected).is_err());
440 Ok(())
441 }
442
443 #[test]
444 fn publishes_only_verified_downloads() -> anyhow::Result<()> {
445 let cache = tempfile::tempdir()?;
446 let package = Package {
447 repository: "base",
448 filename: "test.rpm",
449 sha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
450 };
451 assert!(
452 cached_rpm(cache.path(), &package, &mut |_, path| {
453 fs_err::write(path, b"bad")?;
454 Ok(())
455 })
456 .is_err()
457 );
458 assert!(!cache.path().join(package.filename).exists());
459 let path = cached_rpm(cache.path(), &package, &mut |_, path| {
460 fs_err::write(path, b"abc")?;
461 Ok(())
462 })?;
463 assert_eq!(fs_err::read(path)?, b"abc");
464 Ok(())
465 }
466 }
467}