Skip to main content

cargo_libc/
report.rs

1use crate::{
2    elf::{self, ElfInfo},
3    version::Version,
4};
5use linux_abi_scan::{Report, ScanTarget, Severity};
6use linux_targets::{Request, Selection};
7use std::{fs::File, io::Read, path::Path};
8
9const MAX_ELF_BYTES: u64 = 256 * 1024 * 1024;
10
11#[derive(Debug)]
12pub struct Assessment {
13    pub errors: Vec<String>,
14    pub notes: Vec<String>,
15    pub highest_required: Option<Version>,
16}
17
18/// The tier 1 scan requested on `inspect` (`--distro`, `--kernel`, `--scan`).
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct ScanOptions {
21    pub distro: Option<String>,
22    pub kernel: Option<String>,
23    /// Warnings fail too.
24    pub strict: bool,
25    /// Show informational findings in the text report.
26    pub verbose: bool,
27}
28
29/// A finished scan and the options it ran with.
30pub struct Scan<'a> {
31    pub options: &'a ScanOptions,
32    pub report: Report,
33}
34
35impl Scan<'_> {
36    pub fn passed(&self) -> bool {
37        self.report.exit_code(self.options.strict) == 0
38    }
39}
40
41pub fn read_elf(path: &Path) -> Result<ElfInfo, String> {
42    elf::inspect(&read_bytes(path)?)
43}
44
45pub fn read_bytes(path: &Path) -> Result<Vec<u8>, String> {
46    let file = File::open(path).map_err(|e| format!("cannot open {}: {e}", path.display()))?;
47    if !file.metadata().map_err(|e| e.to_string())?.is_file() {
48        return Err("ELF input must be a regular file".into());
49    }
50    let mut bytes = Vec::new();
51    file.take(MAX_ELF_BYTES + 1)
52        .read_to_end(&mut bytes)
53        .map_err(|e| e.to_string())?;
54    if bytes.len() as u64 > MAX_ELF_BYTES {
55        return Err("ELF input exceeds the prototype's 256 MiB limit".into());
56    }
57    Ok(bytes)
58}
59
60/// Resolves the scan's floors with `linux_targets` (so a preset means the
61/// same here as in the seccomp simulation) and scans `bytes`. Returns the
62/// glibc ceiling for the direct check, which a `--distro` preset sets.
63/// Resolves the scan's floors with `linux_targets`, so a preset means the
64/// same here as in the seccomp simulation. Returns the glibc ceiling for the
65/// direct check: a `--distro` preset sets it, otherwise `--max-glibc` stays.
66pub fn floors(
67    ceiling: Option<Version>,
68    options: &ScanOptions,
69) -> Result<(Option<Version>, ScanTarget), String> {
70    let glibc = ceiling.map(|v| v.to_string());
71    let selection = Selection::resolve(&Request {
72        distro: options.distro.as_deref(),
73        glibc: glibc.as_deref(),
74        kernel: options.kernel.as_deref(),
75        target: None,
76    })?;
77    let ceiling = match (&selection.distro, selection.glibc) {
78        (Some(_), Some(floor)) => Some(
79            floor
80                .to_string()
81                .parse::<Version>()?
82                .validate_glibc_target()?,
83        ),
84        _ => ceiling,
85    };
86    Ok((ceiling, ScanTarget::from_selection(&selection)))
87}
88
89/// Runs the tier 1 scan on `bytes`; see [`floors`].
90pub fn scan<'a>(
91    bytes: &[u8],
92    ceiling: Option<Version>,
93    options: &'a ScanOptions,
94) -> Result<(Option<Version>, Scan<'a>), String> {
95    let (ceiling, target) = floors(ceiling, options)?;
96    let report = linux_abi_scan::scan(bytes, &target).map_err(|e| format!("tier 1 scan: {e}"))?;
97    Ok((ceiling, Scan { options, report }))
98}
99
100pub fn assess(info: &ElfInfo, ceiling: Option<Version>) -> Assessment {
101    let mut result = Assessment {
102        errors: Vec::new(),
103        notes: Vec::new(),
104        highest_required: None,
105    };
106    for need in &info.versions {
107        let Some(suffix) = need.name.strip_prefix("GLIBC_") else {
108            continue;
109        };
110        match suffix.parse::<Version>() {
111            Ok(version) if !need.weak => {
112                result.highest_required =
113                    Some(result.highest_required.map_or(version, |v| v.max(version)));
114                if ceiling.is_some_and(|limit| version > limit) {
115                    result.errors.push(format!(
116                        "{} requires {} above the requested ceiling {}",
117                        need.library,
118                        need.name,
119                        ceiling.unwrap()
120                    ));
121                }
122            }
123            Ok(version) => {
124                if ceiling.is_some_and(|limit| version > limit) {
125                    result.notes.push(format!(
126                        "{} has version-level WEAK requirement {}; reported separately",
127                        need.library, need.name
128                    ));
129                }
130            }
131            Err(_) if need.weak => result.notes.push(format!(
132                "{} has nonnumeric version-level WEAK requirement {}",
133                need.library, need.name
134            )),
135            Err(_) if ceiling.is_some() => result.errors.push(format!(
136                "{} requires {}; a numeric glibc ceiling cannot validate this requirement",
137                need.library, need.name
138            )),
139            Err(_) => result.notes.push(format!(
140                "{} has nonnumeric glibc requirement {}",
141                need.library, need.name
142            )),
143        }
144    }
145    result
146}
147
148pub fn quote_json(value: &str) -> String {
149    let mut result = String::from("\"");
150    for c in value.chars() {
151        match c {
152            '"' => result.push_str("\\\""),
153            '\\' => result.push_str("\\\\"),
154            '\n' => result.push_str("\\n"),
155            '\r' => result.push_str("\\r"),
156            '\t' => result.push_str("\\t"),
157            c if c <= '\u{1f}' => result.push_str(&format!("\\u{:04x}", c as u32)),
158            c => result.push(c),
159        }
160    }
161    result.push('"');
162    result
163}
164
165fn json_strings(values: &[String]) -> String {
166    format!(
167        "[{}]",
168        values
169            .iter()
170            .map(|s| quote_json(s))
171            .collect::<Vec<_>>()
172            .join(",")
173    )
174}
175
176fn scan_json(scan: &Scan) -> String {
177    let findings = scan
178        .report
179        .findings
180        .iter()
181        .map(|f| {
182            format!(
183                concat!(
184                    "{{\"severity\":{},\"confidence\":{},\"check\":{},",
185                    "\"subject\":{},\"address\":{},\"detail\":{}}}"
186                ),
187                quote_json(f.severity.as_str()),
188                quote_json(f.confidence.as_str()),
189                quote_json(f.check),
190                quote_json(&f.subject),
191                f.address
192                    .map_or("null".into(), |a| quote_json(&format!("{a:#x}"))),
193                quote_json(&f.detail)
194            )
195        })
196        .collect::<Vec<_>>()
197        .join(",");
198    format!(
199        concat!(
200            "{{\"tier\":1,\"target\":{},\"strict\":{},\"result\":{},",
201            "\"errors\":{},\"warnings\":{},\"findings\":[{}]}}"
202        ),
203        quote_json(&scan.report.target),
204        scan.options.strict,
205        quote_json(if scan.passed() { "pass" } else { "fail" }),
206        scan.report.count(Severity::Error),
207        scan.report.count(Severity::Warning),
208        findings
209    )
210}
211
212pub fn render(
213    path: &Path,
214    info: &ElfInfo,
215    assessment: &Assessment,
216    ceiling: Option<Version>,
217    scan: Option<&Scan>,
218    json: bool,
219) -> String {
220    let check = if ceiling.is_none() {
221        "not_requested"
222    } else if assessment.errors.is_empty() {
223        "pass"
224    } else {
225        "fail"
226    };
227    if json {
228        let versions = info
229            .versions
230            .iter()
231            .map(|n| {
232                format!(
233                    "{{\"library\":{},\"name\":{},\"version_weak\":{}}}",
234                    quote_json(&n.library),
235                    quote_json(&n.name),
236                    n.weak
237                )
238            })
239            .collect::<Vec<_>>()
240            .join(",");
241        return format!(
242            concat!(
243                "{{\"file\":{},\"machine\":{},\"interpreter\":{},",
244                "\"needed\":{},\"rpath\":{},\"runpath\":{},\"version_requirements\":[{}],",
245                "\"max_glibc\":{},\"highest_required_numeric_glibc\":{},",
246                "\"direct_glibc_requirement_check\":{},\"errors\":{},\"notes\":{},",
247                "\"scan\":{},",
248                "\"transitive_dependencies_checked\":false,\"runtime_tested\":false}}"
249            ),
250            quote_json(&path.to_string_lossy()),
251            info.machine,
252            info.interpreter
253                .as_ref()
254                .map_or("null".into(), |s| quote_json(s)),
255            json_strings(&info.needed),
256            json_strings(&info.rpath),
257            json_strings(&info.runpath),
258            versions,
259            ceiling.map_or("null".into(), |v| quote_json(&v.to_string())),
260            assessment
261                .highest_required
262                .map_or("null".into(), |v| quote_json(&v.to_string())),
263            quote_json(check),
264            json_strings(&assessment.errors),
265            json_strings(&assessment.notes),
266            scan.map_or("null".into(), scan_json)
267        );
268    }
269    let mut lines = vec![
270        format!("ELF: {}", path.display()),
271        format!("Interpreter: {:?}", info.interpreter),
272        format!("Needed libraries: {:?}", info.needed),
273        format!("RPATH: {:?}", info.rpath),
274        format!("RUNPATH: {:?}", info.runpath),
275        "Version requirements:".into(),
276    ];
277    for need in &info.versions {
278        lines.push(format!(
279            "  {:?}: {:?} [{}]",
280            need.library,
281            need.name,
282            if need.weak {
283                "version-level WEAK"
284            } else {
285                "required"
286            }
287        ));
288    }
289    lines.push(format!(
290        "Highest required numeric GLIBC version: {}",
291        assessment
292            .highest_required
293            .map_or("none".into(), |v| v.to_string())
294    ));
295    lines.push(format!("Direct glibc requirement check: {check}"));
296    for error in &assessment.errors {
297        lines.push(format!("  FAIL: {error}"));
298    }
299    for note in &assessment.notes {
300        lines.push(format!("  NOTE: {note}"));
301    }
302    match scan {
303        None => lines.push("Scope: direct ELF version requirements only. Dependent libraries, CPU/kernel requirements, dynamically loaded plugins, and runtime behavior are not checked. For the tier 1 kernel scan, run cargo libc inspect with --distro or --kernel.".into()),
304        Some(scan) => {
305            lines.push(format!(
306                "Tier 1 scan (linux-abi-scan{}): {}",
307                if scan.options.strict { ", strict" } else { "" },
308                if scan.passed() { "pass" } else { "fail" }
309            ));
310            lines.extend(
311                scan.report
312                    .to_text(scan.options.verbose)
313                    .lines()
314                    .map(|line| format!("  {line}")),
315            );
316            lines.push("Scope: this file only. Dependent libraries, dynamically loaded plugins, syscalls made inside glibc, and runtime behavior are not checked.".into());
317        }
318    }
319    lines.join("\n")
320}
321
322pub fn inspect_file(
323    path: &Path,
324    ceiling: Option<Version>,
325    scan_options: Option<&ScanOptions>,
326    json: bool,
327) -> Result<i32, String> {
328    let bytes = read_bytes(path)?;
329    let info = elf::inspect(&bytes)?;
330    let (ceiling, scan) = match scan_options {
331        Some(options) => {
332            let (ceiling, scan) = scan(&bytes, ceiling, options)?;
333            (ceiling, Some(scan))
334        }
335        None => (ceiling, None),
336    };
337    let assessment = assess(&info, ceiling);
338    println!(
339        "{}",
340        render(path, &info, &assessment, ceiling, scan.as_ref(), json)
341    );
342    let passed = assessment.errors.is_empty() && scan.as_ref().is_none_or(Scan::passed);
343    Ok(if passed { 0 } else { 2 })
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::elf::VersionNeed;
350    use linux_abi_scan::{Confidence, Finding};
351
352    fn fixture(needs: &[(&str, bool)]) -> ElfInfo {
353        ElfInfo {
354            machine: 62,
355            interpreter: None,
356            needed: vec!["libc.so.6".into()],
357            rpath: vec![],
358            runpath: vec![],
359            versions: needs
360                .iter()
361                .map(|(name, weak)| VersionNeed {
362                    library: "libc.so.6".into(),
363                    name: name.to_string(),
364                    weak: *weak,
365                })
366                .collect(),
367            definitions: vec![],
368        }
369    }
370
371    #[test]
372    fn newer_weak_requirement_does_not_raise_required_floor() {
373        let result = assess(
374            &fixture(&[("GLIBC_2.17", false), ("GLIBC_2.34", true)]),
375            Some("2.22".parse().unwrap()),
376        );
377        assert!(result.errors.is_empty());
378        assert_eq!(result.highest_required, Some("2.17".parse().unwrap()));
379        assert_eq!(result.notes.len(), 1);
380    }
381
382    #[test]
383    fn rejects_newer_required_and_nonnumeric_abi_requirements() {
384        for name in ["GLIBC_2.34", "GLIBC_PRIVATE", "GLIBC_ABI_DT_RELR"] {
385            let result = assess(&fixture(&[(name, false)]), Some("2.22".parse().unwrap()));
386            assert_eq!(result.errors.len(), 1, "{name}");
387        }
388    }
389
390    #[test]
391    fn does_not_treat_libstdcxx_versions_as_glibc_versions() {
392        let result = assess(
393            &fixture(&[("GLIBCXX_3.4.30", false), ("GCC_4.2.0", false)]),
394            Some("2.22".parse().unwrap()),
395        );
396        assert!(result.errors.is_empty());
397        assert!(result.highest_required.is_none());
398    }
399
400    #[test]
401    fn scan_floors_come_from_the_preset_or_the_flags() {
402        let sles = ScanOptions {
403            distro: Some("sles-12-sp5".into()),
404            ..Default::default()
405        };
406        let (ceiling, target) = floors(None, &sles).unwrap();
407        assert_eq!(ceiling, Some("2.22".parse().unwrap()));
408        assert_eq!(target.glibc.unwrap().to_string(), "2.22");
409        assert!(target.kernel.is_some());
410        assert!(target.label.contains("SUSE"));
411
412        let kernel_only = ScanOptions {
413            kernel: Some("3.10".into()),
414            ..Default::default()
415        };
416        let patch = "2.17.1".parse().unwrap();
417        let (ceiling, target) = floors(Some(patch), &kernel_only).unwrap();
418        assert_eq!(ceiling, Some(patch), "--max-glibc is kept as given");
419        assert!(target.kernel.is_some());
420
421        let (_, target) = floors(Some(patch), &ScanOptions::default()).unwrap();
422        assert!(
423            target.kernel.is_none(),
424            "--scan alone does not simulate a kernel"
425        );
426
427        let unknown = ScanOptions {
428            distro: Some("no-such-distro".into()),
429            ..Default::default()
430        };
431        assert!(floors(None, &unknown).is_err());
432    }
433
434    #[test]
435    fn scan_results_render_in_text_and_json() {
436        let options = ScanOptions {
437            strict: true,
438            ..Default::default()
439        };
440        let scan = Scan {
441            options: &options,
442            report: Report {
443                target: "glibc 2.17, kernel 3.10".into(),
444                findings: vec![Finding {
445                    severity: Severity::Warning,
446                    confidence: Confidence::Heuristic,
447                    check: "syscall-call",
448                    subject: "memfd_create".into(),
449                    detail: "added in \"3.17\"".into(),
450                    address: Some(0x1234),
451                }],
452            },
453        };
454        assert!(!scan.passed(), "strict makes a warning fail");
455        let info = fixture(&[("GLIBC_2.17", false)]);
456        let ceiling = Some("2.17".parse().unwrap());
457        let assessment = assess(&info, ceiling);
458        let path = Path::new("app");
459        let json = render(path, &info, &assessment, ceiling, Some(&scan), true);
460        assert!(json.contains(concat!(
461            "\"scan\":{\"tier\":1,\"target\":\"glibc 2.17, kernel 3.10\",",
462            "\"strict\":true,\"result\":\"fail\",\"errors\":0,\"warnings\":1,",
463            "\"findings\":[{\"severity\":\"warning\",\"confidence\":\"heuristic\",",
464            "\"check\":\"syscall-call\",\"subject\":\"memfd_create\",",
465            "\"address\":\"0x1234\",\"detail\":\"added in \\\"3.17\\\"\"}]}"
466        )));
467        let text = render(path, &info, &assessment, ceiling, Some(&scan), false);
468        assert!(text.contains("Tier 1 scan (linux-abi-scan, strict): fail"));
469        assert!(text.contains("memfd_create at 0x1234"));
470        let plain = render(path, &info, &assessment, ceiling, None, true);
471        assert!(plain.contains("\"scan\":null"));
472    }
473
474    #[test]
475    fn escapes_json_strings() {
476        assert_eq!(quote_json("a\"b\\c\n\u{0}"), "\"a\\\"b\\\\c\\n\\u0000\"");
477    }
478}