Skip to main content

flowey_lib_hvlite/
resolve_mshv_boot_artifacts.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Resolve pinned public mshv payloads or use an explicitly supplied directory.
5
6use crate::common::CommonArch;
7use flowey::node::prelude::*;
8
9flowey_config! {
10    pub struct Config {
11        /// Authorized local artifacts, containing boot/HvLoader.efi and its runtime.
12        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}