From e972cb50126b2a60bd48b90278d5fbb8fbed32ae Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 28 Aug 2026 03:00:08 +0200 Subject: [PATCH] feat: support macOS and Windows shared build artifacts (#14263) Teach the shared-artifact protocol to recognize additive v1 Darwin and Windows tags that encode architecture, Node major, and a minimum operating-system version. Select exact supported tags before version floors, then choose the greatest compatible floor and keep universal artifacts as the final fallback. Detect macOS product versions with the absolute system `sw_vers` path and Windows NT kernel versions with the platform APIs. Enable restoration and publishing on Darwin and Windows x64 and arm64. Implement the same protocol, fingerprints, selection, and host-detection behavior in the TypeScript CLI and pacquet. Keep all existing Linux tags and fingerprints stable. Related to pnpm/pnpm#13771 and pnpm/rfcs#24. --- .changeset/curvy-dingos-build.md | 7 + Cargo.lock | 1 + pnpm/crates/deps-restorer/Cargo.toml | 1 + .../deps-restorer/src/shared_side_effects.rs | 133 +++++++++-- .../src/shared_side_effects/tests.rs | 67 +++--- pnpm/crates/pnpr-client/src/lib.rs | 15 +- .../shared-artifact-protocol/src/lib.rs | 217 ++++++++++++++++-- .../shared-artifact-protocol/src/tests.rs | 131 ++++++++++- pnpr/client/README.md | 28 ++- pnpr/client/src/index.ts | 7 + pnpr/client/src/installSharedSideEffects.ts | 135 +++++++++-- pnpr/client/src/sharedSideEffects.ts | 164 +++++++++++-- .../test/installSharedSideEffects.test.ts | 38 ++- pnpr/client/test/sharedSideEffects.test.ts | 75 ++++++ 14 files changed, 889 insertions(+), 130 deletions(-) create mode 100644 .changeset/curvy-dingos-build.md 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)