diff --git a/Cargo.lock b/Cargo.lock index 181e1d0e81..05e0cc89e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,6 +942,7 @@ dependencies = [ "infra_utils", "itertools", "markdown", + "regex", "semver", "serde", "serde_json", diff --git a/crates/infra/cli/Cargo.toml b/crates/infra/cli/Cargo.toml index 188c220d64..747e110f1d 100644 --- a/crates/infra/cli/Cargo.toml +++ b/crates/infra/cli/Cargo.toml @@ -14,6 +14,7 @@ clap_complete = { workspace = true } infra_utils = { workspace = true } itertools = { workspace = true } markdown = { workspace = true } +regex = { workspace = true } semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -22,4 +23,3 @@ toml = { workspace = true } [lints] workspace = true - diff --git a/crates/infra/cli/src/toolchains/napi/cli.rs b/crates/infra/cli/src/toolchains/napi/cli.rs index 6f273813cf..3a9453a75b 100644 --- a/crates/infra/cli/src/toolchains/napi/cli.rs +++ b/crates/infra/cli/src/toolchains/napi/cli.rs @@ -3,10 +3,9 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use infra_utils::commands::Command; use infra_utils::paths::PathExtensions; -use semver::Version; +use crate::toolchains::napi::glibc; use crate::toolchains::napi::resolver::NapiResolver; -use crate::toolchains::napi::NapiConfig; pub enum BuildTarget { Debug, @@ -64,7 +63,7 @@ impl NapiCli { command.run()?; #[cfg(target_env = "gnu")] - ensure_correct_glibc_for_vscode(resolver, output_dir, target)?; + glibc::ensure_correct_glibc_for_vscode(resolver, output_dir, target)?; let mut source_files = vec![]; let mut node_binary = None; @@ -119,87 +118,3 @@ impl NapiCli { .run(); } } - -#[cfg(target_env = "gnu")] -/// On a GNU host, cross-compile the native addon to only target the oldest supported GLIBC version by VS Code. -/// -/// By default, compiling on the host targets the host's GLIBC version, which is usually newer. -/// To prevent that, we need to explicitly cross-compile for the desired GLIBC version. -/// -/// This is necessary to retain extension compatibility with as many systems as possible: -/// . -fn ensure_correct_glibc_for_vscode( - resolver: &NapiResolver, - output_dir: &Path, - target: &BuildTarget, -) -> Result<()> { - let target_triple = match target { - BuildTarget::ReleaseTarget(target) if target.ends_with("-linux-gnu") => target, - _ => return Ok(()), - }; - - let min_glibc = NapiConfig::target_glibc(resolver)?; - - let output_artifact = match target_triple.split('-').next() { - Some("x86_64") => "index.linux-x64-gnu.node", - Some("aarch64") => "index.linux-arm64-gnu.node", - _ => bail!("Unsupported target {target_triple} for `cargo-zigbuild`."), - }; - let output_artifact_path = output_dir.join(output_artifact); - - let is_host_compiling = target_triple.starts_with(std::env::consts::ARCH); - if is_host_compiling { - let rust_crate_name = resolver.rust_crate_name(); - - // Don't clobber the existing output directory. - let zigbuild_output = tempfile::tempdir()?; - - // Until `@napi-rs/cli` v3 is released with a fixed `zig` support and a new `--cross-compile`, - // we explicitly compile ourselves again with `cargo-zigbuild` to target the desired GLIBC - // version, without having to separately compile on the target platform (e.g. via Docker). - Command::new("cargo") - .arg("zigbuild") - .property("-p", rust_crate_name) - .flag("--release") - .property("--target", format!("{target_triple}.{min_glibc}")) - .property("--target-dir", zigbuild_output.path().to_string_lossy()) - .run()?; - - // Overwrite the existing artifact with the cross-compiled one. - let zigbuild_output = zigbuild_output.into_path(); - let artifact_path = zigbuild_output - .join(target_triple) - .join("release") - .join(format!("lib{rust_crate_name}.so")); - - std::fs::copy(artifact_path, &output_artifact_path)?; - } else { - // Already cross-compiled with the correct GLIBC version. Just verify for sanity. - } - - // Verify that the artifact is compatible with the desired GLIBC version. - let library_glibc_version = Command::new("scripts/min_glibc_version.sh") - .arg(output_artifact_path.to_string_lossy()) - .evaluate()?; - - if lenient_semver(&library_glibc_version)? > lenient_semver(&min_glibc)? { - bail!("The compiled artifact {output_artifact_path:?} targets GLIBC {library_glibc_version}, which is higher than the minimum specified version {min_glibc}."); - } - - Ok(()) -} - -/// Like `Version::parse`, but allows for a missing patch version, defaulting to `0`. -fn lenient_semver(value: &str) -> Result { - let components: Vec<_> = value - .trim() - .split('.') - .map(|part| part.parse::().map_err(Into::into)) - .collect::>()?; - - match &components[..] { - [major, minor] => Ok(Version::new(*major, *minor, 0)), - [major, minor, patch] => Ok(Version::new(*major, *minor, *patch)), - _ => bail!("Invalid semver version components: {components:?}"), - } -} diff --git a/crates/infra/cli/src/toolchains/napi/config.rs b/crates/infra/cli/src/toolchains/napi/config.rs index 52ee458165..654522220c 100644 --- a/crates/infra/cli/src/toolchains/napi/config.rs +++ b/crates/infra/cli/src/toolchains/napi/config.rs @@ -6,6 +6,7 @@ use infra_utils::paths::PathExtensions; use semver::Version; use serde::Deserialize; +use crate::toolchains::napi::glibc::ZigGlibcVersion; use crate::toolchains::napi::resolver::NapiResolver; #[derive(Deserialize)] @@ -31,7 +32,7 @@ struct NapiTriples { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SlangMetadata { - target_glibc: String, + target_glibc: ZigGlibcVersion, } pub struct NapiConfig; @@ -70,7 +71,7 @@ impl NapiConfig { } /// Returns the target glibc version for the GNU targets. - pub fn target_glibc(resolver: &NapiResolver) -> Result { + pub fn target_glibc(resolver: &NapiResolver) -> Result { let package = load_package(&resolver.main_package_dir())?; Ok(package diff --git a/crates/infra/cli/src/toolchains/napi/glibc.rs b/crates/infra/cli/src/toolchains/napi/glibc.rs new file mode 100644 index 0000000000..1279ad6c25 --- /dev/null +++ b/crates/infra/cli/src/toolchains/napi/glibc.rs @@ -0,0 +1,211 @@ +use std::path::Path; + +use anyhow::{bail, Result}; +use infra_utils::commands::Command; +use semver::Version; +use serde::de; + +use crate::toolchains::napi::{BuildTarget, NapiConfig, NapiResolver}; + +/// A GLIBC version (e.g. "2.16") supported by Zig(build) for cross-compilation. +/// +/// See the list of supported versions at . +#[derive(Clone, Copy, Debug)] +pub struct ZigGlibcVersion { + minor: u8, +} + +impl<'de> serde::Deserialize<'de> for ZigGlibcVersion { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + + let components: Vec<_> = value + .trim() + .split('.') + .map(|part| { + part.parse::() + .map_err(|_| de::Error::invalid_type(de::Unexpected::Str(part), &"u8")) + }) + .collect::>()?; + + match &components[..] { + [2, minor] if (16..=38).contains(minor) => Ok(ZigGlibcVersion { minor: *minor }), + _ => Err(de::Error::invalid_value( + de::Unexpected::Str(&value), + &"a valid Zig GLIBC version (2.16 - 2.38)", + )), + } + } +} + +impl std::fmt::Display for ZigGlibcVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "2.{}", self.minor) + } +} + +impl From for Version { + fn from(version: ZigGlibcVersion) -> Self { + Version::new(2, u64::from(version.minor), 0) + } +} + +impl PartialOrd for ZigGlibcVersion { + fn partial_cmp(&self, other: &Version) -> Option { + Some(Version::from(*self).cmp(other)) + } +} + +impl PartialEq for ZigGlibcVersion { + fn eq(&self, other: &Version) -> bool { + Version::from(*self).eq(other) + } +} + +#[cfg(target_env = "gnu")] +/// On a GNU host, cross-compile the native addon to only target the oldest supported GLIBC version by VS Code. +/// +/// By default, compiling on the host targets the host's GLIBC version, which is usually newer. +/// To prevent that, we need to explicitly cross-compile for the desired GLIBC version. +/// +/// This is necessary to retain extension compatibility with as many systems as possible: +/// . +pub fn ensure_correct_glibc_for_vscode( + resolver: &NapiResolver, + output_dir: &Path, + target: &BuildTarget, +) -> Result<()> { + let target_triple = match target { + BuildTarget::ReleaseTarget(target) if target.ends_with("-linux-gnu") => target, + _ => return Ok(()), + }; + + let target_glibc = NapiConfig::target_glibc(resolver)?; + + let output_artifact = match target_triple.split('-').next() { + Some("x86_64") => "index.linux-x64-gnu.node", + Some("aarch64") => "index.linux-arm64-gnu.node", + _ => bail!("Unsupported target {target_triple} for `cargo-zigbuild`."), + }; + let output_artifact_path = output_dir.join(output_artifact); + + let is_host_compiling = target_triple.starts_with(std::env::consts::ARCH); + if is_host_compiling { + let rust_crate_name = resolver.rust_crate_name(); + + // Don't clobber the existing output directory. + let zigbuild_output = tempfile::tempdir()?; + + // Until `@napi-rs/cli` v3 is released with a fixed `zig` support and a new `--cross-compile`, + // we explicitly compile ourselves again with `cargo-zigbuild` to target the desired GLIBC + // version, without having to separately compile on the target platform (e.g. via Docker). + Command::new("cargo") + .arg("zigbuild") + .property("-p", rust_crate_name) + .flag("--release") + .property("--target", format!("{target_triple}.{target_glibc}")) + .property("--target-dir", zigbuild_output.path().to_string_lossy()) + .run()?; + + // Overwrite the existing artifact with the cross-compiled one. + let zigbuild_output = zigbuild_output.into_path(); + let artifact_path = zigbuild_output + .join(target_triple) + .join("release") + .join(format!("lib{rust_crate_name}.so")); + + std::fs::copy(artifact_path, &output_artifact_path)?; + } else { + // Already cross-compiled with the correct GLIBC version. Just verify for sanity. + } + + // Verify that the artifact is compatible with the desired GLIBC version. + let library_glibc_version = + fetch_min_supported_glibc_version(&output_artifact_path.to_string_lossy())?; + + if target_glibc < library_glibc_version { + bail!("The compiled artifact {output_artifact_path:?} targets GLIBC {library_glibc_version}, which is higher than the minimum specified version {target_glibc}."); + } + + Ok(()) +} + +#[cfg(target_env = "gnu")] +fn fetch_min_supported_glibc_version(lib_path: &str) -> Result { + // # Note: `ldd` does not work reliably when inspecting cross-compiled ARM binaries on x86_64 + let output = Command::new("objdump") + .flag("-T") + .arg(lib_path) + .evaluate()?; + + Ok(extract_min_supported_glibc_from_objdump(&output)) +} + +fn extract_min_supported_glibc_from_objdump(objdump_output: &str) -> Version { + // Find and capture "2.3.5" (3rd component optional) from "(GLIBC_2.3.5)" substrings + let regexp = + regex::Regex::new(r"\(GLIBC_(?[0-9])+\.(?[0-9]+)(.(?[0-9]+))?.*\)") + .unwrap(); + regexp + .captures_iter(objdump_output) + .map(|capture| { + let major = capture.name("major").unwrap().as_str(); + let minor = capture.name("minor").unwrap().as_str(); + let patch = capture.name("patch").map_or("0", |x| x.as_str()); + let [major, minor, patch] = [major, minor, patch].map(|x| x.parse::().unwrap()); + + semver::Version::new(major, minor, patch) + }) + .max() + .expect("at least one version to be matched") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_min_supported_glibc_from_objdump() { + const OUTPUT: &str = r" + crates/solidity/outputs/npm/package/target/napi/x86_64-unknown-linux-gnu/index.linux-x64-gnu.node: file format elf64-x86-64 + + DYNAMIC SYMBOL TABLE: + 0000000000000000 w D *UND* 0000000000000000 Base __gmon_start__ + 0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.3) __tls_get_addr + 0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.2.5) free + 0000000000000000 D *UND* 0000000000000000 Base napi_delete_reference + 0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.14) memcpy + 0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.2.5) malloc + "; + + let result = extract_min_supported_glibc_from_objdump(OUTPUT); + assert_eq!(result, Version::new(2, 14, 0)); + } + + #[test] + fn test_deserialize_zig_glibc_version() -> Result<()> { + let version = serde_json::from_str::(r#""2.16""#)?; + assert_eq!(version.minor, 16); + + let version = serde_json::from_str::(r#""2.38""#)?; + assert_eq!(version.minor, 38); + + let version = serde_json::from_str::(r#""2.39""#); + assert!(version.is_err()); + + let version = serde_json::from_str::(r#""3.20""#); + assert!(version.is_err()); + + // Zig only supports versions without the patch component. + let version = serde_json::from_str::(r#""2.2.5""#); + assert!(version.is_err()); + + Ok(()) + } + + #[test] + fn test_display() { + let version = ZigGlibcVersion { minor: 16 }; + assert_eq!(format!("{version}"), "2.16"); + } +} diff --git a/crates/infra/cli/src/toolchains/napi/mod.rs b/crates/infra/cli/src/toolchains/napi/mod.rs index 099d1bc3a5..f5197f0407 100644 --- a/crates/infra/cli/src/toolchains/napi/mod.rs +++ b/crates/infra/cli/src/toolchains/napi/mod.rs @@ -1,6 +1,7 @@ mod cli; mod compiler; mod config; +mod glibc; mod resolver; pub use cli::*; diff --git a/scripts/min_glibc_version.sh b/scripts/min_glibc_version.sh deleted file mode 100755 index 54fb710419..0000000000 --- a/scripts/min_glibc_version.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -# This scripts determines the minimum GLIBC version required for a dynamic library. - -set -e -o pipefail - -# List the dynamic symbols of the library -# Note: `ldd` does not work reliably when inspecting cross-compiled ARM binaries on x86_64 -objdump -T "$1" \ - | - # Select only the GLIBC symbols - grep -o '(GLIBC_[0-9][0-9]*.*)' \ - | - # Version-sort the unique symbols, descending - sort -r -V | uniq \ - | - # Print out just the version number - sed -n 's/.*GLIBC_\([0-9.]*\)).*/\1/p' \ - | - # Finally, print out the highest version - head -n 1