From b4e1fa4e0184e55cff7818062ed500004e007c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Misty=20De=20M=C3=A9o?= Date: Thu, 14 Sep 2023 12:26:20 -0700 Subject: [PATCH] feat: add a linkage checker subcommand --- cargo-dist/src/cli.rs | 14 ++++ cargo-dist/src/errors.rs | 8 ++ cargo-dist/src/lib.rs | 169 +++++++++++++++++++++++++++++++++++++++ cargo-dist/src/main.rs | 24 +++++- cargo-dist/src/tasks.rs | 2 +- 5 files changed, 215 insertions(+), 2 deletions(-) diff --git a/cargo-dist/src/cli.rs b/cargo-dist/src/cli.rs index 02bb7ef27..ce612ba54 100644 --- a/cargo-dist/src/cli.rs +++ b/cargo-dist/src/cli.rs @@ -127,6 +127,9 @@ pub enum Commands { /// Generate CI scripts for orchestrating cargo-dist (deprecated in favour of generate) #[clap(disable_version_flag = true)] GenerateCi(GenerateCiArgs), + /// Report on the dynamic libraries used by the built artifacts. + #[clap(disable_version_flag = true)] + Linkage(LinkageArgs), /// Generate the final build manifest without running any builds. /// /// This command is designed to match the exact behaviour of @@ -277,6 +280,17 @@ pub struct GenerateCiArgs { #[clap(default_value_t = false)] pub check: bool, } +#[derive(Args, Clone, Debug)] +pub struct LinkageArgs { + /// Print human-readable output + #[clap(long)] + #[clap(default_value_t = false)] + pub print_output: bool, + /// Print output as JSON + #[clap(long)] + #[clap(default_value_t = false)] + pub print_json: bool, +} #[derive(Args, Clone, Debug)] pub struct HelpMarkdownArgs {} diff --git a/cargo-dist/src/errors.rs b/cargo-dist/src/errors.rs index 394f740ba..f938d5cd9 100644 --- a/cargo-dist/src/errors.rs +++ b/cargo-dist/src/errors.rs @@ -229,6 +229,14 @@ pub enum DistError { /// The missing keys keys: &'static [&'static str], }, + /// Linkage check can't be run for this combination of OS and target + #[error("unable to run linkage check for {target} on {host}")] + LinkageCheckInvalidOS { + /// The OS the check was run on + host: String, + /// The OS being checked + target: String, + }, } impl From for DistError { diff --git a/cargo-dist/src/lib.rs b/cargo-dist/src/lib.rs index fa6444906..672575531 100644 --- a/cargo-dist/src/lib.rs +++ b/cargo-dist/src/lib.rs @@ -27,6 +27,7 @@ use config::{ ArtifactMode, ChecksumStyle, CompressionImpl, Config, DirtyMode, GenerateMode, ZipStyle, }; use semver::Version; +use serde::Serialize; use tracing::{info, warn}; use errors::*; @@ -617,6 +618,15 @@ pub struct GenerateArgs { pub modes: Vec, } +/// Arguments for `cargo dist linkage` ([`do_linkage][]) +#[derive(Debug)] +pub struct LinkageArgs { + /// Print human-readable output + pub print_output: bool, + /// Print output as JSON + pub print_json: bool, +} + fn do_generate_preflight_checks(dist: &DistGraph) -> Result<()> { // Enforce cargo-dist-version, unless... // @@ -709,6 +719,165 @@ pub fn run_generate(dist: &DistGraph, args: &GenerateArgs) -> Result<()> { Ok(()) } +/// Determinage dynamic linkage of built artifacts (impl of `cargo dist linkage`) +pub fn do_linkage(cfg: &Config, args: &LinkageArgs) -> Result<()> { + let dist = gather_work(cfg)?; + + let mut reports = vec![]; + + for target in cfg.targets.clone() { + let releases: Vec = dist + .releases + .clone() + .into_iter() + .filter(|r| r.targets.contains(&target)) + .collect(); + + if releases.is_empty() { + eprintln!("No matching release for target {target}"); + continue; + } + + for release in releases { + let path = Utf8PathBuf::from(&dist.dist_dir).join(format!("{}-{target}", release.id)); + + for (_, binary) in release.bins { + let bin_path = path.join(binary); + if !bin_path.exists() { + eprintln!("Binary {bin_path} missing; skipping check"); + } else { + reports.push(determine_linkage(&bin_path, &target)?); + } + } + } + } + + if args.print_output { + for report in &reports { + eprintln!("{}", report.report()); + } + } + if args.print_json { + let j = serde_json::to_string(&reports).unwrap(); + println!("{}", j); + } + + Ok(()) +} + +/// Information about dynamic libraries used by a binary +#[derive(Debug, Serialize)] +pub struct Linkage { + /// The filename of the binary + pub binary: String, + /// The target triple for which the binary was built + pub target: String, + /// Libraries included with the operating system + pub system: Vec, + /// Libraries provided by the Homebrew package manager + pub homebrew: Vec, + /// Public libraries not provided by the system and not managed by any package manager + pub public_unmanaged: Vec, + /// Libraries which don't fall into any other categories + pub other: Vec, + /// Frameworks, only used on macOS + pub frameworks: Vec, +} + +impl Linkage { + /// Formatted human-readable output + pub fn report(&self) -> String { + let s = format!( + r#"{} ({}): + +System: {} +Homebrew: {} +Public (unmanaged): {} +Frameworks: {} +Other: {}"#, + self.binary, + self.target, + self.system.join(" "), + self.homebrew.join(" "), + self.public_unmanaged.join(" "), + self.frameworks.join(" "), + self.other.join(" "), + ); + + s.to_owned() + } +} + +fn do_otool(path: &Utf8PathBuf) -> DistResult> { + let output = Command::new("otool") + .arg("-XL") + .arg(path) + .output() + .expect("otool failed to run"); + + let mut result = String::from_utf8_lossy(&output.stdout).to_string(); + if let Some(stripped) = result.strip_suffix('\n') { + result = stripped.to_owned(); + } + let libraries: Vec = result + .split('\n') + // Lines are formatted like: + // "/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)" + .map(|line| { + line.trim_start() + .split('(') + .next() + .unwrap() + .trim_end() + .to_string() + }) + .collect(); + + Ok(libraries) +} + +fn determine_linkage(path: &Utf8PathBuf, target: &str) -> DistResult { + // Update this as more OSs are supported + if std::env::consts::OS != "macos" { + return Err(DistError::LinkageCheckInvalidOS { + host: std::env::consts::OS.to_owned(), + target: target.to_owned(), + }); + } + + let libraries = do_otool(path)?; + let mut linkage = Linkage { + binary: path.file_name().unwrap().to_owned(), + target: target.to_owned(), + system: vec![], + homebrew: vec![], + public_unmanaged: vec![], + frameworks: vec![], + other: vec![], + }; + for library in libraries { + if library.starts_with("/opt/homebrew") { + linkage.homebrew.push(library.clone()); + } else if library.starts_with("/usr/lib") || library.starts_with("/lib") { + linkage.system.push(library.clone()); + } else if library.starts_with("/System/Library/Frameworks") + || library.starts_with("/Library/Frameworks") + { + linkage.frameworks.push(library.clone()); + } else if library.starts_with("/usr/local") { + if std::fs::canonicalize(&library)?.starts_with("/usr/local/Cellar") { + linkage.homebrew.push(library.clone()); + } else { + linkage.public_unmanaged.push(library.clone()); + } + } else { + linkage.other.push(library.clone()); + } + } + + Ok(linkage) +} + /// Run any necessary integrity checks for "primary" commands like build/plan /// /// (This is currently equivalent to `cargo dist generate --check`) diff --git a/cargo-dist/src/main.rs b/cargo-dist/src/main.rs index 16aba48d8..33a64ecda 100644 --- a/cargo-dist/src/main.rs +++ b/cargo-dist/src/main.rs @@ -15,7 +15,7 @@ use cli::{ use console::Term; use miette::IntoDiagnostic; -use crate::cli::{BuildArgs, GenerateArgs, GenerateCiArgs, InitArgs}; +use crate::cli::{BuildArgs, GenerateArgs, GenerateCiArgs, InitArgs, LinkageArgs}; mod cli; @@ -33,6 +33,7 @@ fn real_main(cli: &axocli::CliApp) -> Result<(), miette::Report> { Commands::Init(args) => cmd_init(config, args), Commands::Generate(args) => cmd_generate(config, args), Commands::GenerateCi(args) => cmd_generate_ci(config, args), + Commands::Linkage(args) => cmd_linkage(config, args), Commands::Manifest(args) => cmd_manifest(config, args), Commands::Plan(args) => cmd_plan(config, args), Commands::HelpMarkdown(args) => cmd_help_md(config, args), @@ -244,6 +245,27 @@ fn cmd_generate(cli: &Cli, args: &GenerateArgs) -> Result<(), miette::Report> { do_generate(&config, &args) } +fn cmd_linkage(cli: &Cli, args: &LinkageArgs) -> Result<(), miette::Report> { + let config = cargo_dist::config::Config { + needs_coherent_announcement_tag: false, + artifact_mode: cargo_dist::config::ArtifactMode::All, + no_local_paths: cli.no_local_paths, + allow_all_dirty: cli.allow_dirty, + targets: cli.target.clone(), + ci: cli.ci.iter().map(|ci| ci.to_lib()).collect(), + installers: cli.installer.iter().map(|ins| ins.to_lib()).collect(), + announcement_tag: cli.tag.clone(), + }; + let mut options = cargo_dist::LinkageArgs { + print_output: args.print_output, + print_json: args.print_json, + }; + if !args.print_output && !args.print_json { + options.print_output = true; + } + do_linkage(&config, &options) +} + fn cmd_generate_ci(cli: &Cli, args: &GenerateCiArgs) -> Result<(), miette::Report> { cmd_generate( cli, diff --git a/cargo-dist/src/tasks.rs b/cargo-dist/src/tasks.rs index a7ec40519..550577a2d 100644 --- a/cargo-dist/src/tasks.rs +++ b/cargo-dist/src/tasks.rs @@ -458,7 +458,7 @@ pub struct Symbols { } /// A logical release of an application that artifacts are grouped under -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Release { /// The name of the app pub app_name: String,