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#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct ScanOptions {
21 pub distro: Option<String>,
22 pub kernel: Option<String>,
23 pub strict: bool,
25 pub verbose: bool,
27}
28
29pub 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
60pub 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
89pub 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}