diff --git a/Cargo.lock b/Cargo.lock index 1d1c2496d31..01eeab84047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +dependencies = [ + "objc2", +] + [[package]] name = "bstr" version = "1.12.0" @@ -327,6 +336,7 @@ dependencies = [ "anyhow", "base64", "blake3", + "block2", "cargo-credential", "cargo-credential-libsecret", "cargo-credential-macos-keychain", @@ -362,6 +372,7 @@ dependencies = [ "libc", "libgit2-sys", "memchr", + "objc2", "opener", "openssl", "os_info", @@ -3065,6 +3076,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "561f357ba7f3a2a61563a186a163d0a3a5247e1089524a3981d49adb775078bc" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "object" version = "0.36.7" diff --git a/Cargo.toml b/Cargo.toml index c2e4976cb4c..b2387fd39ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ anstyle = "1.0.11" anyhow = "1.0.98" base64 = "0.22.1" blake3 = "1.8.2" +block2 = "0.6.1" build-rs = { version = "0.3.1", path = "crates/build-rs" } cargo = { path = "" } cargo-credential = { version = "0.4.2", path = "credential/cargo-credential" } @@ -69,6 +70,7 @@ libgit2-sys = "0.18.2" libloading = "0.8.8" memchr = "2.7.5" miow = "0.6.0" +objc2 = "0.6.2" opener = "0.8.2" openssl = "0.10.73" openssl-sys = "0.9.109" @@ -253,6 +255,11 @@ features = [ "Win32_System_Threading", ] +# For ExecutionPolicy framework interaction. +[target.'cfg(target_vendor = "apple")'.dependencies] +block2.workspace = true +objc2.workspace = true + [dev-dependencies] annotate-snippets = { workspace = true, features = ["testing-colors"] } cargo-test-support.workspace = true diff --git a/src/bin/cargo/commands/install.rs b/src/bin/cargo/commands/install.rs index 7668b376c64..7e9b7da1990 100644 --- a/src/bin/cargo/commands/install.rs +++ b/src/bin/cargo/commands/install.rs @@ -216,7 +216,7 @@ pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult { let mut compile_opts = args.compile_options( gctx, - UserIntent::Build, + UserIntent::Install, workspace.as_ref(), ProfileChecking::Custom, )?; diff --git a/src/cargo/core/compiler/build_config.rs b/src/cargo/core/compiler/build_config.rs index 58438aeba3f..58a41fda93c 100644 --- a/src/cargo/core/compiler/build_config.rs +++ b/src/cargo/core/compiler/build_config.rs @@ -1,4 +1,5 @@ use crate::core::compiler::CompileKind; +use crate::core::features::DetectAntivirus; use crate::util::context::JobsConfig; use crate::util::interning::InternedString; use crate::util::{CargoResult, GlobalContext, RustfixDiagnosticServer}; @@ -52,6 +53,9 @@ pub struct BuildConfig { pub sbom: bool, /// Build compile time dependencies only, e.g., build scripts and proc macros pub compile_time_deps_only: bool, + /// Whether we should try to detect and notify the user when antivirus + /// software might make newly created binaries slow to launch. + pub detect_antivirus: DetectAntivirus, } fn default_parallelism() -> CargoResult { @@ -127,6 +131,19 @@ impl BuildConfig { _ => Vec::new(), }; + let detect_antivirus = match (cfg.detect_antivirus, gctx.cli_unstable().detect_antivirus) { + // Warn while the config is still unstable. + (Some(_), DetectAntivirus::Never) => { + gctx.shell().warn( + "ignoring 'build.detect-antivirus' config, pass `-Zdetect-antivirus` to enable it", + )?; + DetectAntivirus::Never + } + // Allow overriding with config. + (Some(false), _) => DetectAntivirus::Never, + (_, flag) => flag, + }; + Ok(BuildConfig { requested_kinds, jobs, @@ -145,6 +162,7 @@ impl BuildConfig { timing_outputs, sbom, compile_time_deps_only: false, + detect_antivirus, }) } @@ -280,12 +298,14 @@ impl CompileMode { /// /// For example, when a user runs `cargo test`, the intent is [`UserIntent::Test`], /// but this might result in multiple [`CompileMode`]s for different units. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum UserIntent { /// Build benchmark binaries, e.g., `cargo bench` Bench, - /// Build binaries and libraries, e.g., `cargo run`, `cargo install`, `cargo build`. + /// Build binaries and libraries, e.g., `cargo run`, `cargo build`, `cargo rustc`. Build, + /// Build binaries and libraries for installing, e.g. `cargo install`. + Install, /// Perform type-check, e.g., `cargo check`. Check { test: bool }, /// Document packages. diff --git a/src/cargo/core/features.rs b/src/cargo/core/features.rs index f34f12ccddd..423a4f81612 100644 --- a/src/cargo/core/features.rs +++ b/src/cargo/core/features.rs @@ -400,6 +400,38 @@ impl FromStr for FixEdition { } } +/// The value for `-Zdetect-antivirus`. +#[derive(Debug, Deserialize, Default, PartialEq, Eq, Hash, Copy, Clone)] +#[serde(rename_all = "lowercase")] +pub enum DetectAntivirus { + /// Always detect antivirus. + /// + /// This is useful when testing the feature, but isn't expected to be + /// useful to the general user. + Always, + /// Detect antivirus, but only when deemed reasonable. + /// + /// This is intended to be the default in the future. + Auto, + /// Never attempt to detect antivirus. + #[default] + Never, +} + +impl FromStr for DetectAntivirus { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result::Err> { + Ok(match s { + "always" => Self::Always, + "auto" => Self::Auto, + "never" => Self::Never, + _ => bail!( + "invalid `-Zdetect-antivirus`, expected `always`, `auto` or `never`, got `{s}`" + ), + }) + } +} + #[derive(Debug, PartialEq)] enum Status { Stable, @@ -854,6 +886,7 @@ unstable_cli_options!( checksum_freshness: bool = ("Use a checksum to determine if output is fresh rather than filesystem mtime"), codegen_backend: bool = ("Enable the `codegen-backend` option in profiles in .cargo/config.toml file"), config_include: bool = ("Enable the `include` key in config files"), + detect_antivirus: DetectAntivirus = ("Enable the experimental antivirus detection and the config option to disable it"), direct_minimal_versions: bool = ("Resolve minimal dependency versions instead of maximum (direct dependencies only)"), dual_proc_macros: bool = ("Build proc-macros for both the host and the target"), feature_unification: bool = ("Enable new feature unification modes in workspaces"), @@ -1373,6 +1406,7 @@ impl CliUnstable { "codegen-backend" => self.codegen_backend = parse_empty(k, v)?, "config-include" => self.config_include = parse_empty(k, v)?, "direct-minimal-versions" => self.direct_minimal_versions = parse_empty(k, v)?, + "detect-antivirus" => self.detect_antivirus = v.unwrap_or("auto").parse()?, "dual-proc-macros" => self.dual_proc_macros = parse_empty(k, v)?, "feature-unification" => self.feature_unification = parse_empty(k, v)?, "fix-edition" => { diff --git a/src/cargo/ops/cargo_compile/compile_filter.rs b/src/cargo/ops/cargo_compile/compile_filter.rs index cddcd56b87d..966c48dc56f 100644 --- a/src/cargo/ops/cargo_compile/compile_filter.rs +++ b/src/cargo/ops/cargo_compile/compile_filter.rs @@ -219,17 +219,18 @@ impl CompileFilter { match intent { UserIntent::Test | UserIntent::Doctest | UserIntent::Bench => true, UserIntent::Check { test: true } => true, - UserIntent::Build | UserIntent::Doc { .. } | UserIntent::Check { test: false } => { - match *self { - CompileFilter::Default { .. } => false, - CompileFilter::Only { - ref examples, - ref tests, - ref benches, - .. - } => examples.is_specific() || tests.is_specific() || benches.is_specific(), - } - } + UserIntent::Build + | UserIntent::Install + | UserIntent::Doc { .. } + | UserIntent::Check { test: false } => match *self { + CompileFilter::Default { .. } => false, + CompileFilter::Only { + ref examples, + ref tests, + ref benches, + .. + } => examples.is_specific() || tests.is_specific() || benches.is_specific(), + }, } } diff --git a/src/cargo/ops/cargo_compile/mod.rs b/src/cargo/ops/cargo_compile/mod.rs index 9d3f5724f41..1ea4f5e23ac 100644 --- a/src/cargo/ops/cargo_compile/mod.rs +++ b/src/cargo/ops/cargo_compile/mod.rs @@ -43,9 +43,10 @@ use crate::core::compiler::UserIntent; use crate::core::compiler::unit_dependencies::build_unit_dependencies; use crate::core::compiler::unit_graph::{self, UnitDep, UnitGraph}; use crate::core::compiler::{BuildConfig, BuildContext, BuildRunner, Compilation}; -use crate::core::compiler::{CompileKind, CompileTarget, RustcTargetData, Unit}; +use crate::core::compiler::{CompileKind, CompileMode, CompileTarget, RustcTargetData, Unit}; use crate::core::compiler::{CrateType, TargetInfo, apply_env_config, standard_lib}; use crate::core::compiler::{DefaultExecutor, Executor, UnitInterner}; +use crate::core::features::DetectAntivirus; use crate::core::profiles::Profiles; use crate::core::resolver::features::{self, CliFeatures, FeaturesFor}; use crate::core::resolver::{HasDevUnits, Resolve}; @@ -55,7 +56,7 @@ use crate::ops; use crate::ops::resolve::{SpecsAndResolvedFeatures, WorkspaceResolve}; use crate::util::context::{GlobalContext, WarningHandling}; use crate::util::interning::InternedString; -use crate::util::{CargoResult, StableHasher}; +use crate::util::{CargoResult, StableHasher, detect_antivirus}; mod compile_filter; pub use compile_filter::{CompileFilter, FilterRule, LibRule}; @@ -228,7 +229,11 @@ pub fn create_bcx<'a, 'gctx>( // Perform some pre-flight validation. match build_config.intent { - UserIntent::Test | UserIntent::Build | UserIntent::Check { .. } | UserIntent::Bench => { + UserIntent::Test + | UserIntent::Build + | UserIntent::Install + | UserIntent::Check { .. } + | UserIntent::Bench => { if ws.gctx().get_env("RUST_FLAGS").is_ok() { gctx.shell().warn( "Cargo does not read `RUST_FLAGS` environment variable. Did you mean `RUSTFLAGS`?", @@ -556,6 +561,40 @@ where `` is the latest version supporting rustc {rustc_version}" } } + if build_config.detect_antivirus != DetectAntivirus::Never { + // Count the number of test binaries and build scripts we'll need to + // run. This doesn't take into account the binary that will be run + // if `cargo run` was specified, and doesn't handle pre-2024 `rustdoc` + // tests, but that's fine, this is only a heuristic. + let num_binaries = unit_graph + .keys() + .filter(|unit| { + matches!( + unit.mode, + CompileMode::Test | CompileMode::Doctest | CompileMode::RunCustomBuild + ) + }) + .count(); + + tracing::debug!("estimated {num_binaries} binaries that could be slowed down by antivirus"); + + // Heuristic: Only do the check if we have to run more than a specific + // number of binaries. This makes it so that small beginner projects + // don't hit this. + // + // We also don't want to do this check when installing, since there + // might be `cargo install` users who are not necessarily developers + // (and so the note will be irrelevant to them). + if (10 < num_binaries && build_config.intent != UserIntent::Install) + || build_config.detect_antivirus == DetectAntivirus::Always + { + if let Err(err) = detect_antivirus::detect_and_report(gctx) { + // Errors in this detection are not fatal. + tracing::error!("failed detecting whether binaries may be slow to run: {err}"); + } + } + } + let bcx = BuildContext::new( ws, pkg_set, diff --git a/src/cargo/ops/cargo_compile/unit_generator.rs b/src/cargo/ops/cargo_compile/unit_generator.rs index 1565a5ec241..b9670039550 100644 --- a/src/cargo/ops/cargo_compile/unit_generator.rs +++ b/src/cargo/ops/cargo_compile/unit_generator.rs @@ -182,7 +182,7 @@ impl<'a> UnitGenerator<'a, '_> { .iter() .filter(|t| t.tested() || t.is_example()) .collect(), - UserIntent::Build | UserIntent::Check { .. } => targets + UserIntent::Build | UserIntent::Install | UserIntent::Check { .. } => targets .iter() .filter(|t| t.is_bin() || t.is_lib()) .collect(), @@ -453,7 +453,7 @@ impl<'a> UnitGenerator<'a, '_> { FilterRule::Just(_) => Target::is_test, }; let test_mode = match self.intent { - UserIntent::Build => CompileMode::Test, + UserIntent::Build | UserIntent::Install => CompileMode::Test, UserIntent::Check { .. } => CompileMode::Check { test: true }, _ => default_mode, }; @@ -775,7 +775,7 @@ Rustdoc did not scrape the following examples because they require dev-dependenc fn to_compile_mode(intent: UserIntent) -> CompileMode { match intent { UserIntent::Test | UserIntent::Bench => CompileMode::Test, - UserIntent::Build => CompileMode::Build, + UserIntent::Build | UserIntent::Install => CompileMode::Build, UserIntent::Check { test } => CompileMode::Check { test }, UserIntent::Doc { .. } => CompileMode::Doc, UserIntent::Doctest => CompileMode::Doctest, diff --git a/src/cargo/util/context/mod.rs b/src/cargo/util/context/mod.rs index 62b04113f4b..2acafcdf4a9 100644 --- a/src/cargo/util/context/mod.rs +++ b/src/cargo/util/context/mod.rs @@ -2770,6 +2770,8 @@ pub struct CargoBuildConfig { pub sbom: Option, /// Unstable feature `-Zbuild-analysis`. pub analysis: Option, + /// Unstable feature `-Zdetect-antivirus`. + pub detect_antivirus: Option, } /// Metrics collection for build analysis. diff --git a/src/cargo/util/detect_antivirus/execution_policy.rs b/src/cargo/util/detect_antivirus/execution_policy.rs new file mode 100644 index 00000000000..25a30f59d90 --- /dev/null +++ b/src/cargo/util/detect_antivirus/execution_policy.rs @@ -0,0 +1,187 @@ +//! Utilities for using a dynamically loaded ExecutionPolicy.framework. +//! +//! ExecutionPolicy is only available since macOS 10.15, while Rust's +//! minimum supported version for host tooling is macOS 10.12: +//! https://doc.rust-lang.org/rustc/platform-support/apple-darwin.html#host-tooling +//! +//! For this reason, we must load the framework dynamically instead of linking +//! it statically - which gets a bit more involved. +//! +//! See for a safer interface that +//! can be used if support for lower macOS versions are dropped (or once Rust +//! gains better support for weak linking). +//! +//! NOTE: `addPolicyExceptionForURL:error:` probably isn't relevant for us, +//! that is more used for e.g. allowing running a recently downloaded binary +//! (and requires that you already have developer tool authorization). + +use std::cell::Cell; +use std::ffi::{CStr, c_void}; +use std::marker::PhantomData; +use std::rc::Rc; + +use anyhow::Context; +use block2::{DynBlock, RcBlock}; +use objc2::ffi::NSInteger; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, Bool, NSObject}; +use objc2::{available, msg_send}; + +use crate::CargoResult; + +/// A handle to the dynamically loaded ExecutionPolicy framework. +#[derive(Debug)] +pub struct ExecutionPolicyHandle(*mut c_void); + +impl ExecutionPolicyHandle { + /// Dynamically load the ExecutionPolicy framework, and return None if it + /// isn't available. + pub fn open() -> CargoResult> { + let path = c"/System/Library/Frameworks/ExecutionPolicy.framework/ExecutionPolicy"; + + let handle = unsafe { libc::dlopen(path.as_ptr(), libc::RTLD_LAZY | libc::RTLD_LOCAL) }; + + if handle.is_null() { + // SAFETY: `dlerror` is safe to call. + let err = unsafe { libc::dlerror() }; + let err = if err.is_null() { + None + } else { + // SAFETY: The error is a valid C string. + Some(unsafe { CStr::from_ptr(err) }) + }; + + // The framework was introduced in macOS 10.15+ / Mac Catalyst 13.0+. + if available!(macos = 10.15, ios = 13.0) { + Err(anyhow::format_err!( + "failed loading ExecutionPolicy.framework: {err:?}" + )) + } else { + // The framework is not available on macOS 10.14 and below + // (which also means that the antivirus doesn't exist yet, so + // nothing for us to detect and warn against). + Ok(None) + } + } else { + Ok(Some(Self(handle))) + } + } +} + +impl Drop for ExecutionPolicyHandle { + fn drop(&mut self) { + // SAFETY: The handle is valid. + let _ = unsafe { libc::dlclose(self.0) }; + // Ignore errors when closing. This is also what `libloading` does: + // https://docs.rs/libloading/0.8.6/src/libloading/os/unix/mod.rs.html#374 + } +} + +/// Query the "Developer Tool" status of the environment. +/// +/// Internally, this calls the system via XPC. +/// +/// See [`objc2_execution_policy::EPDeveloperTool`] for details. +/// +/// [`objc2_execution_policy::EPDeveloperTool`]: https://docs.rs/objc2-execution-policy/0.3.1/objc2_execution_policy/struct.EPDeveloperTool.html +#[derive(Debug)] +pub struct EPDeveloperTool<'handle> { + _handle: PhantomData<&'handle ExecutionPolicyHandle>, + obj: Retained, +} + +impl<'handle> EPDeveloperTool<'handle> { + /// Call `+[EPDeveloperTool new]` to get a new handle. + pub fn new(_handle: &'handle ExecutionPolicyHandle) -> CargoResult { + // Dynamically query the class (loading the framework with dlopen + // above should have made this available). + let cls = + AnyClass::get(c"EPDeveloperTool").context("failed finding `EPDeveloperTool` class")?; + + // SAFETY: The signature of +[EPDeveloperTool new] is correct and + // the method is safe to call. + let obj: Option> = unsafe { msg_send![cls, new] }; + + // Null can happen in OOM situations, and maybe if failing to connect + // via. XPC to the required services. + let obj = obj.context("failed allocating and initializing `EPDeveloperTool` instance")?; + + let _handle = PhantomData; + Ok(Self { _handle, obj }) + } + + /// Call `-[EPDeveloperTool authorizationStatus]`. + pub fn authorization_status(&self) -> EPDeveloperToolStatus { + // SAFETY: -[EPDeveloperTool authorizationStatus] correctly + // returns EPDeveloperToolStatus and the method is safe to call. + let status: NSInteger = unsafe { msg_send![&*self.obj, authorizationStatus] }; + EPDeveloperToolStatus(status) + } + + /// Call `requestDeveloperToolAccessWithCompletionHandler:` and get the + /// result. + //// + /// This allows the user to more easily see which application needs to be + /// allowed (but _is_ also requesting higher privileges, so we need to be + /// clear in messaging around that). + pub fn request_access(&self) -> CargoResult { + // Wrapper to make the signature easier to write. + fn inner(obj: &NSObject, block: &DynBlock) { + // SAFETY: + // - The method is safe to call, and we provide a correctly typed + // block, and constrain the signature to be void / unit return. + // - No Send/Sync requirements are needed, because the block is + // not marked @Sendable in Swift. + // - The 'static requirement on the block is needed because the + // block is marked as @escaping in Swift. Note that the fact + // that the API is annotated as such is kind of weird, there + // isn't really a way that it could call this block on the + // current thread later (which is what a lone @escaping means). + unsafe { msg_send![obj, requestDeveloperToolAccessWithCompletionHandler: block] } + } + + let result = Rc::new(Cell::new(None)); + let result_clone = result.clone(); + let block = RcBlock::new(move |granted: Bool| result_clone.set(Some(granted.as_bool()))); + inner(&self.obj, &block); + result.get().context("failed getting result of -[EPDeveloperTool requestDeveloperToolAccessWithCompletionHandler:]") + } +} + +/// The Developer Tool status of the process. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EPDeveloperToolStatus(pub NSInteger); + +impl EPDeveloperToolStatus { + #[doc(alias = "EPDeveloperToolStatusNotDetermined")] + pub const NOT_DETERMINED: Self = Self(0); + #[doc(alias = "EPDeveloperToolStatusRestricted")] + pub const RESTRICTED: Self = Self(1); + #[doc(alias = "EPDeveloperToolStatusDenied")] + pub const DENIED: Self = Self(2); + #[doc(alias = "EPDeveloperToolStatusAuthorized")] + pub const AUTHORIZED: Self = Self(3); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn does_not_crash() { + let Some(handle) = ExecutionPolicyHandle::open().unwrap() else { + return; + }; + + let developer_tool = EPDeveloperTool::new(&handle).unwrap(); + + let _ = developer_tool.authorization_status(); + + // Test that requesting access doesn't crash either. This might be + // slightly annoying for macOS Cargo developers if they _really_ don't + // want their terminal to show up in their Developer Tools settings, + // but in that case we should probably reconsider this feature. + let _ = developer_tool.request_access().unwrap(); + } +} diff --git a/src/cargo/util/detect_antivirus/mod.rs b/src/cargo/util/detect_antivirus/mod.rs new file mode 100644 index 00000000000..3940d5f004a --- /dev/null +++ b/src/cargo/util/detect_antivirus/mod.rs @@ -0,0 +1,124 @@ +//! # Utilities for detecting whether antivirus software is active +//! +//! Antivirus software such as Microsoft Defender and macOS' XProtect usually +//! intercept process creation for new binaries, and do a signature-based +//! check to see if the binary contains known malware: +//! +//! +//! Most users do not create new binaries all the time (and some malware +//! allegedly got around antivirus checks by doing this in the past), so from +//! a security standpoint it makes sense to spend time analyzing these. +//! +//! But developers *do* often create new binaries (Cargo does it for build +//! scripts and tests), and since there is a fairly high cost to these checks, +//! it makes sense for us to guide the user towards selectively disabling +//! these security features of their OS to allow for faster iteration time +//! when developing their software. + +use super::{CargoResult, GlobalContext}; + +#[cfg(target_os = "macos")] +mod execution_policy; +#[cfg(target_os = "macos")] +mod sip; + +/// Detect and report if macOS' XProtect (Gatekeeper) is enabled in the +/// context of the current process, and thus likely to introduce overhead +/// in the first launch of binaries we create. +/// +/// This is the case if the top-level program that we're running under +/// (often Terminal.app or sshd-keygen-wrapper if in an ssh session) is +/// marked as having Developer Tool permissions, or if (parts of) SIP is +/// disabled. +/// +/// NOTE: This check is not necessarily exhaustive - there might be other +/// yet-unknown factors that influence how long it takes to launch a newly +/// created binary? This fact is part of the motivation for allowing the +/// user to opt-out of the check. +#[cfg(target_os = "macos")] // Host macOS +pub fn detect_and_report(gtcx: &GlobalContext) -> CargoResult<()> { + use self::execution_policy::{EPDeveloperTool, EPDeveloperToolStatus, ExecutionPolicyHandle}; + + // We use Objective-C objects in here, use an autorelease pool to make + // sure it's cleaned up afterwards. + objc2::rc::autoreleasepool(|_| { + let Some(handle) = ExecutionPolicyHandle::open()? else { + tracing::debug!("the ExecutionPolicy framework is (expectedly) not available"); + return Ok(()); + }; + + let developer_tool = EPDeveloperTool::new(&handle)?; + + // Check whether we're running under an environment that has the + // "Developer Tool" grant. + let status = developer_tool.authorization_status(); + let status_str = match status { + EPDeveloperToolStatus::NOT_DETERMINED => "not determined", + EPDeveloperToolStatus::RESTRICTED => "restricted", + EPDeveloperToolStatus::DENIED => "denied", + EPDeveloperToolStatus::AUTHORIZED => "authorized", + _ => "unknown", + }; + tracing::debug!("Developer Tool authorization status: {status_str}"); + if status == EPDeveloperToolStatus::AUTHORIZED { + // We are! No need to report anything then, newly created binaries + // should be fast to run from the get-go. + return Ok(()); + } + + // Otherwise, detect if SIP's Filesystem Protections are disabled. + // + // We do this check secondly, because the "happy path" / the fast path + // should be that the user has Developer Tool authorization. + let sip_fs_enabled = sip::fs_from_command()?; + tracing::debug!("are SIP Filesystem Protections enabled? {sip_fs_enabled}"); + if !sip_fs_enabled { + // They are! Also no need to report anything here. + return Ok(()); + } + + // If we aren't authorized, attempt to request "Developer Tool" + // privileges from the system. + // + // NOTE: This has the side-effect of adding the parent binary to + // `System Preferences > Security & Privacy > Developer Tools`, even + // if the request fails, which is why we do this as the last resort. + // + // The side-effect is desired though, because it makes it much easier + // for the user to see which binary they actually need to allow as a + // Developer Tool (e.g. if using a third-party terminal like iTerm). + // + // This is kinda similar to `spctl developer-mode enable-terminal`, + // except that the binary that is added there is always Terminal.app. + let res = developer_tool.request_access()?; + if res { + // Our request for access was granted! No need to report anything. + return Ok(()); + } + + gtcx.shell().note( + "detected that XProtect is enabled in this session, which may \ + slow down builds as it scans build scripts and test binaries \ + before they are run.\ + \n\ + If you trust the software that you run in your terminal, then \ + this overhead can be avoided by giving it more permissions under \ + `System Preferences > Security & Privacy > Developer Tools`. \ + (Cargo has made an entry for the current terminal appear there, \ + though you will need to go and manually enable it).\ + \n\ + Alternatively, you can disable this note by adding \ + `build.detect-antivirus = false` to your ~/.cargo/config.toml.\ + \n\ + See \ + for more information.", + )?; + + Ok(()) + }) +} + +#[cfg(not(target_os = "macos"))] +pub fn detect_and_report(_gtcx: &GlobalContext) -> CargoResult<()> { + Ok(()) +} diff --git a/src/cargo/util/detect_antivirus/sip.rs b/src/cargo/util/detect_antivirus/sip.rs new file mode 100644 index 00000000000..5542ec51d0b --- /dev/null +++ b/src/cargo/util/detect_antivirus/sip.rs @@ -0,0 +1,52 @@ +//! Utilities to detect which parts of SIP (System Integrity Protection) are enabled. + +use std::process::Command; + +use anyhow::Context; + +use crate::CargoResult; + +/// Invoke `csrutil status`, and parse the output to figure out if Filesystem +/// Protections are enabled (disabled with `csrutil disable`, or selectively +/// with `csrutil enable --without fs`). +/// +/// Might fail if the output changes in the future. If this happens, consider +/// one of the alternative implementations in . +pub fn fs_from_command() -> CargoResult { + // Invoke directly, to avoid issues if a weird PATH is set. + let output = Command::new("/usr/bin/csrutil") + .arg("status") + .output() + .context("failed invoking `/usr/bin/csrutil status`")?; + + if !output.status.success() { + anyhow::bail!("`/usr/bin/csrutil status` failed: {output:?}"); + } + + let stdout = String::from_utf8(output.stdout).unwrap(); + + if stdout.contains("Filesystem Protections: enabled") + || stdout.contains("System Integrity Protection status: enabled") + { + Ok(true) + } else if stdout.contains("Filesystem Protections: disabled") + || stdout.contains("System Integrity Protection status: disabled") + { + Ok(false) + } else { + // We could consider making this a warning instead? + Err(anyhow::format_err!( + "could not parse output of `/usr/bin/csrutil status`: {stdout:?}", + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn does_not_crash() { + fs_from_command().unwrap(); + } +} diff --git a/src/cargo/util/mod.rs b/src/cargo/util/mod.rs index a61fb5b9651..c9c0926e1f9 100644 --- a/src/cargo/util/mod.rs +++ b/src/cargo/util/mod.rs @@ -37,6 +37,7 @@ mod counter; pub mod cpu; pub mod credential; mod dependency_queue; +pub mod detect_antivirus; pub mod diagnostic_server; pub mod edit_distance; pub mod errors; diff --git a/src/doc/src/SUMMARY.md b/src/doc/src/SUMMARY.md index d66d65f1228..999ba8895f4 100644 --- a/src/doc/src/SUMMARY.md +++ b/src/doc/src/SUMMARY.md @@ -100,3 +100,4 @@ * [Changelog](CHANGELOG.md) * [Appendix: Glossary](appendix/glossary.md) * [Appendix: Git Authentication](appendix/git-authentication.md) +* [Appendix: Antivirus Software](appendix/antivirus.md) diff --git a/src/doc/src/appendix/antivirus.md b/src/doc/src/appendix/antivirus.md new file mode 100644 index 00000000000..3cd5011bd1f --- /dev/null +++ b/src/doc/src/appendix/antivirus.md @@ -0,0 +1,32 @@ +# Antivirus Software + +Many operating systems include antivirus software programs that scan the system for malware. These sometimes interfere with development workflows, see below. + + + +## XProtect + +macOS has a layered system for protecting against malware, see [Apple's documentation](https://support.apple.com/en-gb/guide/security/sec469d47bd8/web) for details. One of the components here is XProtect, which intercepts all binary launches and scans new binaries for malware before executing them. + +Unfortunately, scanning binaries is done on a single thread and can be fairly slow (100-300ms). This affects projects developed with Cargo, since Cargo creates many binaries that are often only executed once (examples include build scripts and test binaries). + +You can avoid this overhead by doing the following: +- Open `System Settings` and navigate to the `Privacy & Security` item. +- On older macOS versions, click the `Privacy` tab. +- Navicate to the `Developer Tools` item. +- Add your terminal to the list, and enable it. + - You can run `spctl developer-mode enable-terminal` to add `Terminal.app` to this list. + - If you use a third-party terminal application, you might need to add that here as well. +- Restart your terminal. + +See the screenshot below for what this looks like on macOS 26 Tahoe. + +![System Settings with Terminal.app set as a Developer Tool](../images/macos-developer-tool-settings.png) + +### Security considerations + +Unfortunately, there doesn't seem to be a way to scope this to select binaries, e.g. adding Cargo directly as a developer tool has no effect, it has to be the "top-level" process. + +As such, **this disables your antivirus for all software that is launched via your terminal**. This is only one part of macOS' security protections that specifically checks for known malware signatures, and it is nowhere near as unsafe as disabling SIP or giving Full Disk Access would be - but depending on your security constraints, you might still consider leaving it enabled. + +Changing this setting has not been tested on systems in enterprise environments, it might affect more things there. diff --git a/src/doc/src/images/macos-developer-tool-settings.png b/src/doc/src/images/macos-developer-tool-settings.png new file mode 100644 index 00000000000..eb5fe408d32 Binary files /dev/null and b/src/doc/src/images/macos-developer-tool-settings.png differ diff --git a/src/doc/src/index.md b/src/doc/src/index.md index 9fa65b79341..1eb79263d79 100644 --- a/src/doc/src/index.md +++ b/src/doc/src/index.md @@ -32,6 +32,7 @@ The commands will let you interact with Cargo using its command-line interface. **Appendices:** * [Glossary](appendix/glossary.md) * [Git Authentication](appendix/git-authentication.md) +* [Antivirus Software](appendix/antivirus.md) **Other Documentation:** * [Changelog](CHANGELOG.md) diff --git a/src/doc/src/reference/unstable.md b/src/doc/src/reference/unstable.md index c8b64236fe1..27fded68849 100644 --- a/src/doc/src/reference/unstable.md +++ b/src/doc/src/reference/unstable.md @@ -131,6 +131,7 @@ Each new feature described below should explain how to use it. * [Package message format](#package-message-format) --- Message format for `cargo package`. * [`fix-edition`](#fix-edition) --- A permanently unstable edition migration helper. * [Plumbing subcommands](https://github.com/crate-ci/cargo-plumbing) --- Low, level commands that act as APIs for Cargo, like `cargo metadata` + * [Detect antivirus](#detect-antivirus) --- Detect whether newly created binaries may be slow to launch due to antivirus. ## allow-features @@ -1947,6 +1948,35 @@ enabled = true Enables the new build-dir filesystem layout. This layout change unblocks work towards caching and locking improvements. +## Detect Antivirus + +* Tracking Issue: [#0](https://github.com/rust-lang/cargo/issues/0) + +The `-Zdetect-antivirus=auto` flag enables detection of antivirus software that might make launching a binary for the first time slower (which in turn makes Cargo's build scripts and tests slower), and outputs a notice to the user if this is the case. + +This feature uses a small heuristic to avoid doing the detection when deemed unnecessary. `-Zdetect-antivirus=always` may be used to disable this heuristic. + +This feature will be enabled by default in the future (the flag acts as-if this future is now). + +Currently only implemented for macOS' XProtect/Gatekeeper, but could be expanded to Windows Defender in the future. + +```toml +# Example ~/.cargo/config.toml + +# Disable warning, e.g. if using a workplace-issued Mac that +# doesn't allow granting Developer Tool permissions. +[build] +detect-antivirus = false +``` + +### `build.detect-antivirus` + +* Type: boolean +* Default: true (when `-Zdetect-antivirus` is enabled) +* Environment: CARGO_BUILD_DETECT_ANTIVIRUS + +Allow opting out of antivirus detection. + # Stabilized and removed features diff --git a/tests/testsuite/cargo/z_help/stdout.term.svg b/tests/testsuite/cargo/z_help/stdout.term.svg index f48d5c64e5f..fb1a3c09d66 100644 --- a/tests/testsuite/cargo/z_help/stdout.term.svg +++ b/tests/testsuite/cargo/z_help/stdout.term.svg @@ -1,4 +1,4 @@ - +