Skip to main content

lowell_core/inspect/
uki.rs

1use crate::formats::initramfs::{detect, Compression};
2use crate::formats::osrel::{read_os_release, OsRelease};
3use crate::formats::pe::PeFile;
4use crate::inspect::ext::SectionLookupExt;
5use anyhow::{Context, Result};
6use sha2::{Digest, Sha256};
7use std::path::PathBuf;
8use std::time::Instant;
9use tracing::{debug, debug_span};
10
11#[derive(Debug)]
12pub struct UkiOptions {
13    /// Path to the UKI to inspect
14    pub file: PathBuf,
15}
16
17#[derive(Debug, serde::Serialize)]
18pub struct Report {
19    pub arch: String,        // e.g. "aarch64"
20    pub pe32_plus: bool,     // PE32+?
21    pub has_signature: bool, // Authenticode present?
22    pub cert_count: usize,   // number of certs (if has_signature)
23    pub cmdline: String,
24    pub os_release: Option<OsRelease>,
25    pub linux: SectionInfo,
26    pub initrd: InitrdInfo,
27}
28
29#[derive(Debug, serde::Serialize)]
30pub struct SectionInfo {
31    pub offset: usize,
32    pub size: usize,
33    pub sha256: String,
34}
35
36#[derive(Debug, serde::Serialize)]
37pub struct InitrdInfo {
38    #[serde(flatten)]
39    pub section: SectionInfo,
40    pub compression: Compression,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub entries_estimate: Option<usize>,
43}
44
45pub fn inspect(UkiOptions { file: uki }: UkiOptions) -> Result<Report> {
46    // Parent span
47    let _inspect_span = debug_span!("inspect", path = %uki.display()).entered();
48
49    // 1) File read
50    let t0 = Instant::now();
51    let bytes = std::fs::read(&uki).with_context(|| format!("read {}", uki.display()))?;
52    debug!(
53        len = bytes.len(),
54        elapsed_ms = t0.elapsed().as_millis(),
55        "read_file"
56    );
57
58    // 2) Parse PE + arch
59    let t = Instant::now();
60    let pef = PeFile::from_bytes(bytes)?;
61    let (arch, pe32p) = pef.arch_summary()?;
62    debug!(
63        arch,
64        pe32_plus = pe32p,
65        elapsed_ms = t.elapsed().as_millis(),
66        "parse_pe"
67    );
68
69    // 3) cmdline + os-release
70    let t = Instant::now();
71    let cmdline = pef
72        .read_text(".cmdline")?
73        .unwrap_or_default()
74        .trim()
75        .to_string();
76    let os_release: Option<OsRelease> = read_os_release(&pef)?;
77    debug!(elapsed_ms = t.elapsed().as_millis(), "metadata");
78
79    // 4) .linux: fetch + hash
80    let (mut linux_info, linux_bytes) = pef.section_info_and_bytes(".linux")?;
81    let t = Instant::now();
82    linux_info.sha256 = format!("{:x}", Sha256::digest(linux_bytes));
83    debug!(
84        size = linux_bytes.len(),
85        elapsed_ms = t.elapsed().as_millis(),
86        "sha256_linux"
87    );
88
89    // 5) .initrd: fetch + hash + detect
90    let (mut initrd_info, initrd_bytes) = pef.section_info_and_bytes(".initrd")?;
91    let t = Instant::now();
92    initrd_info.sha256 = format!("{:x}", Sha256::digest(initrd_bytes));
93    let detect_t = Instant::now();
94    let compression = detect(initrd_bytes);
95    debug!(
96        size = initrd_bytes.len(),
97        hash_ms = t.elapsed().as_millis(),
98        detect_ms = detect_t.elapsed().as_millis(),
99        "initrd_hash_and_detect"
100    );
101
102    // 6) Certificates (do once; reuse for has_signature + count)
103    let t = Instant::now();
104    let cert_count = pef.certificate_blobs()?.len();
105    let has_signature = cert_count > 0;
106    debug!(
107        cert_count,
108        elapsed_ms = t.elapsed().as_millis(),
109        "certificates"
110    );
111
112    let initrd = InitrdInfo {
113        section: initrd_info,
114        compression,
115        entries_estimate: None,
116    };
117
118    Ok(Report {
119        arch: arch.to_string(),
120        pe32_plus: pe32p,
121        has_signature,
122        cert_count,
123        cmdline,
124        os_release,
125        linux: linux_info,
126        initrd,
127    })
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::formats::initramfs::{detect, Compression};
134    use crate::formats::osrel::read_os_release_from_str;
135
136    // ---- initramfs detection (pure unit tests) ----
137
138    #[test]
139    fn initramfs_detects_gzip_xz_zstd_newc_unknown() {
140        // gzip magic: 1F 8B
141        assert!(matches!(
142            detect(&[0x1F, 0x8B, 0x08, 0x00]),
143            Compression::Gzip
144        ));
145
146        // xz magic: FD 37 7A 58 5A 00
147        assert!(matches!(
148            detect(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]),
149            Compression::Xz
150        ));
151
152        // zstd magic: 28 B5 2F FD
153        assert!(matches!(
154            detect(&[0x28, 0xB5, 0x2F, 0xFD]),
155            Compression::Zstd
156        ));
157
158        // newc cpio (uncompressed): ASCII "070701" at start
159        assert!(matches!(detect(b"070701..."), Compression::Uncompressed));
160
161        // unknown / too short
162        assert!(matches!(detect(&[]), Compression::Unknown));
163        assert!(matches!(detect(&[0x00, 0x01]), Compression::Unknown));
164    }
165
166    // ---- os-release parsing (pure unit tests) ----
167
168    #[test]
169    fn osrelease_parses_fedora41_and_prefers_pretty_name() {
170        // Realistic snippet (trimmed)
171        let fedora = r#"NAME="Fedora Linux"
172VERSION="41 (Forty One)"
173ID=fedora
174VERSION_ID=41
175PRETTY_NAME="Fedora Linux 41 (Forty One)"
176"#;
177
178        let os = read_os_release_from_str(fedora)
179            .expect("parse ok")
180            .expect("Some(os-release)");
181
182        // PRETTY_NAME takes priority for human display
183        assert_eq!(os.name.as_deref(), Some("Fedora Linux 41 (Forty One)"));
184        // Stable fields used for tooling/logic
185        assert_eq!(os.id.as_deref(), Some("fedora"));
186        assert_eq!(os.version_id.as_deref(), Some("41"));
187    }
188
189    #[test]
190    fn osrelease_falls_back_to_name_when_pretty_missing() {
191        let minimal = r#"NAME="MyOS"
192ID=myos
193VERSION_ID="1.2.3"
194"#;
195        let os = read_os_release_from_str(minimal)
196            .expect("parse ok")
197            .expect("Some(os-release)");
198
199        // PRETTY_NAME absent → fall back to NAME
200        assert_eq!(os.name.as_deref(), Some("MyOS"));
201        assert_eq!(os.id.as_deref(), Some("myos"));
202        assert_eq!(os.version_id.as_deref(), Some("1.2.3"));
203    }
204
205    // ---- optional integration smoke test (ignored by default) ----
206    //
207    // Run with:  UKI_PATH=/full/path/to/vmlinuz.efi  cargo test -- --ignored
208    // or:        cargo test inspect_real_uki_smoke -- --ignored
209    #[test]
210    #[ignore = "requires UKI_PATH"]
211    fn inspect_real_uki_smoke() {
212        let uki_path = std::env::var("UKI_PATH").expect("set UKI_PATH to a real UKI");
213        let report = inspect(UkiOptions {
214            file: uki_path.into(),
215        })
216        .expect("inspect report");
217
218        // Sanity checks that don’t depend on a specific distro
219        assert!(!report.arch.is_empty());
220        assert!(report.linux.size > 0);
221        assert!(report.initrd.section.size > 0);
222        assert_ne!(report.initrd.compression, Compression::Unknown);
223
224        // sha256 fields should be 64 hex chars
225        assert_eq!(report.linux.sha256.len(), 64);
226        assert!(report.linux.sha256.chars().all(|c| c.is_ascii_hexdigit()));
227        assert_eq!(report.initrd.section.sha256.len(), 64);
228        assert!(report
229            .initrd
230            .section
231            .sha256
232            .chars()
233            .all(|c| c.is_ascii_hexdigit()));
234
235        // If the UKI embeds .cmdline, it should be trimmed
236        assert_eq!(report.cmdline, report.cmdline.trim());
237    }
238}