diff --git a/.changeset/curvy-dingos-build.md b/.changeset/curvy-dingos-build.md new file mode 100644 index 0000000000..10b5971e20 --- /dev/null +++ b/.changeset/curvy-dingos-build.md @@ -0,0 +1,7 @@ +--- +"@pnpm/pnpr.client": minor +"pacquet": minor +"pnpm": minor +--- + +Added macOS and Windows x64 and arm64 support to remote shared build artifacts [pnpm/pnpm#13771](https://github.com/pnpm/pnpm/issues/13771). diff --git a/Cargo.lock b/Cargo.lock index 606cd67959..edb82134a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4722,6 +4722,7 @@ dependencies = [ "serde_json", "sha2", "ssri", + "sysinfo", "tar", "tempfile", "text-block-macros", diff --git a/pnpm/crates/deps-restorer/Cargo.toml b/pnpm/crates/deps-restorer/Cargo.toml index 6633131577..9110be00d2 100644 --- a/pnpm/crates/deps-restorer/Cargo.toml +++ b/pnpm/crates/deps-restorer/Cargo.toml @@ -80,6 +80,7 @@ libc = { workspace = true } [target.'cfg(windows)'.dependencies] same-file = { workspace = true } +sysinfo = { workspace = true } windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] } [lints] diff --git a/pnpm/crates/deps-restorer/src/shared_side_effects.rs b/pnpm/crates/deps-restorer/src/shared_side_effects.rs index 5ffbeea8c2..01c2429094 100644 --- a/pnpm/crates/deps-restorer/src/shared_side_effects.rs +++ b/pnpm/crates/deps-restorer/src/shared_side_effects.rs @@ -10,9 +10,10 @@ use pnpm_lockfile::{PackageKey, PackageMetadata, SnapshotEntry}; use pnpm_pnpr_client::{ ARTIFACT_KIND, ArtifactBlobRequest, ArtifactBlobUpload, ArtifactCandidate, ArtifactFile, ArtifactManifest, ArtifactPayload, BuilderProfile, CompatibilityConstraints, - LinuxGlibcPlatform, OwnerScope, PackageIdentity, PnprClient, PnprClientError, + LinuxGlibcPlatform, MacOsPlatform, OwnerScope, PackageIdentity, PnprClient, PnprClientError, PublishArtifactRequest, RejectedArtifact, ResolveArtifactsOptions, SignedArtifactEnvelope, - blob_id, linux_glibc_supported_tags, linux_glibc_tag, platform_fingerprint, + WindowsPlatform, blob_id, linux_glibc_supported_tags, linux_glibc_tag, macos_supported_tags, + macos_tag, platform_fingerprint, windows_supported_tags, windows_tag, }; use pnpm_shared_artifact_protocol::compatibility_rank; use pnpm_store_dir::{CafsFileInfo, RemoteSideEffectsOrigin, SideEffectsDiff, StoreIndexWriter}; @@ -20,8 +21,11 @@ use sha2::{Digest as _, Sha512}; use std::{ collections::{BTreeMap, HashMap, HashSet}, path::{Path, PathBuf}, + process::Command, sync::Arc, }; +#[cfg(windows)] +use sysinfo::System; pub(crate) type BaseCasPaths = HashMap>; @@ -33,11 +37,46 @@ pub struct SharedSideEffectsPublisher { key_id: String, organization: String, packages: HashSet, - platform: LinuxGlibcPlatform<'static>, + platform: ArtifactPlatform<'static>, private_key: Vec, runtime: tokio::runtime::Handle, } +#[derive(Clone, Copy)] +enum ArtifactPlatform<'a> { + LinuxGlibc(LinuxGlibcPlatform<'a>), + MacOs(MacOsPlatform<'a>), + Windows(WindowsPlatform<'a>), +} + +impl ArtifactPlatform<'_> { + fn node_major(self) -> u32 { + match self { + Self::LinuxGlibc(platform) => platform.node_major, + Self::MacOs(platform) => platform.node_major, + Self::Windows(platform) => platform.node_major, + } + } + + fn supported_tags( + self, + ) -> Result, pnpm_shared_artifact_protocol::ArtifactProtocolError> { + match self { + Self::LinuxGlibc(platform) => linux_glibc_supported_tags(platform), + Self::MacOs(platform) => macos_supported_tags(platform), + Self::Windows(platform) => windows_supported_tags(platform), + } + } + + fn tag(self) -> Result { + match self { + Self::LinuxGlibc(platform) => linux_glibc_tag(platform), + Self::MacOs(platform) => macos_tag(platform), + Self::Windows(platform) => windows_tag(platform), + } + } +} + struct CandidateGroup { candidate: ArtifactCandidate, pinned_envelope_digest: Option, @@ -83,8 +122,8 @@ pub(crate) async fn apply_shared_side_effects( return Vec::new(); } let Some(settings) = config.remote_side_effects_cache.as_ref() else { return Vec::new() }; - let Some(platform) = linux_glibc_platform(snapshots) else { return Vec::new() }; - let supported_tags = match linux_glibc_supported_tags(platform) { + let Some(platform) = artifact_platform(snapshots) else { return Vec::new() }; + let supported_tags = match platform.supported_tags() { Ok(tags) => tags, Err(error) => { tracing::warn!(target: "pacquet::install", %error, "remote side-effects platform is unsupported"); @@ -129,7 +168,7 @@ pub(crate) async fn apply_shared_side_effects( &mut deps_state_cache, in_lockfile_order(&graph).into_iter().map(|(key, _)| key), ); - let engine_name = pnpm_graph_hasher::engine_name(platform.node_major, None, None); + let engine_name = pnpm_graph_hasher::engine_name(platform.node_major(), None, None); let mut groups = BTreeMap::::new(); let mut collisions = HashSet::new(); let mut artifact_pin_records = Vec::new(); @@ -749,7 +788,7 @@ pub(crate) fn shared_side_effects_publisher( return None; } let snapshots = snapshots?; - let platform = linux_glibc_platform(snapshots)?; + let platform = artifact_platform(snapshots)?; let private_key = BASE64.decode(settings.private_key.as_ref()?).ok()?; let key_id = settings.key_id.clone()?; let builder_id = settings.builder_id.clone()?; @@ -839,7 +878,7 @@ impl SharedSideEffectsPublisher { builder_id: self.builder_id.clone(), builder_profile: self.builder_profile.clone(), compatibility: CompatibilityConstraints::Tagged { - tags: vec![linux_glibc_tag(self.platform).map_err(|error| error.to_string())?], + tags: vec![self.platform.tag().map_err(|error| error.to_string())?], }, manifest: ArtifactManifest { added: files, deleted: diff.deleted.unwrap_or_default() }, }; @@ -859,23 +898,77 @@ impl SharedSideEffectsPublisher { } } -fn linux_glibc_platform( +fn artifact_platform( snapshots: &HashMap, -) -> Option> { - if pnpm_graph_hasher::host_platform() != "linux" - || !matches!(pnpm_graph_hasher::host_arch(), "x64" | "arm64") - { +) -> Option> { + let architecture = pnpm_graph_hasher::host_arch(); + if !matches!(architecture, "x64" | "arm64") { return None; } let node_major = find_runtime_node_major(Some(snapshots)).or_else(pnpm_graph_hasher::detect_node_major)?; - let (glibc_major, glibc_minor) = pnpm_detect_libc::glibc_version()?; - Some(LinuxGlibcPlatform { - architecture: pnpm_graph_hasher::host_arch(), - node_major, - glibc_major, - glibc_minor, - }) + match pnpm_graph_hasher::host_platform() { + "linux" => { + let (glibc_major, glibc_minor) = pnpm_detect_libc::glibc_version()?; + Some(ArtifactPlatform::LinuxGlibc(LinuxGlibcPlatform { + architecture, + node_major, + glibc_major, + glibc_minor, + })) + } + "darwin" => { + let (macos_major, macos_minor) = macos_product_version()?; + Some(ArtifactPlatform::MacOs(MacOsPlatform { + architecture, + node_major, + macos_major, + macos_minor, + })) + } + "win32" => { + let (windows_major, windows_minor, windows_build) = windows_kernel_version()?; + Some(ArtifactPlatform::Windows(WindowsPlatform { + architecture, + node_major, + windows_major, + windows_minor, + windows_build, + })) + } + _ => None, + } +} + +fn macos_product_version() -> Option<(u32, u32)> { + let output = Command::new("/usr/bin/sw_vers").arg("-productVersion").output().ok()?; + output.status.success().then_some(())?; + parse_macos_product_version(std::str::from_utf8(&output.stdout).ok()?) +} + +fn parse_macos_product_version(value: &str) -> Option<(u32, u32)> { + let mut components = value.trim().split('.'); + let major = components.next()?.parse().ok()?; + let minor = components.next()?.parse().ok()?; + (major > 0 && major < 1_000_000 && minor < 1_000_000).then_some((major, minor)) +} + +#[cfg(windows)] +fn windows_kernel_version() -> Option<(u32, u32, u32)> { + let build = System::kernel_version()?.parse().ok()?; + // Windows 10 and 11 both use NT kernel version 10.0; sysinfo provides the build number. + validate_windows_kernel_version(10, 0, build) +} + +#[cfg(not(windows))] +fn windows_kernel_version() -> Option<(u32, u32, u32)> { + None +} + +#[cfg(any(windows, test))] +fn validate_windows_kernel_version(major: u32, minor: u32, build: u32) -> Option<(u32, u32, u32)> { + (major > 0 && major < 1_000 && minor < 1_000 && build > 0 && build < 1_000_000) + .then_some((major, minor, build)) } fn patch_hash(snapshot_key: &PackageKey) -> Option { diff --git a/pnpm/crates/deps-restorer/src/shared_side_effects/tests.rs b/pnpm/crates/deps-restorer/src/shared_side_effects/tests.rs index a055d649fb..75ec844d1e 100644 --- a/pnpm/crates/deps-restorer/src/shared_side_effects/tests.rs +++ b/pnpm/crates/deps-restorer/src/shared_side_effects/tests.rs @@ -1,6 +1,20 @@ -use super::package_version; +use super::{package_version, parse_macos_product_version, validate_windows_kernel_version}; use pnpm_lockfile::PackageKey; +#[test] +fn parses_macos_product_versions() { + assert_eq!(parse_macos_product_version("15.5.1\n"), Some((15, 5))); + assert_eq!(parse_macos_product_version("26.0\n"), Some((26, 0))); + assert_eq!(parse_macos_product_version("Darwin 25.0\n"), None); +} + +#[test] +fn validates_windows_kernel_versions() { + assert_eq!(validate_windows_kernel_version(10, 0, 26_100), Some((10, 0, 26_100))); + assert_eq!(validate_windows_kernel_version(0, 0, 26_100), None); + assert_eq!(validate_windows_kernel_version(10, 0, 0), None); +} + #[test] fn package_identity_uses_the_manifest_version_for_non_registry_sources() { let file: PackageKey = "native-addon@file:../native-addon.tgz".parse().unwrap(); @@ -117,10 +131,9 @@ async fn a_non_regular_file_is_not_reused_as_store_content() { } } -/// A restore only happens where the remote cache applies at all: -/// `linux_glibc_platform` refuses anything but linux-glibc on x64 or arm64, so -/// anywhere else — musl, or a linux arch pacquet publishes no artifacts for — -/// there is no restore to observe and each test skips itself. +/// A restore only happens where the remote cache applies at all. Unsupported +/// libc, operating-system, and architecture combinations have no restore to +/// observe, so the platform gates below make that boundary visible to tests. mod restore { use crate::{AllowBuildPolicy, RequiresBuildBySnapshot, SideEffectsMapsBySnapshot}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; @@ -133,7 +146,6 @@ mod restore { use pnpm_pnpr_client::{ ARTIFACT_KIND, ArtifactFile, ArtifactManifest, ArtifactPayload, BuilderProfile, CompatibilityConstraints, OwnerScope, ResolveArtifactsRequest, SignedArtifactEnvelope, - linux_glibc_supported_tags, }; use pnpm_shared_artifact_protocol::{ ArtifactVariant, ResolveArtifactsResponse, ResolvedArtifact, @@ -371,19 +383,14 @@ mod restore { async fn restore(store_dir: &StoreDir, expected_downloads: usize) -> PathBuf { let snapshots = snapshots(); let packages = packages(); - // The cfg above settles the target; the glibc *version* is read from - // the environment (`getconf`, then `ldd`), which the target cannot - // promise. A host with neither is not one the remote cache serves, so - // say that rather than leaving a bare unwrap to explain itself. - let platform = super::super::linux_glibc_platform(&snapshots).expect( - "the glibc version must be readable — install `getconf` or `ldd`, \ - or run this suite on a host the remote side-effects cache serves", - ); + let platform = super::super::artifact_platform(&snapshots) + .expect("the host compatibility floor must be readable on a supported platform"); assert_eq!( - platform.node_major, 22, + platform.node_major(), + 22, "the lockfile's Node pin, not the machine's Node, must decide the platform", ); - let mut supported_tags = linux_glibc_supported_tags(platform).expect("supported tags"); + let mut supported_tags = platform.supported_tags().expect("supported tags"); let compatibility_tag = supported_tags.swap_remove(0); let mut server = mockito::Server::new_async().await; @@ -452,12 +459,16 @@ mod restore { #[tokio::test] #[cfg_attr( - not(all( - target_os = "linux", - target_env = "gnu", - any(target_arch = "x86_64", target_arch = "aarch64") + not(any( + all( + target_os = "linux", + target_env = "gnu", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + all(target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64")), + all(target_os = "windows", any(target_arch = "x86_64", target_arch = "aarch64")) )), - ignore = "the remote side-effects cache only serves linux-glibc on x64 and arm64" + ignore = "the remote side-effects cache only serves glibc Linux, macOS, and Windows on x64 and arm64" )] async fn content_the_store_lacks_is_downloaded() { let store = tempfile::tempdir().expect("tempdir"); @@ -477,12 +488,16 @@ mod restore { /// difference between them. #[tokio::test] #[cfg_attr( - not(all( - target_os = "linux", - target_env = "gnu", - any(target_arch = "x86_64", target_arch = "aarch64") + not(any( + all( + target_os = "linux", + target_env = "gnu", + any(target_arch = "x86_64", target_arch = "aarch64") + ), + all(target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64")), + all(target_os = "windows", any(target_arch = "x86_64", target_arch = "aarch64")) )), - ignore = "the remote side-effects cache only serves linux-glibc on x64 and arm64" + ignore = "the remote side-effects cache only serves glibc Linux, macOS, and Windows on x64 and arm64" )] async fn content_the_store_already_holds_is_not_downloaded() { let store = tempfile::tempdir().expect("tempdir"); diff --git a/pnpm/crates/pnpr-client/src/lib.rs b/pnpm/crates/pnpr-client/src/lib.rs index d6adfd69d4..f86b060796 100644 --- a/pnpm/crates/pnpr-client/src/lib.rs +++ b/pnpm/crates/pnpr-client/src/lib.rs @@ -36,13 +36,15 @@ use reqwest::Client; pub use pnpm_shared_artifact_protocol::{ ARTIFACT_KIND, ArtifactBlobRequest, ArtifactBlobUpload, ArtifactCandidate, ArtifactFile, ArtifactManifest, ArtifactPayload, BuilderProfile, COMPATIBILITY_TAG_SCHEMA, - CompatibilityConstraints, INPUT_KEY_PREFIX, LinuxGlibcPlatform, OwnerScope, PackageIdentity, - PublishArtifactRequest, ResolveArtifactsRequest, SIGNATURE_ALGORITHM, SignedArtifactEnvelope, - blob_id, linux_glibc_supported_tags, linux_glibc_tag, platform_fingerprint, + CompatibilityConstraints, INPUT_KEY_PREFIX, LinuxGlibcPlatform, MacOsPlatform, OwnerScope, + PackageIdentity, PublishArtifactRequest, ResolveArtifactsRequest, SIGNATURE_ALGORITHM, + SignedArtifactEnvelope, WindowsPlatform, blob_id, linux_glibc_supported_tags, linux_glibc_tag, + macos_supported_tags, macos_tag, platform_fingerprint, windows_supported_tags, windows_tag, }; use pnpm_shared_artifact_protocol::{ MAX_CANDIDATES, MAX_FILE_SIZE, MAX_RESOLVE_RESPONSE_SIZE, MAX_VARIANTS_PER_CANDIDATE, - ResolveArtifactsResponse, compatibility_rank, validate_supported_tags, verify_blob, + ResolveArtifactsResponse, compatibility_rank_prevalidated, validate_supported_tags, + verify_blob, }; /// The `registries` a request declares, keyed by registry URL. @@ -590,7 +592,7 @@ impl PnprClient { artifact.key, ))); } - let mut best: Option<(usize, String, VerifiedArtifact)> = None; + let mut best: Option<(u64, String, VerifiedArtifact)> = None; for variant in artifact.variants { let Some(public_key) = opts.trusted_keys.get(&variant.envelope.key_id) else { continue; @@ -635,7 +637,8 @@ impl PnprClient { if !artifact_matches_candidate(&payload, candidate) { continue; } - let Some(rank) = compatibility_rank(&payload.compatibility, &opts.supported_tags) + let Some(rank) = + compatibility_rank_prevalidated(&payload.compatibility, &opts.supported_tags) else { continue; }; diff --git a/pnpm/crates/shared-artifact-protocol/src/lib.rs b/pnpm/crates/shared-artifact-protocol/src/lib.rs index 63ace75f9d..5286e2add2 100644 --- a/pnpm/crates/shared-artifact-protocol/src/lib.rs +++ b/pnpm/crates/shared-artifact-protocol/src/lib.rs @@ -35,6 +35,8 @@ pub const MAX_SIGNATURE_SIZE: usize = 72; pub const MAX_ENCODED_SIGNED_PAYLOAD_SIZE: usize = MAX_SIGNED_PAYLOAD_SIZE.div_ceil(3) * 4; pub const MAX_ENCODED_SIGNATURE_SIZE: usize = MAX_SIGNATURE_SIZE.div_ceil(3) * 4; pub const MAX_RESOLVE_RESPONSE_SIZE: usize = 16 * 1024 * 1024; +const COMPATIBILITY_FLOOR_RANK_OFFSET: u64 = 64; +const COMPATIBILITY_FLOOR_RANK_STRIDE: u64 = 1_000_000_000_000; #[derive(Debug, Display, Error)] pub enum ArtifactProtocolError { @@ -70,6 +72,23 @@ pub struct LinuxGlibcPlatform<'a> { pub glibc_minor: u32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MacOsPlatform<'a> { + pub architecture: &'a str, + pub node_major: u32, + pub macos_major: u32, + pub macos_minor: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowsPlatform<'a> { + pub architecture: &'a str, + pub node_major: u32, + pub windows_major: u32, + pub windows_minor: u32, + pub windows_build: u32, +} + impl OwnerScope { #[must_use] pub fn organization(name: impl Into) -> Self { @@ -611,13 +630,38 @@ pub fn verify_blob(integrity: &str, bytes: &[u8]) -> Result<(), ArtifactProtocol pub fn compatibility_rank( constraints: &CompatibilityConstraints, supported_tags: &[String], -) -> Option { +) -> Option { validate_supported_tags(supported_tags).ok()?; + compatibility_rank_prevalidated(constraints, supported_tags) +} + +#[must_use] +pub fn compatibility_rank_prevalidated( + constraints: &CompatibilityConstraints, + supported_tags: &[String], +) -> Option { match constraints { - CompatibilityConstraints::Universal => Some(supported_tags.len()), - CompatibilityConstraints::Tagged { tags } => { - supported_tags.iter().position(|supported| tags.iter().any(|tag| tag == supported)) - } + CompatibilityConstraints::Universal => Some(u64::MAX), + CompatibilityConstraints::Tagged { tags } => supported_tags + .iter() + .enumerate() + .flat_map(|(index, supported)| { + tags.iter().filter_map(move |artifact| { + if artifact == supported { + return u64::try_from(index).ok(); + } + let distance = version_floor_rank( + parse_compatibility_tag(supported).ok()?, + parse_compatibility_tag(artifact).ok()?, + )?; + u64::try_from(index) + .ok()? + .checked_mul(COMPATIBILITY_FLOOR_RANK_STRIDE)? + .checked_add(COMPATIBILITY_FLOOR_RANK_OFFSET)? + .checked_add(distance) + }) + }) + .min(), } } @@ -654,6 +698,37 @@ pub fn linux_glibc_supported_tags( .collect() } +pub fn macos_tag(platform: MacOsPlatform<'_>) -> Result { + let MacOsPlatform { architecture, node_major, macos_major, macos_minor } = platform; + let tag = format!( + "{COMPATIBILITY_TAG_SCHEMA}:darwin-{architecture}-node{node_major}-macos{macos_major}.{macos_minor}", + ); + validate_compatibility_tag(&tag)?; + Ok(tag) +} + +pub fn macos_supported_tags( + platform: MacOsPlatform<'_>, +) -> Result, ArtifactProtocolError> { + Ok(vec![macos_tag(platform)?]) +} + +pub fn windows_tag(platform: WindowsPlatform<'_>) -> Result { + let WindowsPlatform { architecture, node_major, windows_major, windows_minor, windows_build } = + platform; + let tag = format!( + "{COMPATIBILITY_TAG_SCHEMA}:win32-{architecture}-node{node_major}-windows{windows_major}.{windows_minor}.{windows_build}", + ); + validate_compatibility_tag(&tag)?; + Ok(tag) +} + +pub fn windows_supported_tags( + platform: WindowsPlatform<'_>, +) -> Result, ArtifactProtocolError> { + Ok(vec![windows_tag(platform)?]) +} + pub fn platform_fingerprint(supported_tags: &[String]) -> Result { validate_supported_tags(supported_tags)?; let mut hasher = Sha256::new(); @@ -739,30 +814,142 @@ fn validate_compatibility( } fn validate_compatibility_tag(tag: &str) -> Result<(), ArtifactProtocolError> { + parse_compatibility_tag(tag).map(|_| ()) +} + +enum ParsedCompatibilityTag<'a> { + Linux, + MacOs(MacOsPlatform<'a>), + Windows(WindowsPlatform<'a>), +} + +fn parse_compatibility_tag(tag: &str) -> Result, ArtifactProtocolError> { validate_scalar("compatibility tag", tag, 512)?; let Some(platform) = tag.strip_prefix("pnpm:v1:") else { return Err(invalid_tag("unknown compatibility tag schema")); }; let mut parts = platform.split('-'); - let (Some(os), Some(architecture), Some(node), Some(libc), None) = + let (Some(os), Some(architecture), Some(node), Some(runtime), None) = (parts.next(), parts.next(), parts.next(), parts.next(), parts.next()) else { return Err(invalid_tag("compatibility tag has the wrong number of dimensions")); }; - if os != "linux" || !matches!(architecture, "x64" | "arm64") { - return Err(invalid_tag("v1 only defines Linux x64 and arm64 tags")); + if !matches!(architecture, "x64" | "arm64") { + return Err(invalid_tag("v1 only defines x64 and arm64 tags")); } - parse_canonical_number( + let node_major = parse_canonical_number( node.strip_prefix("node").ok_or_else(|| invalid_tag("missing Node dimension"))?, "Node major version", false, )?; - let libc = libc.strip_prefix("glibc").ok_or_else(|| invalid_tag("missing glibc floor"))?; - let (major, minor) = - libc.split_once('.').ok_or_else(|| invalid_tag("glibc floor must be major.minor"))?; - parse_canonical_number(major, "glibc major version", false)?; - parse_canonical_number(minor, "glibc minor version", true)?; - Ok(()) + match os { + "linux" => { + let libc = + runtime.strip_prefix("glibc").ok_or_else(|| invalid_tag("missing glibc floor"))?; + let (major, minor) = libc + .split_once('.') + .ok_or_else(|| invalid_tag("glibc floor must be major.minor"))?; + parse_canonical_number(major, "glibc major version", false)?; + parse_canonical_number(minor, "glibc minor version", true)?; + Ok(ParsedCompatibilityTag::Linux) + } + "darwin" => { + let macos = + runtime.strip_prefix("macos").ok_or_else(|| invalid_tag("missing macOS floor"))?; + let (major, minor) = macos + .split_once('.') + .ok_or_else(|| invalid_tag("macOS floor must be major.minor"))?; + let macos_major = parse_macos_version_component(major, "macOS major version", false)?; + let macos_minor = parse_macos_version_component(minor, "macOS minor version", true)?; + Ok(ParsedCompatibilityTag::MacOs(MacOsPlatform { + architecture, + node_major, + macos_major, + macos_minor, + })) + } + "win32" => { + let windows = runtime + .strip_prefix("windows") + .ok_or_else(|| invalid_tag("missing Windows floor"))?; + let mut components = windows.split('.'); + let (Some(major), Some(minor), Some(build), None) = + (components.next(), components.next(), components.next(), components.next()) + else { + return Err(invalid_tag("Windows floor must be major.minor.build")); + }; + let windows_major = + parse_windows_version_component(major, "Windows major version", false, 1_000)?; + let windows_minor = + parse_windows_version_component(minor, "Windows minor version", true, 1_000)?; + let windows_build = + parse_windows_version_component(build, "Windows build number", false, 1_000_000)?; + Ok(ParsedCompatibilityTag::Windows(WindowsPlatform { + architecture, + node_major, + windows_major, + windows_minor, + windows_build, + })) + } + _ => Err(invalid_tag("v1 only defines Linux, macOS, and Windows tags")), + } +} + +fn parse_macos_version_component( + value: &str, + label: &str, + allow_zero: bool, +) -> Result { + let number = parse_canonical_number(value, label, allow_zero)?; + if number >= 1_000_000 { + return Err(invalid_tag(&format!("{label} is too large"))); + } + Ok(number) +} + +fn macos_version_rank(platform: MacOsPlatform<'_>) -> u64 { + u64::from(platform.macos_major) * 1_000_000 + u64::from(platform.macos_minor) +} + +fn parse_windows_version_component( + value: &str, + label: &str, + allow_zero: bool, + exclusive_maximum: u32, +) -> Result { + let number = parse_canonical_number(value, label, allow_zero)?; + if number >= exclusive_maximum { + return Err(invalid_tag(&format!("{label} is too large"))); + } + Ok(number) +} + +fn windows_version_rank(platform: WindowsPlatform<'_>) -> u64 { + u64::from(platform.windows_major) * 1_000_000_000 + + u64::from(platform.windows_minor) * 1_000_000 + + u64::from(platform.windows_build) +} + +fn version_floor_rank( + consumer: ParsedCompatibilityTag<'_>, + artifact: ParsedCompatibilityTag<'_>, +) -> Option { + match (consumer, artifact) { + (ParsedCompatibilityTag::MacOs(consumer), ParsedCompatibilityTag::MacOs(artifact)) + if consumer.architecture == artifact.architecture + && consumer.node_major == artifact.node_major => + { + macos_version_rank(consumer).checked_sub(macos_version_rank(artifact)) + } + (ParsedCompatibilityTag::Windows(consumer), ParsedCompatibilityTag::Windows(artifact)) + if consumer.architecture == artifact.architecture + && consumer.node_major == artifact.node_major => + { + windows_version_rank(consumer).checked_sub(windows_version_rank(artifact)) + } + _ => None, + } } fn parse_canonical_number( diff --git a/pnpm/crates/shared-artifact-protocol/src/tests.rs b/pnpm/crates/shared-artifact-protocol/src/tests.rs index 070721e525..4d0801ddc1 100644 --- a/pnpm/crates/shared-artifact-protocol/src/tests.rs +++ b/pnpm/crates/shared-artifact-protocol/src/tests.rs @@ -10,10 +10,11 @@ use sha2::{Digest as _, Sha512}; use crate::{ ARTIFACT_KIND, ArtifactBlobUpload, ArtifactFile, ArtifactManifest, ArtifactPayload, - BuilderProfile, CompatibilityConstraints, LinuxGlibcPlatform, OwnerScope, PackageIdentity, - PublishArtifactRequest, SIGNATURE_ALGORITHM, SignedArtifactEnvelope, blob_id, - compatibility_rank, linux_glibc_supported_tags, linux_glibc_tag, platform_fingerprint, - validate_manifest_path, verify_blob, + BuilderProfile, CompatibilityConstraints, LinuxGlibcPlatform, MacOsPlatform, OwnerScope, + PackageIdentity, PublishArtifactRequest, SIGNATURE_ALGORITHM, SignedArtifactEnvelope, + WindowsPlatform, blob_id, compatibility_rank, linux_glibc_supported_tags, linux_glibc_tag, + macos_supported_tags, macos_tag, platform_fingerprint, validate_manifest_path, verify_blob, + windows_supported_tags, windows_tag, }; fn integrity(bytes: &[u8]) -> String { @@ -24,6 +25,19 @@ fn linux(architecture: &str, glibc_minor: u32) -> LinuxGlibcPlatform<'_> { LinuxGlibcPlatform { architecture, node_major: 22, glibc_major: 2, glibc_minor } } +fn macos(architecture: &str, macos_major: u32, macos_minor: u32) -> MacOsPlatform<'_> { + MacOsPlatform { architecture, node_major: 22, macos_major, macos_minor } +} + +fn windows( + architecture: &str, + windows_major: u32, + windows_minor: u32, + windows_build: u32, +) -> WindowsPlatform<'_> { + WindowsPlatform { architecture, node_major: 22, windows_major, windows_minor, windows_build } +} + fn payload(file_integrity: String) -> ArtifactPayload { ArtifactPayload { kind: ARTIFACT_KIND.to_string(), @@ -273,7 +287,7 @@ fn compatibility_uses_the_consumers_preference_order() { assert_eq!(compatibility_rank(&fallback, &supported), Some(22)); assert_eq!( compatibility_rank(&CompatibilityConstraints::Universal, &supported), - Some(supported.len()), + Some(u64::MAX), ); assert_eq!( compatibility_rank( @@ -286,6 +300,109 @@ fn compatibility_uses_the_consumers_preference_order() { ); } +#[test] +fn macos_compatibility_uses_product_version_floors() { + let supported = macos_supported_tags(macos("arm64", 15, 5)).unwrap(); + assert_eq!(supported, ["pnpm:v1:darwin-arm64-node22-macos15.5"]); + assert_eq!( + platform_fingerprint(&supported).unwrap(), + "b56fa5629b56d18308bbf7978d61b9afaf862e133ad18aef31588e0888eef3f8", + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![macos_tag(macos("arm64", 15, 4)).unwrap()], + }, + &supported, + ), + Some(65), + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![macos_tag(macos("arm64", 14, 6)).unwrap()], + }, + &supported, + ), + Some(1_000_063), + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![macos_tag(macos("arm64", 16, 0)).unwrap()], + }, + &supported, + ), + None, + ); + assert_eq!( + compatibility_rank(&CompatibilityConstraints::Universal, &supported), + Some(u64::MAX), + ); + + let multiple_supported = + vec![macos_tag(macos("arm64", 15, 5)).unwrap(), macos_tag(macos("arm64", 14, 6)).unwrap()]; + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![macos_tag(macos("arm64", 14, 6)).unwrap()], + }, + &multiple_supported, + ), + Some(1), + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![macos_tag(macos("arm64", 15, 4)).unwrap()], + }, + &multiple_supported, + ), + Some(65), + ); +} + +#[test] +fn windows_compatibility_uses_kernel_version_floors() { + let supported = windows_supported_tags(windows("x64", 10, 0, 26_100)).unwrap(); + assert_eq!(supported, ["pnpm:v1:win32-x64-node22-windows10.0.26100"]); + assert_eq!( + platform_fingerprint(&supported).unwrap(), + "f5590f12a6d651acdcb3b60d7d25a5d2e1ad2f5af3e53d841391dec9e871c46e", + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![windows_tag(windows("x64", 10, 0, 22_621)).unwrap()], + }, + &supported, + ), + Some(3_543), + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![windows_tag(windows("x64", 6, 3, 9_600)).unwrap()], + }, + &supported, + ), + Some(3_997_016_564), + ); + assert_eq!( + compatibility_rank( + &CompatibilityConstraints::Tagged { + tags: vec![windows_tag(windows("x64", 10, 0, 26_101)).unwrap()], + }, + &supported, + ), + None, + ); + assert_eq!( + compatibility_rank(&CompatibilityConstraints::Universal, &supported), + Some(u64::MAX), + ); +} + #[test] fn compatibility_tags_and_platform_fingerprints_are_canonical() { let supported = linux_glibc_supported_tags(linux("x64", 3)).unwrap(); @@ -306,6 +423,10 @@ fn compatibility_tags_and_platform_fingerprints_are_canonical() { for invalid in [ "pnpm:v2:linux-x64-node22-glibc2.17", "pnpm:v1:darwin-x64-node22-glibc2.17", + "pnpm:v1:darwin-x64-node22-macos15", + "pnpm:v1:darwin-x64-node22-macos015.5", + "pnpm:v1:win32-x64-node22-windows10.0", + "pnpm:v1:win32-x64-node22-windows10.0.026100", "pnpm:v1:linux-x64-node022-glibc2.17", "pnpm:v1:linux-x64-node22-glibc02.17", "pnpm:v1:linux-x64-node22-glibc2", diff --git a/pnpr/client/README.md b/pnpr/client/README.md index 58868d9406..4969bdc130 100644 --- a/pnpr/client/README.md +++ b/pnpr/client/README.md @@ -72,7 +72,7 @@ remoteSideEffectsCache: `requiresBuild: true`, pass `allowBuilds`, and have a verified source integrity. `--ignore-scripts` disables remote reuse. An unavailable server, invalid signature, incompatible platform, or bad blob falls back to the ordinary local -build. The PoC supports Linux glibc on x64 and arm64. +build. The PoC supports Linux glibc, macOS, and Windows on x64 and arm64. ### Trust material @@ -211,12 +211,14 @@ artifacts. ### v0 compatibility tags -The PoC intentionally defines one narrow tagged platform vocabulary rather +The PoC intentionally defines an explicit tagged platform vocabulary rather than interpreting unknown producer claims. `universal` is the positive claim -for platform-independent output. The only `tagged` form is: +for platform-independent output. The `tagged` forms are: ```text pnpm:v1:linux--node-glibc. +pnpm:v1:darwin--node-macos. +pnpm:v1:win32--node-windows.. ``` `architecture` is `x64` or `arm64`; all numeric components are canonical @@ -224,15 +226,25 @@ unsigned decimals without leading zeroes. A consumer generates tags for its glibc version down to minor zero, most recent floor first. For example, glibc 2.3 advertises `glibc2.3`, `glibc2.2`, `glibc2.1`, and `glibc2.0`. Matching is exact against that ordered set, so an artifact tagged with a 2.1 floor serves a -2.3 consumer. Tagged matches beat `universal`; equal-rank variants are ordered -by ascending signed-envelope digest. Unknown schemas, platforms, dimensions, -or malformed tags are misses. Other platforms and libc families remain out of -the PoC instead of being treated as compatible guesses. +2.3 consumer. + +A macOS consumer advertises one tag containing its product-version major and +minor. A Windows consumer likewise advertises one tag containing its NT kernel +major, minor, and build number. These versions are parsed minimum-runtime +floors: the operating system, architecture, and Node major must match exactly, +and the consumer version must be at least the artifact version. Exact matches +precede derived floor matches, and the greatest compatible floor wins. Tagged +matches beat `universal`; equal-rank variants are ordered by ascending +signed-envelope digest. Unknown schemas, platforms, dimensions, or malformed +tags are misses. Other operating systems and libc families remain out of the +PoC instead of being treated as compatible guesses. `platformFingerprint` is SHA-256 over the ASCII bytes `pnpm-platform-fingerprint-v1\0`, followed by every canonical supported tag in preference order and a NUL byte after each tag. Duplicate tags and lists longer -than 64 entries are rejected. +than 64 entries are rejected. macOS and Windows therefore fingerprint their +one exact consumer tag, so a macOS minor or Windows kernel-build change creates +a distinct platform fingerprint. ### Signed envelope and blobs diff --git a/pnpr/client/src/index.ts b/pnpr/client/src/index.ts index b290443724..105503a171 100644 --- a/pnpr/client/src/index.ts +++ b/pnpr/client/src/index.ts @@ -22,12 +22,16 @@ export { type BuilderProfile, COMPATIBILITY_TAG_SCHEMA, type CompatibilityConstraints, + compatibilityRank, createSignedArtifactEnvelope, downloadSharedArtifactBlob, INPUT_KEY_PREFIX, linuxGlibcCompatibilityTag, type LinuxGlibcPlatform, linuxGlibcSupportedTags, + macOSCompatibilityTag, + type MacOSPlatform, + macOSSupportedTags, type OwnerScope, type PackageIdentity, platformFingerprint, @@ -42,4 +46,7 @@ export { verifySignedArtifactEnvelope, verifyStoredSharedSideEffects, type VerifyStoredSharedSideEffectsOptions, + windowsCompatibilityTag, + type WindowsPlatform, + windowsSupportedTags, } from './sharedSideEffects.js' diff --git a/pnpr/client/src/installSharedSideEffects.ts b/pnpr/client/src/installSharedSideEffects.ts index b10f4447be..66ad43c6f1 100644 --- a/pnpr/client/src/installSharedSideEffects.ts +++ b/pnpr/client/src/installSharedSideEffects.ts @@ -1,5 +1,7 @@ +import { execFileSync } from 'node:child_process' import { createPrivateKey } from 'node:crypto' import fs from 'node:fs/promises' +import { release as osRelease } from 'node:os' import util from 'node:util' import { calcDepState, calcDepStateInputKey, type DepsGraph, type DepsStateCache } from '@pnpm/deps.graph-hasher' @@ -18,7 +20,11 @@ import { createSignedArtifactEnvelope, downloadSharedArtifactBlob, linuxGlibcCompatibilityTag, + type LinuxGlibcPlatform, linuxGlibcSupportedTags, + macOSCompatibilityTag, + type MacOSPlatform, + macOSSupportedTags, ownerNamespace, platformFingerprint, pnprSupportsSharedSideEffects, @@ -28,6 +34,9 @@ import { type SignedArtifactEnvelope, type VerifiedArtifact, verifyStoredSharedSideEffects, + windowsCompatibilityTag, + type WindowsPlatform, + windowsSupportedTags, } from './sharedSideEffects.js' export interface RemoteSideEffectsInstallNode { @@ -80,6 +89,11 @@ export interface RemoteSideEffectsRestorer { restore: (node: RemoteSideEffectsInstallNode) => Promise } +type ArtifactPlatform = + | { kind: 'linuxGlibc', platform: LinuxGlibcPlatform } + | { kind: 'macOS', platform: MacOSPlatform } + | { kind: 'windows', platform: WindowsPlatform } + /** * How long the first queued candidate waits for company before its lookup * leaves. Long enough to gather the packages whose fetches land together, @@ -108,23 +122,23 @@ export function canRestoreRemoteSideEffects (opts: RemoteSideEffectsPrerequisite (opts.settings.packages?.length ?? 0) > 0 && Object.keys(opts.settings.trustedKeys ?? {}).length > 0 && !opts.ignoreScripts && - currentLinuxGlibcPlatform(opts.nodeVersion) != null + currentArtifactPlatform(opts.nodeVersion) != null } export function createRemoteSideEffectsRestorer ( opts: RemoteSideEffectsRestorerOptions ): RemoteSideEffectsRestorer | undefined { if (!canRestoreRemoteSideEffects(opts)) return undefined - const platform = currentLinuxGlibcPlatform(opts.nodeVersion) + const artifactPlatform = currentArtifactPlatform(opts.nodeVersion) const { pnprServer, settings } = opts const organization = settings?.organization - if (platform == null || settings == null || organization == null) return undefined + if (artifactPlatform == null || settings == null || organization == null) return undefined const registryUrl = pnprServer const ownerName = organization const owner = { type: 'organization', name: ownerName } as const let supportedTags: string[] try { - supportedTags = linuxGlibcSupportedTags(platform) + supportedTags = artifactSupportedTags(artifactPlatform) } catch (err: unknown) { opts.warn?.(`Remote side-effects platform is unsupported: ${errorMessage(err)}`) return undefined @@ -607,9 +621,9 @@ export async function publishBuiltSharedSideEffects ( ) return const { builderId, keyId, organization, privateKey } = opts.settings if (organization == null) return - const platform = currentLinuxGlibcPlatform(opts.nodeVersion) + const artifactPlatform = currentArtifactPlatform(opts.nodeVersion) const sourceIntegrity = verifiedIntegrity(opts.resolution) - if (keyId == null || privateKey == null || builderId == null || platform == null || sourceIntegrity == null) return + if (keyId == null || privateKey == null || builderId == null || artifactPlatform == null || sourceIntegrity == null) return const manifest = await artifactManifest(opts.upload) if (manifest == null) return const inputKey = calcDepStateInputKey({ @@ -632,7 +646,7 @@ export async function publishBuiltSharedSideEffects ( }, compatibility: { kind: 'tagged', - tags: [linuxGlibcCompatibilityTag(platform)], + tags: [artifactCompatibilityTag(artifactPlatform)], }, manifest: manifest.manifest, } @@ -683,25 +697,96 @@ async function artifactManifest (upload: UploadPkgToStoreResult): Promise<{ } } -function currentLinuxGlibcPlatform (nodeVersion?: string): { - architecture: string - nodeMajor: number - glibcMajor: number - glibcMinor: number -} | undefined { - if (process.platform !== 'linux' || !['x64', 'arm64'].includes(process.arch)) return undefined - const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } - const glibc = report.header?.glibcVersionRuntime?.split('.') - const nodeMajor = Number((nodeVersion ?? process.version).replace(/^v/, '').split('.')[0]) - const glibcMajor = Number(glibc?.[0]) - const glibcMinor = Number(glibc?.[1]) - if (![nodeMajor, glibcMajor, glibcMinor].every(Number.isSafeInteger)) return undefined - return { - architecture: process.arch, - nodeMajor, - glibcMajor, - glibcMinor, +function currentArtifactPlatform (nodeVersion?: string): ArtifactPlatform | undefined { + if (!['x64', 'arm64'].includes(process.arch)) return undefined + const version = nodeVersion ?? process.version + const nodeMajor = Number((version.startsWith('v') ? version.slice(1) : version).split('.')[0]) + if (!Number.isSafeInteger(nodeMajor) || nodeMajor <= 0) return undefined + if (process.platform === 'linux') { + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } + const [glibcMajor, glibcMinor] = report.header?.glibcVersionRuntime?.split('.').map(Number) ?? [] + if (![glibcMajor, glibcMinor].every(Number.isSafeInteger)) return undefined + return { + kind: 'linuxGlibc', + platform: { architecture: process.arch, nodeMajor, glibcMajor, glibcMinor }, + } } + if (process.platform === 'darwin') { + const version = macOSProductVersion() + if (version == null) return undefined + return { + kind: 'macOS', + platform: { + architecture: process.arch, + nodeMajor, + macOSMajor: version.major, + macOSMinor: version.minor, + }, + } + } + if (process.platform === 'win32') { + const version = windowsKernelVersion(osRelease()) + if (version == null) return undefined + return { + kind: 'windows', + platform: { + architecture: process.arch, + nodeMajor, + windowsMajor: version.major, + windowsMinor: version.minor, + windowsBuild: version.build, + }, + } + } + return undefined +} + +function artifactCompatibilityTag (artifactPlatform: ArtifactPlatform): string { + switch (artifactPlatform.kind) { + case 'linuxGlibc': return linuxGlibcCompatibilityTag(artifactPlatform.platform) + case 'macOS': return macOSCompatibilityTag(artifactPlatform.platform) + case 'windows': return windowsCompatibilityTag(artifactPlatform.platform) + } +} + +function artifactSupportedTags (artifactPlatform: ArtifactPlatform): string[] { + switch (artifactPlatform.kind) { + case 'linuxGlibc': return linuxGlibcSupportedTags(artifactPlatform.platform) + case 'macOS': return macOSSupportedTags(artifactPlatform.platform) + case 'windows': return windowsSupportedTags(artifactPlatform.platform) + } +} + +let cachedMacOSProductVersion: { major: number, minor: number } | null | undefined + +function macOSProductVersion (): { major: number, minor: number } | undefined { + if (cachedMacOSProductVersion !== undefined) return cachedMacOSProductVersion ?? undefined + try { + const [major, minor] = execFileSync('/usr/bin/sw_vers', ['-productVersion'], { + encoding: 'utf8', + timeout: 5_000, + }).trim().split('.').map(Number) + cachedMacOSProductVersion = + Number.isSafeInteger(major) && major > 0 && major < 1_000_000 && + Number.isSafeInteger(minor) && minor >= 0 && minor < 1_000_000 + ? { major, minor } + : null + } catch { + cachedMacOSProductVersion = null + } + return cachedMacOSProductVersion ?? undefined +} + +function windowsKernelVersion (release: string): { major: number, minor: number, build: number } | undefined { + const components = release.split('.') + if (components.length !== 3) return undefined + const [major, minor, build] = components.map(Number) + if ( + !Number.isSafeInteger(major) || major <= 0 || major >= 1_000 || + !Number.isSafeInteger(minor) || minor < 0 || minor >= 1_000 || + !Number.isSafeInteger(build) || build <= 0 || build >= 1_000_000 + ) return undefined + return { major, minor, build } } function verifiedIntegrity (resolution: LockfileResolution): string | undefined { diff --git a/pnpr/client/src/sharedSideEffects.ts b/pnpr/client/src/sharedSideEffects.ts index 85b328fb3d..627302e760 100644 --- a/pnpr/client/src/sharedSideEffects.ts +++ b/pnpr/client/src/sharedSideEffects.ts @@ -45,6 +45,21 @@ export interface LinuxGlibcPlatform { glibcMinor: number } +export interface MacOSPlatform { + architecture: string + nodeMajor: number + macOSMajor: number + macOSMinor: number +} + +export interface WindowsPlatform { + architecture: string + nodeMajor: number + windowsMajor: number + windowsMinor: number + windowsBuild: number +} + export interface BuilderProfile { imageDigest?: string architectureBaseline: string @@ -293,7 +308,7 @@ export async function resolveSharedSideEffects ( payload.sourceIntegrity !== candidate.sourceIntegrity || !ownersEqual(payload.owner, candidate.owner) ) continue - const rank = compatibilityRank(payload.compatibility, opts.supportedTags) + const rank = rankCompatibility(payload.compatibility, opts.supportedTags) if (rank == null) continue const pinnedDigest = opts.pinnedEnvelopeDigests?.get(candidate.key) if (pinnedDigest != null && digest !== pinnedDigest) continue @@ -676,12 +691,48 @@ function insertUniquePath (path: string, exact: Set, folded: Set folded.add(caseFolded) } -function compatibilityRank (constraints: CompatibilityConstraints, supportedTags: string[]): number | undefined { - if (constraints.kind === 'universal') return supportedTags.length - for (let index = 0; index < supportedTags.length; index++) { - if (constraints.tags.includes(supportedTags[index])) return index +export function compatibilityRank (constraints: CompatibilityConstraints, supportedTags: string[]): number | undefined { + try { + validateSupportedTags(supportedTags) + } catch { + return undefined } - return undefined + return rankCompatibility(constraints, supportedTags) +} + +function rankCompatibility (constraints: CompatibilityConstraints, supportedTags: string[]): number | undefined { + if (constraints.kind === 'universal') return Number.MAX_SAFE_INTEGER + let bestRank: number | undefined + for (let index = 0; index < supportedTags.length; index++) { + const supportedTag = supportedTags[index] + for (const artifactTag of constraints.tags) { + if (artifactTag === supportedTag) { + bestRank = Math.min(bestRank ?? index, index) + continue + } + let consumer: VersionedPlatform | undefined + let artifact: VersionedPlatform | undefined + try { + consumer = parseVersionedCompatibilityTag(supportedTag) + artifact = parseVersionedCompatibilityTag(artifactTag) + } catch { + return undefined + } + if ( + consumer == null || + artifact == null || + consumer.kind !== artifact.kind || + consumer.platform.architecture !== artifact.platform.architecture || + consumer.platform.nodeMajor !== artifact.platform.nodeMajor + ) continue + const consumerVersion = versionedPlatformRank(consumer) + const artifactVersion = versionedPlatformRank(artifact) + if (consumerVersion < artifactVersion) continue + const rank = 64 + index * 1_000_000_000_000 + consumerVersion - artifactVersion + bestRank = Math.min(bestRank ?? rank, rank) + } + } + return bestRank } export function linuxGlibcCompatibilityTag ( @@ -711,6 +762,32 @@ export function linuxGlibcSupportedTags ( ) } +export function macOSSupportedTags (platform: MacOSPlatform): string[] { + return [macOSCompatibilityTag(platform)] +} + +export function macOSCompatibilityTag ( + platform: MacOSPlatform +): string { + const { architecture, nodeMajor, macOSMajor, macOSMinor } = platform + const tag = `${COMPATIBILITY_TAG_SCHEMA}:darwin-${architecture}-node${nodeMajor}-macos${macOSMajor}.${macOSMinor}` + validateCompatibilityTag(tag) + return tag +} + +export function windowsSupportedTags (platform: WindowsPlatform): string[] { + return [windowsCompatibilityTag(platform)] +} + +export function windowsCompatibilityTag ( + platform: WindowsPlatform +): string { + const { architecture, nodeMajor, windowsMajor, windowsMinor, windowsBuild } = platform + const tag = `${COMPATIBILITY_TAG_SCHEMA}:win32-${architecture}-node${nodeMajor}-windows${windowsMajor}.${windowsMinor}.${windowsBuild}` + validateCompatibilityTag(tag) + return tag +} + export function platformFingerprint (supportedTags: string[]): string { validateSupportedTags(supportedTags) const hash = createHash('sha256').update('pnpm-platform-fingerprint-v1\0') @@ -737,16 +814,75 @@ function validateCompatibilityTag (tag: string): void { } const parts = tag.slice(COMPATIBILITY_TAG_SCHEMA.length + 1).split('-') if (parts.length !== 4) throw new Error('Shared artifact compatibility tag has the wrong number of dimensions') - const [os, architecture, node, libc] = parts - if (os !== 'linux' || !['x64', 'arm64'].includes(architecture)) { - throw new Error('Shared artifact compatibility tag only supports Linux x64 and arm64 in v1') + const [os, architecture, node, runtime] = parts + if (!['x64', 'arm64'].includes(architecture)) { + throw new Error('Shared artifact compatibility tag only supports x64 and arm64 in v1') } parseCanonicalNumber(node.startsWith('node') ? node.slice(4) : '', 'Node major version', false) - const glibc = libc.startsWith('glibc') ? libc.slice(5) : '' - const version = glibc.split('.') - if (version.length !== 2) throw new Error('Shared artifact glibc floor must be major.minor') - parseCanonicalNumber(version[0], 'glibc major version', false) - parseCanonicalNumber(version[1], 'glibc minor version', true) + if (os === 'linux') { + const glibc = runtime.startsWith('glibc') ? runtime.slice(5) : '' + const version = glibc.split('.') + if (version.length !== 2) throw new Error('Shared artifact glibc floor must be major.minor') + parseCanonicalNumber(version[0], 'glibc major version', false) + parseCanonicalNumber(version[1], 'glibc minor version', true) + } else if (os === 'darwin') { + const macOS = runtime.startsWith('macos') ? runtime.slice(5) : '' + const version = macOS.split('.') + if (version.length !== 2) throw new Error('Shared artifact macOS floor must be major.minor') + const major = parseCanonicalNumber(version[0], 'macOS major version', false) + const minor = parseCanonicalNumber(version[1], 'macOS minor version', true) + if (major >= 1_000_000 || minor >= 1_000_000) { + throw new Error('Shared artifact macOS version component is too large') + } + } else if (os === 'win32') { + const windows = runtime.startsWith('windows') ? runtime.slice(7) : '' + const version = windows.split('.') + if (version.length !== 3) throw new Error('Shared artifact Windows floor must be major.minor.build') + const major = parseCanonicalNumber(version[0], 'Windows major version', false) + const minor = parseCanonicalNumber(version[1], 'Windows minor version', true) + const build = parseCanonicalNumber(version[2], 'Windows build number', false) + if (major >= 1_000 || minor >= 1_000 || build >= 1_000_000) { + throw new Error('Shared artifact Windows version component is too large') + } + } else { + throw new Error('Shared artifact compatibility tag only supports Linux, macOS, and Windows in v1') + } +} + +type VersionedPlatform = + | { kind: 'macOS', platform: MacOSPlatform } + | { kind: 'windows', platform: WindowsPlatform } + +function parseVersionedCompatibilityTag (tag: string): VersionedPlatform | undefined { + validateCompatibilityTag(tag) + const [os, architecture, node, runtime] = tag.slice(COMPATIBILITY_TAG_SCHEMA.length + 1).split('-') + const nodeMajor = Number(node.slice(4)) + if (os === 'darwin') { + const [macOSMajor, macOSMinor] = runtime.slice(5).split('.').map(Number) + return { + kind: 'macOS', + platform: { architecture, nodeMajor, macOSMajor, macOSMinor }, + } + } + if (os === 'win32') { + const [windowsMajor, windowsMinor, windowsBuild] = runtime.slice(7).split('.').map(Number) + return { + kind: 'windows', + platform: { architecture, nodeMajor, windowsMajor, windowsMinor, windowsBuild }, + } + } + return undefined +} + +function versionedPlatformRank (versionedPlatform: VersionedPlatform): number { + if (versionedPlatform.kind === 'macOS') { + return versionedPlatform.platform.macOSMajor * 1_000_000 + versionedPlatform.platform.macOSMinor + } + return ( + versionedPlatform.platform.windowsMajor * 1_000_000_000 + + versionedPlatform.platform.windowsMinor * 1_000_000 + + versionedPlatform.platform.windowsBuild + ) } function parseCanonicalNumber (value: string, label: string, allowZero: boolean): number { diff --git a/pnpr/client/test/installSharedSideEffects.test.ts b/pnpr/client/test/installSharedSideEffects.test.ts index 46b795f724..599ba81924 100644 --- a/pnpr/client/test/installSharedSideEffects.test.ts +++ b/pnpr/client/test/installSharedSideEffects.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process' import { createHash, generateKeyPairSync } from 'node:crypto' import fs from 'node:fs/promises' import { createServer } from 'node:http' @@ -13,10 +14,11 @@ import { createRemoteSideEffectsRestorer, createSignedArtifactEnvelope, linuxGlibcCompatibilityTag, - type LinuxGlibcPlatform, + macOSCompatibilityTag, publishBuiltSharedSideEffects, type SignedArtifactEnvelope, verifySignedArtifactEnvelope, + windowsCompatibilityTag, } from '@pnpm/pnpr.client' import type { PackageFilesResponse, SideEffectsDiff, StoreController } from '@pnpm/store.controller-types' import type { DepPath } from '@pnpm/types' @@ -30,8 +32,8 @@ const builtFile = Buffer.from('compiled native addon') const builtFileIntegrity = `sha512-${createHash('sha512').update(builtFile).digest('base64')}` describe('install remote side-effects', () => { test('hydrates the store and selects a verified remote build', async () => { - const platform = currentLinuxGlibcPlatform() - if (platform == null) return + const compatibilityTag = currentArtifactCompatibilityTag() + if (compatibilityTag == null) return const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) const trustedKeys = { @@ -69,7 +71,7 @@ describe('install remote side-effects', () => { }, compatibility: { kind: 'tagged', - tags: [linuxGlibcCompatibilityTag(platform)], + tags: [compatibilityTag], }, manifest: { added: [ @@ -824,7 +826,7 @@ describe('install remote side-effects', () => { }) test('publishes a signed build diff produced by install', async () => { - if (currentLinuxGlibcPlatform() == null) return + if (currentArtifactCompatibilityTag() == null) return const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) let publishedBody: Buffer | undefined @@ -909,13 +911,27 @@ describe('install remote side-effects', () => { }) }) -function currentLinuxGlibcPlatform (): LinuxGlibcPlatform | undefined { - if (process.platform !== 'linux' || !['x64', 'arm64'].includes(process.arch)) return undefined - const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } - const [glibcMajor, glibcMinor] = report.header?.glibcVersionRuntime?.split('.').map(Number) ?? [] +function currentArtifactCompatibilityTag (): string | undefined { + if (!['x64', 'arm64'].includes(process.arch)) return undefined const nodeMajor = Number(process.versions.node.split('.')[0]) - if (![nodeMajor, glibcMajor, glibcMinor].every(Number.isSafeInteger)) return undefined - return { architecture: process.arch, nodeMajor, glibcMajor, glibcMinor } + if (!Number.isSafeInteger(nodeMajor)) return undefined + if (process.platform === 'linux') { + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } + const [glibcMajor, glibcMinor] = report.header?.glibcVersionRuntime?.split('.').map(Number) ?? [] + if (![glibcMajor, glibcMinor].every(Number.isSafeInteger)) return undefined + return linuxGlibcCompatibilityTag({ architecture: process.arch, nodeMajor, glibcMajor, glibcMinor }) + } + if (process.platform === 'darwin') { + const [macOSMajor, macOSMinor] = execFileSync('/usr/bin/sw_vers', ['-productVersion'], { + encoding: 'utf8', + }).trim().split('.').map(Number) + return macOSCompatibilityTag({ architecture: process.arch, nodeMajor, macOSMajor, macOSMinor }) + } + if (process.platform === 'win32') { + const [windowsMajor, windowsMinor, windowsBuild] = os.release().split('.').map(Number) + return windowsCompatibilityTag({ architecture: process.arch, nodeMajor, windowsMajor, windowsMinor, windowsBuild }) + } + return undefined } async function listen (server: ReturnType): Promise { diff --git a/pnpr/client/test/sharedSideEffects.test.ts b/pnpr/client/test/sharedSideEffects.test.ts index 0cbd17e719..7563c75b30 100644 --- a/pnpr/client/test/sharedSideEffects.test.ts +++ b/pnpr/client/test/sharedSideEffects.test.ts @@ -4,15 +4,20 @@ import { createServer } from 'node:http' import { describe, expect, test } from '@jest/globals' import { type ArtifactPayload, + compatibilityRank, createSignedArtifactEnvelope, downloadSharedArtifactBlob, linuxGlibcCompatibilityTag, linuxGlibcSupportedTags, + macOSCompatibilityTag, + macOSSupportedTags, platformFingerprint, publishSharedSideEffects, resolveSharedSideEffects, signedArtifactEnvelopeDigest, verifySignedArtifactEnvelope, + windowsCompatibilityTag, + windowsSupportedTags, } from '@pnpm/pnpr.client' const contents = Buffer.from('native-addon') @@ -180,9 +185,60 @@ describe('signed shared artifacts', () => { ]) expect(platformFingerprint(supportedTags)).toBe('fdfaaed730a56031779ee5e572e1e82aad454501ec5fbcfad6648e8a1e465f0c') + const macOSSupported = macOSSupportedTags(macOS(15, 5)) + expect(macOSSupported).toEqual(['pnpm:v1:darwin-arm64-node22-macos15.5']) + expect(platformFingerprint(macOSSupported)).toBe('b56fa5629b56d18308bbf7978d61b9afaf862e133ad18aef31588e0888eef3f8') + expect(compatibilityRank({ + kind: 'tagged', + tags: [macOSCompatibilityTag(macOS(15, 4))], + }, macOSSupported)).toBe(65) + expect(compatibilityRank({ + kind: 'tagged', + tags: [macOSCompatibilityTag(macOS(14, 6))], + }, macOSSupported)).toBe(1_000_063) + expect(compatibilityRank({ + kind: 'tagged', + tags: [macOSCompatibilityTag(macOS(16, 0))], + }, macOSSupported)).toBeUndefined() + expect(compatibilityRank({ kind: 'universal' }, macOSSupported)).toBe(Number.MAX_SAFE_INTEGER) + + const multipleMacOSSupported = [ + macOSCompatibilityTag(macOS(15, 5)), + macOSCompatibilityTag(macOS(14, 6)), + ] + expect(compatibilityRank({ + kind: 'tagged', + tags: [macOSCompatibilityTag(macOS(14, 6))], + }, multipleMacOSSupported)).toBe(1) + expect(compatibilityRank({ + kind: 'tagged', + tags: [macOSCompatibilityTag(macOS(15, 4))], + }, multipleMacOSSupported)).toBe(65) + + const windowsSupported = windowsSupportedTags(windows({ major: 10, minor: 0, build: 26_100 })) + expect(windowsSupported).toEqual(['pnpm:v1:win32-x64-node22-windows10.0.26100']) + expect(platformFingerprint(windowsSupported)).toBe('f5590f12a6d651acdcb3b60d7d25a5d2e1ad2f5af3e53d841391dec9e871c46e') + expect(compatibilityRank({ + kind: 'tagged', + tags: [windowsCompatibilityTag(windows({ major: 10, minor: 0, build: 22_621 }))], + }, windowsSupported)).toBe(3_543) + expect(compatibilityRank({ + kind: 'tagged', + tags: [windowsCompatibilityTag(windows({ major: 6, minor: 3, build: 9_600 }))], + }, windowsSupported)).toBe(3_997_016_564) + expect(compatibilityRank({ + kind: 'tagged', + tags: [windowsCompatibilityTag(windows({ major: 10, minor: 0, build: 26_101 }))], + }, windowsSupported)).toBeUndefined() + expect(compatibilityRank({ kind: 'universal' }, windowsSupported)).toBe(Number.MAX_SAFE_INTEGER) + for (const invalid of [ 'pnpm:v2:linux-x64-node22-glibc2.17', 'pnpm:v1:darwin-x64-node22-glibc2.17', + 'pnpm:v1:darwin-x64-node22-macos15', + 'pnpm:v1:darwin-x64-node22-macos015.5', + 'pnpm:v1:win32-x64-node22-windows10.0', + 'pnpm:v1:win32-x64-node22-windows10.0.026100', 'pnpm:v1:linux-x64-node022-glibc2.17', 'pnpm:v1:linux-x64-node22-glibc02.17', 'pnpm:v1:linux-x64-node22-glibc2', @@ -439,6 +495,25 @@ describe('signed shared artifacts', () => { }) }) +function macOS (macOSMajor: number, macOSMinor: number, architecture = 'arm64') { + return { architecture, nodeMajor: 22, macOSMajor, macOSMinor } +} + +function windows ({ major, minor, build, architecture = 'x64' }: { + major: number + minor: number + build: number + architecture?: string +}) { + return { + architecture, + nodeMajor: 22, + windowsMajor: major, + windowsMinor: minor, + windowsBuild: build, + } +} + async function listen (server: ReturnType): Promise { await new Promise((resolve, reject) => { server.once('error', reject)