Skip to main content

cargo_dist/
linkage.rs

1//! The Linkage Checker, which lets us detect what a binary dynamically links to (and why)
2
3use std::{
4    fs::{self, File},
5    io::{Cursor, Read},
6};
7
8use axoasset::SourceFile;
9use axoprocess::Cmd;
10use camino::Utf8PathBuf;
11use cargo_dist_schema::{
12    AssetInfo, BuildEnvironment, DistManifest, GlibcVersion, Library, Linkage, PackageManager,
13    TripleNameRef,
14};
15use comfy_table::{presets::UTF8_FULL, Table};
16use goblin::Object;
17use mach_object::{LoadCommand, OFile};
18use tracing::warn;
19
20use crate::{config::Config, errors::*, gather_work, Artifact, DistGraph};
21
22/// Arguments for `dist linkage` ([`do_linkage][])
23#[derive(Debug)]
24pub struct LinkageArgs {
25    /// Print human-readable output
26    pub print_output: bool,
27    /// Print output as JSON
28    pub print_json: bool,
29    /// Read linkage data from JSON rather than performing a live check
30    pub from_json: Option<String>,
31}
32
33/// Determinage dynamic linkage of built artifacts (impl of `dist linkage`)
34pub fn do_linkage(cfg: &Config, args: &LinkageArgs) -> DistResult<()> {
35    let manifest = if let Some(target) = args.from_json.clone() {
36        let file = SourceFile::load_local(target)?;
37        file.deserialize_json()?
38    } else {
39        let (dist, mut manifest) = gather_work(cfg)?;
40        compute_linkage_assuming_local_build(&dist, &mut manifest, cfg)?;
41        manifest
42    };
43
44    if args.print_output {
45        eprintln!("{}", LinkageDisplay(&manifest));
46    }
47    if args.print_json {
48        let string = serde_json::to_string_pretty(&manifest).unwrap();
49        println!("{string}");
50    }
51    Ok(())
52}
53
54/// Assuming someone just ran `dist build` on the current machine,
55/// compute the linkage by checking binaries in the temp to-be-zipped dirs.
56fn compute_linkage_assuming_local_build(
57    dist: &DistGraph,
58    manifest: &mut DistManifest,
59    cfg: &Config,
60) -> DistResult<()> {
61    let targets = &cfg.targets;
62    let artifacts = &dist.artifacts;
63    let dist_dir = &dist.dist_dir;
64
65    for target in targets {
66        let artifacts: Vec<Artifact> = artifacts
67            .clone()
68            .into_iter()
69            .filter(|r| r.target_triples.contains(target))
70            .collect();
71
72        if artifacts.is_empty() {
73            eprintln!("No matching artifact for target {target}");
74            continue;
75        }
76
77        for artifact in artifacts {
78            let path = Utf8PathBuf::from(&dist_dir).join(format!("{}-{target}", artifact.id));
79
80            for (bin_idx, binary_relpath) in artifact.required_binaries {
81                let bin = dist.binary(bin_idx);
82                let bin_path = path.join(binary_relpath);
83                if !bin_path.exists() {
84                    eprintln!("Binary {bin_path} missing; skipping check");
85                } else {
86                    let linkage = determine_linkage(&bin_path, target);
87                    manifest.assets.insert(
88                        bin.id.clone(),
89                        AssetInfo {
90                            id: bin.id.clone(),
91                            name: bin.name.clone(),
92                            system: dist.system_id.clone(),
93                            linkage: Some(linkage),
94                            target_triples: vec![target.clone()],
95                        },
96                    );
97                }
98            }
99        }
100    }
101
102    Ok(())
103}
104
105/// Formatter for a DistManifest that prints the linkage human-readably
106pub struct LinkageDisplay<'a>(pub &'a DistManifest);
107
108impl std::fmt::Display for LinkageDisplay<'_> {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        for asset in self.0.assets.values() {
111            let Some(linkage) = &asset.linkage else {
112                continue;
113            };
114            let name = &asset.name;
115            let targets = asset.target_triples.join(", ");
116            write!(f, "{name}")?;
117            if !targets.is_empty() {
118                write!(f, " ({targets})")?;
119            }
120            writeln!(f, "\n")?;
121            format_linkage_table(f, linkage)?;
122        }
123        Ok(())
124    }
125}
126
127/// Formatted human-readable output
128fn format_linkage_table(f: &mut std::fmt::Formatter<'_>, linkage: &Linkage) -> std::fmt::Result {
129    let mut table = Table::new();
130    table
131        .load_preset(UTF8_FULL)
132        .set_header(vec!["Category", "Libraries"])
133        .add_row(vec![
134            "System",
135            linkage
136                .system
137                .clone()
138                .into_iter()
139                .map(|l| l.to_string())
140                .collect::<Vec<String>>()
141                .join("\n")
142                .as_str(),
143        ])
144        .add_row(vec![
145            "Homebrew",
146            linkage
147                .homebrew
148                .clone()
149                .into_iter()
150                .map(|l| l.to_string())
151                .collect::<Vec<String>>()
152                .join("\n")
153                .as_str(),
154        ])
155        .add_row(vec![
156            "Public (unmanaged)",
157            linkage
158                .public_unmanaged
159                .clone()
160                .into_iter()
161                .map(|l| l.path)
162                .collect::<Vec<String>>()
163                .join("\n")
164                .as_str(),
165        ])
166        .add_row(vec![
167            "Frameworks",
168            linkage
169                .frameworks
170                .clone()
171                .into_iter()
172                .map(|l| l.path)
173                .collect::<Vec<String>>()
174                .join("\n")
175                .as_str(),
176        ])
177        .add_row(vec![
178            "Other",
179            linkage
180                .other
181                .clone()
182                .into_iter()
183                .map(|l| l.to_string())
184                .collect::<Vec<String>>()
185                .join("\n")
186                .as_str(),
187        ]);
188    write!(f, "{table}")
189}
190
191/// Create a homebrew library for the given path
192pub fn library_from_homebrew(library: String) -> Library {
193    // Doesn't currently support Homebrew installations in
194    // non-default locations
195    let brew_prefix = if library.starts_with("/opt/homebrew/opt/") {
196        Some("/opt/homebrew/opt/")
197    } else if library.starts_with("/usr/local/opt/") {
198        Some("/usr/local/opt/")
199    } else {
200        None
201    };
202
203    if let Some(prefix) = brew_prefix {
204        let cloned = library.clone();
205        let stripped = cloned.strip_prefix(prefix).unwrap();
206        let mut package = stripped.split('/').next().unwrap().to_owned();
207
208        // The path alone isn't enough to determine the tap the formula
209        // came from. If the install receipt exists, we can use it to
210        // get the name of the source tap.
211        let receipt = Utf8PathBuf::from(&prefix)
212            .join(&package)
213            .join("INSTALL_RECEIPT.json");
214
215        // If the receipt doesn't exist or can't be loaded, that's not an
216        // error; we can fall back to the package basename we parsed out
217        // of the path.
218        if receipt.exists() {
219            let _ = SourceFile::load_local(&receipt)
220                .and_then(|file| file.deserialize_json())
221                .map(|parsed: serde_json::Value| {
222                    if let Some(tap) = parsed["source"]["tap"].as_str() {
223                        if tap != "homebrew/core" {
224                            package = format!("{tap}/{package}");
225                        }
226                    }
227                });
228        }
229
230        Library {
231            path: library,
232            source: Some(package.to_owned()),
233            package_manager: Some(PackageManager::Homebrew),
234        }
235    } else {
236        Library {
237            path: library,
238            source: None,
239            package_manager: None,
240        }
241    }
242}
243
244/// Create an apt library for the given path
245pub fn library_from_apt(library: String) -> DistResult<Library> {
246    // We can't get this information on other OSs
247    if std::env::consts::OS != "linux" {
248        return Ok(Library {
249            path: library,
250            source: None,
251            package_manager: None,
252        });
253    }
254
255    let process = Cmd::new("dpkg", "get linkage info from dpkg")
256        .arg("--search")
257        .arg(&library)
258        .output();
259    match process {
260        Ok(output) => {
261            let output = String::from_utf8(output.stdout)?;
262
263            let package = output.split(':').next().unwrap();
264            let source = if package.is_empty() {
265                None
266            } else {
267                Some(package.to_owned())
268            };
269            let package_manager = if source.is_some() {
270                Some(PackageManager::Apt)
271            } else {
272                None
273            };
274
275            Ok(Library {
276                path: library,
277                source,
278                package_manager,
279            })
280        }
281        // Couldn't find a package for this file
282        Err(_) => Ok(Library {
283            path: library,
284            source: None,
285            package_manager: None,
286        }),
287    }
288}
289
290fn do_otool(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
291    let mut libraries = vec![];
292
293    let mut f = File::open(path)?;
294    let mut buf = vec![];
295    let size = f.read_to_end(&mut buf).unwrap();
296    let mut cur = Cursor::new(&buf[..size]);
297    if let Ok(OFile::MachFile {
298        header: _,
299        commands,
300    }) = OFile::parse(&mut cur)
301    {
302        let commands = commands
303            .iter()
304            .map(|load| load.command())
305            .cloned()
306            .collect::<Vec<LoadCommand>>();
307
308        for command in commands {
309            match command {
310                LoadCommand::IdDyLib(ref dylib)
311                | LoadCommand::LoadDyLib(ref dylib)
312                | LoadCommand::LoadWeakDyLib(ref dylib)
313                | LoadCommand::ReexportDyLib(ref dylib)
314                | LoadCommand::LoadUpwardDylib(ref dylib)
315                | LoadCommand::LazyLoadDylib(ref dylib) => {
316                    libraries.push(dylib.name.to_string());
317                }
318                _ => {}
319            }
320        }
321    }
322
323    Ok(libraries)
324}
325
326fn do_ldd(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
327    let mut libraries = vec![];
328
329    // We ignore the status here because for whatever reason arm64 glibc ldd can decide
330    // to return non-zero status on binaries with no dynamic linkage (e.g. musl-static).
331    // This was observed both in arm64 ubuntu and asahi (both glibc ldd).
332    // x64 glibc ldd is perfectly fine with this and returns 0, so... *shrug* compilers!
333    let output = Cmd::new("ldd", "get linkage info from ldd")
334        .arg(path)
335        .check(false)
336        .output()?;
337
338    let result = String::from_utf8_lossy(&output.stdout).to_string();
339    let lines = result.trim_end().split('\n');
340
341    for line in lines {
342        let line = line.trim();
343
344        // There's no dynamic linkage at all; we can safely break,
345        // there will be nothing useful to us here.
346        if line.starts_with("not a dynamic executable") || line.starts_with("statically linked") {
347            break;
348        }
349
350        // Not a library that actually concerns us
351        if line.starts_with("linux-vdso") {
352            continue;
353        }
354
355        // Format: libname.so.1 => /path/to/libname.so.1 (address)
356        if let Some(path) = line.split(" => ").nth(1) {
357            // This may be a symlink rather than the actual underlying library;
358            // we resolve the symlink here so that we return the real paths,
359            // making it easier to map them to their packages later.
360            let lib = (path.split(' ').next().unwrap()).to_owned();
361            let realpath = fs::canonicalize(&lib)?;
362            libraries.push(realpath.to_string_lossy().to_string());
363        } else {
364            continue;
365        }
366    }
367
368    Ok(libraries)
369}
370
371fn do_pe(path: &Utf8PathBuf) -> DistResult<Vec<String>> {
372    let buf = std::fs::read(path)?;
373    match Object::parse(&buf)? {
374        Object::PE(pe) => Ok(pe.libraries.into_iter().map(|s| s.to_owned()).collect()),
375        // Static libraries link against nothing
376        Object::Archive(_) => Ok(vec![]),
377        _ => Err(DistError::LinkageCheckUnsupportedBinary),
378    }
379}
380
381/// Get the linkage for a single binary
382///
383/// If linkage fails for any reason we warn and return the default empty linkage
384pub fn determine_linkage(path: &Utf8PathBuf, target: &TripleNameRef) -> Linkage {
385    match try_determine_linkage(path, target) {
386        Ok(linkage) => linkage,
387        Err(e) => {
388            warn!("Skipping linkage for {path}:\n{:?}", miette::Report::new(e));
389            Linkage::default()
390        }
391    }
392}
393
394/// Get the linkage for a single binary
395fn try_determine_linkage(path: &Utf8PathBuf, target: &TripleNameRef) -> DistResult<Linkage> {
396    let libraries = if target.is_darwin() {
397        do_otool(path)?
398    } else if target.is_linux() {
399        // Currently can only be run on Linux
400        if std::env::consts::OS != "linux" {
401            return Err(DistError::LinkageCheckInvalidOS {
402                host: std::env::consts::OS.to_owned(),
403                target: target.to_owned(),
404            });
405        }
406        do_ldd(path)?
407    } else if target.is_windows() {
408        do_pe(path)?
409    } else {
410        return Err(DistError::LinkageCheckUnsupportedBinary);
411    };
412
413    let mut linkage = Linkage {
414        system: Default::default(),
415        homebrew: Default::default(),
416        public_unmanaged: Default::default(),
417        frameworks: Default::default(),
418        other: Default::default(),
419    };
420    for library in libraries {
421        if library.starts_with("/opt/homebrew") {
422            linkage
423                .homebrew
424                .insert(library_from_homebrew(library.clone()));
425        } else if library.starts_with("/usr/lib") || library.starts_with("/lib") {
426            linkage.system.insert(library_from_apt(library.clone())?);
427        } else if library.starts_with("/System/Library/Frameworks")
428            || library.starts_with("/Library/Frameworks")
429        {
430            linkage.frameworks.insert(Library::new(library.clone()));
431        } else if library.starts_with("/usr/local") {
432            if std::fs::canonicalize(&library)?.starts_with("/usr/local/Cellar") {
433                linkage
434                    .homebrew
435                    .insert(library_from_homebrew(library.clone()));
436            } else {
437                linkage
438                    .public_unmanaged
439                    .insert(Library::new(library.clone()));
440            }
441        } else {
442            linkage.other.insert(library_from_apt(library.clone())?);
443        }
444    }
445
446    Ok(linkage)
447}
448
449/// Determine the build environment on the current host
450/// This should be done local to the builder!
451pub fn determine_build_environment(target: &TripleNameRef) -> BuildEnvironment {
452    if target.is_darwin() {
453        determine_macos_build_environment().unwrap_or(BuildEnvironment::Indeterminate)
454    } else if target.is_linux() {
455        determine_linux_build_environment().unwrap_or(BuildEnvironment::Indeterminate)
456    } else if target.is_windows() {
457        BuildEnvironment::Windows
458    } else {
459        BuildEnvironment::Indeterminate
460    }
461}
462
463fn determine_linux_build_environment() -> DistResult<BuildEnvironment> {
464    // If we're running this cross-host somehow, we should return an
465    // indeterminate result here
466    if std::env::consts::OS != "linux" {
467        return Ok(BuildEnvironment::Indeterminate);
468    }
469
470    let mut cmd = Cmd::new("ldd", "determine glibc version");
471    cmd.arg("--version");
472    let output = cmd.output()?;
473    let output_str = String::from_utf8(output.stdout)?;
474    let first_line = output_str.lines().next().unwrap_or(&output_str).trim_end();
475    // Running on a system without glibc at all
476    let glibc_version = if !first_line.contains("GNU libc") && !first_line.contains("GLIBC") {
477        None
478    } else {
479        // Formats observed in the wild:
480        // ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35 (Ubuntu 22.04)
481        // ldd (Debian GLIBC 2.36-9+deb12u7) 2.36 (Debian)
482        // ldd (GNU libc) 2.39 (Fedora)
483        first_line
484            .split(' ')
485            .next_back()
486            .and_then(|s| s.split_once('.').map(glibc_from_tuple))
487            .transpose()?
488    };
489
490    Ok(BuildEnvironment::Linux { glibc_version })
491}
492
493fn glibc_from_tuple(versions: (&str, &str)) -> Result<GlibcVersion, DistError> {
494    let major = versions.0.parse::<u64>()?;
495    let series = versions.1.parse::<u64>()?;
496
497    Ok(GlibcVersion { major, series })
498}
499
500fn determine_macos_build_environment() -> DistResult<BuildEnvironment> {
501    // If we're running this cross-host somehow, we should return an
502    // indeterminate result here
503    if std::env::consts::OS != "macos" {
504        return Ok(BuildEnvironment::Indeterminate);
505    }
506
507    let mut cmd = Cmd::new("sw_vers", "determine OS version");
508    cmd.arg("-productVersion");
509    let output = cmd.output()?;
510    let os_version = String::from_utf8(output.stdout)?.trim_end().to_owned();
511
512    Ok(BuildEnvironment::MacOS { os_version })
513}