diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml index a9317d76..aeaa2a54 100644 --- a/.github/actions/build/action.yml +++ b/.github/actions/build/action.yml @@ -26,7 +26,7 @@ runs: using: composite steps: - name: Install cross - if: ${{ !endsWith(inputs.target, '-apple-darwin') && inputs.target != 'wasm32-browserpod-linux-musl' }} + if: ${{ !endsWith(inputs.target, '-apple-darwin') && !contains(inputs.target, '-windows-') && inputs.target != 'wasm32-browserpod-linux-musl' }} uses: taiki-e/install-action@a27ef18d36cfa66b0af3a360104621793b41c036 # v2.54.3 with: tool: cross @@ -90,7 +90,7 @@ runs: cargo "+browserpod-${{inputs.browserpod-version}}" build --verbose --bin yarn-bin --profile=${{inputs.profile}} --target=${{inputs.target}} cp target/${{inputs.target}}/${{inputs.profile}}/yarn-bin target/${{inputs.target}}/${{inputs.profile}}/yarn-bin.wasm - elif [[ "${{inputs.target}}" == *-apple-darwin ]]; then + elif [[ "${{inputs.target}}" == *-apple-darwin || "${{inputs.target}}" == *-windows-* ]]; then cargo build --verbose --profile=${{inputs.profile}} --target=${{inputs.target}} else cross build --verbose --profile=${{inputs.profile}} --target=${{inputs.target}} diff --git a/.github/actions/prepare-node/action.yml b/.github/actions/prepare-node/action.yml index e3378279..6e4ce8f9 100644 --- a/.github/actions/prepare-node/action.yml +++ b/.github/actions/prepare-node/action.yml @@ -23,7 +23,11 @@ runs: chmod +x "${{inputs.switch}}" install_dir="${RUNNER_TEMP}/yarn-switch-bin" mkdir -p "${install_dir}" - cp -f "${{inputs.switch}}" "${install_dir}/yarn" + if [[ "${RUNNER_OS}" == "Windows" ]]; then + cp -f "${{inputs.switch}}" "${install_dir}/yarn.exe" + else + cp -f "${{inputs.switch}}" "${install_dir}/yarn" + fi echo "${install_dir}" >> "${GITHUB_PATH}" - name: Add link to the local path @@ -41,4 +45,7 @@ runs: - name: Install dependencies shell: bash run: | + if [[ -n "${{inputs.switch}}" ]]; then + export YARN_ENABLE_IMMUTABLE_INSTALLS=0 + fi yarn install diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 911292ff..2872d46d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,14 +22,22 @@ jobs: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest + ext: '' - target: aarch64-unknown-linux-musl os: ubuntu-latest + ext: '' - target: i686-unknown-linux-musl os: ubuntu-latest + ext: '' - target: aarch64-apple-darwin os: macos-latest + ext: '' + - target: x86_64-pc-windows-msvc + os: windows-latest + ext: '.exe' - target: wasm32-browserpod-linux-musl os: ubuntu-latest + ext: '' name: "Building ${{matrix.target}}" runs-on: ${{matrix.os}} @@ -66,8 +74,8 @@ jobs: with: name: yarn-${{matrix.target}} path: | - target/${{matrix.target}}/release-lto-nodebug/yarn-bin - target/${{matrix.target}}/release-lto-nodebug/yarn + target/${{matrix.target}}/release-lto-nodebug/yarn-bin${{matrix.ext}} + target/${{matrix.target}}/release-lto-nodebug/yarn${{matrix.ext}} target/${{matrix.target}}/release-lto-nodebug/LICENSE.md - name: Upload Browserpod artifacts @@ -88,10 +96,15 @@ jobs: test: strategy: + fail-fast: false matrix: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest + ext: '' + - target: x86_64-pc-windows-msvc + os: windows-latest + ext: '.exe' name: "Testing ${{matrix.target}}" needs: [build] @@ -112,19 +125,26 @@ jobs: - name: Prepare Node uses: ./.github/actions/prepare-node with: - link: artifacts/yarn-bin - switch: artifacts/yarn + link: artifacts/yarn-bin${{matrix.ext}} + switch: artifacts/yarn${{matrix.ext}} - name: Generate the test report + shell: bash + env: + TEST_BINARY: ${{github.workspace}}/artifacts/yarn-bin${{matrix.ext}} + TEST_SWITCH_BINARY: ${{github.workspace}}/artifacts/yarn${{matrix.ext}} run: | - chmod +x artifacts/{yarn,yarn-bin} + if [[ "${RUNNER_OS}" != "Windows" ]]; then + chmod +x artifacts/{yarn,yarn-bin} + fi - export TEST_BINARY=$(pwd)/artifacts/yarn-bin - export TEST_SWITCH_BINARY=$(pwd)/artifacts/yarn + status=0 + yarn ./tests/acceptance-tests jest --json --outputFile="${GITHUB_WORKSPACE}/report.json" || status=$? - yarn ./tests/acceptance-tests jest --json --outputFile=$(pwd)/report.json || true + exit "${status}" - name: Upload test report + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: yarn-${{matrix.target}}-test-report diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 02c966d9..25e3c25d 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -48,12 +48,19 @@ jobs: include: - target: x86_64-unknown-linux-musl os: ubuntu-latest + ext: '' - target: aarch64-unknown-linux-musl os: ubuntu-latest + ext: '' - target: i686-unknown-linux-musl os: ubuntu-latest + ext: '' - target: aarch64-apple-darwin os: macos-latest + ext: '' + - target: x86_64-pc-windows-msvc + os: windows-latest + ext: '.exe' name: 'Building ${{matrix.target}}' runs-on: ${{matrix.os}} @@ -70,8 +77,24 @@ jobs: version: ${{needs.check.outputs.version}} - name: Generate the archive files + if: runner.os != 'Windows' + shell: bash run: | - zip -jX yarn-${{matrix.target}}.zip target/${{matrix.target}}/release-lto-nodebug/{yarn-bin,yarn,LICENSE.md} + zip -jX yarn-${{matrix.target}}.zip \ + target/${{matrix.target}}/release-lto-nodebug/yarn-bin${{matrix.ext}} \ + target/${{matrix.target}}/release-lto-nodebug/yarn${{matrix.ext}} \ + target/${{matrix.target}}/release-lto-nodebug/LICENSE.md + + - name: Generate the archive files + if: runner.os == 'Windows' + shell: pwsh + run: | + $archiveRoot = New-Item -ItemType Directory -Path "archive-${{matrix.target}}" + Copy-Item "target/${{matrix.target}}/release-lto-nodebug/yarn-bin${{matrix.ext}}" $archiveRoot + Copy-Item "target/${{matrix.target}}/release-lto-nodebug/yarn${{matrix.ext}}" $archiveRoot + Copy-Item "target/${{matrix.target}}/release-lto-nodebug/LICENSE.md" $archiveRoot + Compress-Archive -Path "$archiveRoot/*" -DestinationPath "yarn-${{matrix.target}}.zip" + Remove-Item -Recurse -Force $archiveRoot - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -173,7 +196,7 @@ jobs: const ext = includes(targetName, `windows`) ? `.exe` : ``; // We don't need to distribute Yarn Switch itself - fs.unlinkSync(path.join(destinationPath, `yarn`)); + fs.unlinkSync(path.join(destinationPath, `yarn${ext}`)); // Rename the binary to `yarn` fs.renameSync(path.join(destinationPath, `yarn-bin${ext}`), path.join(destinationPath, `yarn${ext}`)); @@ -190,6 +213,7 @@ jobs: const os = findFirst(targetName, { linux: `linux`, darwin: `darwin`, + win32: `windows`, }); fs.writeFileSync(path.join(destinationPath, `package.json`), `${JSON.stringify({ diff --git a/Cargo.lock b/Cargo.lock index 47d91871..3d2f1655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,6 +1968,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + [[package]] name = "konst" version = "0.2.20" @@ -5433,6 +5443,7 @@ dependencies = [ "fundu", "hex", "indexmap 2.14.0", + "junction", "libc", "num", "ouroboros", @@ -5449,5 +5460,6 @@ dependencies = [ "tokio", "urlencoding", "wax", + "windows-sys 0.61.2", "zpm-macro-enum", ] diff --git a/Cargo.toml b/Cargo.toml index 2c6f4913..04121230 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ http-body-util = "0.1" hyper = { version = "1", features = ["server", "http1"] } hyper-util = { version = "0.1", features = ["tokio"] } itertools = "0.14.0" +junction = "1.2.0" indexmap = {version = "2.11.0", features = ["serde"]} interprocess = { version = "2.2.2", features = ["tokio"] } mimalloc = "0.1.43" @@ -116,6 +117,7 @@ ts-rs = "12.0.1" serde_json = { version = "1.0.145", features = ["preserve_order"] } serde = { version = "1.0.207", features = ["derive"] } wax = { git = "https://github.com/arcanis/wax.git" } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } winnow = "0.7" url = "2.5.7" urlencoding = "2.1.3" diff --git a/install-script.sh b/install-script.sh index 144a2398..438b54ba 100644 --- a/install-script.sh +++ b/install-script.sh @@ -74,6 +74,12 @@ case $platform in 'Darwin arm64') target=aarch64-apple-darwin ;; +'MINGW64_NT'*' x86_64' | 'MSYS_NT'*' x86_64' | 'CYGWIN_NT'*' x86_64') + target=x86_64-pc-windows-msvc + ;; +'MINGW64_NT'*' ARM64' | 'MINGW64_NT'*' aarch64' | 'MSYS_NT'*' ARM64' | 'MSYS_NT'*' aarch64' | 'CYGWIN_NT'*' ARM64' | 'CYGWIN_NT'*' aarch64') + target=x86_64-pc-windows-msvc + ;; 'Linux aarch64' | 'Linux arm64') target=aarch64-unknown-linux-musl ;; @@ -82,6 +88,15 @@ case $platform in ;; esac +case $target in +*-windows-*) + ext=.exe + ;; +*) + ext= + ;; +esac + install_dir=$HOME/.yarn/switch/bin tmp_dir=$install_dir.tmp archive=$tmp_dir/yarn.zip @@ -100,16 +115,16 @@ curl --fail --location --progress-bar --output "$archive" $yarn_uri || error "Failed to download Yarn from $(colorize "$color_url" "$yarn_uri")" unzip -q "$archive" -d "$tmp_dir" -rm "$tmp_dir"/yarn-bin +rm "$tmp_dir"/yarn-bin$ext rm "$archive" if [[ -n "$bin_dir" ]]; then - mv -f "$tmp_dir"/yarn "$bin_dir"/ + mv -f "$tmp_dir"/yarn$ext "$bin_dir"/ else rm -rf "$install_dir" mv "$tmp_dir" "$install_dir" echo - "$install_dir"/yarn switch postinstall -H "$HOME" + "$install_dir"/yarn$ext switch postinstall -H "$HOME" fi diff --git a/packages/zpm-formats/src/lib.rs b/packages/zpm-formats/src/lib.rs index 0fad61cf..de2fb5a3 100644 --- a/packages/zpm-formats/src/lib.rs +++ b/packages/zpm-formats/src/lib.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, os::unix::fs::PermissionsExt}; +use std::borrow::Cow; use libdeflater::{CompressionLvl, Compressor}; use zpm_utils::{FromFileString, impl_file_string_from_str, Path, ToFileString, ToHumanString}; @@ -190,9 +190,7 @@ pub fn entries_from_folder<'a>(path: &Path) -> Result>, Error> { let rel_path = entry_path.relative_to(&base); let data = entry_path.fs_read()?; - let metadata = entry_path.fs_metadata()?; - - let is_exec = metadata.permissions().mode() & 0o111 != 0; + let is_exec = entry_path.fs_is_executable()?; let mode = if is_exec { 0o755 } else { 0o644 }; entries.push(Entry { @@ -216,9 +214,7 @@ pub fn entries_from_files<'a>(base: &Path, files: &[Path]) -> Result Scanner<'a> { } fn skip_whitespace(&mut self) { - while self.offset < self.input.len() && (self.input[self.offset] == b' ' || self.input[self.offset] == b'\t' || self.input[self.offset] == b'\n') { + while self.offset < self.input.len() && matches!(self.input[self.offset], b' ' | b'\t' | b'\n' | b'\r') { self.offset += 1; } } fn rskip_whitespace(&mut self) { - while self.offset > 0 && (self.input[self.offset - 1] == b' ' || self.input[self.offset - 1] == b'\t' || self.input[self.offset - 1] == b'\n') { + while self.offset > 0 && matches!(self.input[self.offset - 1], b' ' | b'\t' | b'\n' | b'\r') { self.offset -= 1; } } @@ -935,6 +935,8 @@ mod tests { // Edge cases with whitespace #[case(b"{ \"spaced\": \"value\" }", vec!["spaced"], Value::String("updated".to_string()), b"{ \"spaced\": \"updated\" }")] #[case(b"{\n\n \"key\": \"value\"\n\n}", vec!["key"], Value::String("new_value".to_string()), b"{\n\n \"key\": \"new_value\"\n\n}")] + #[case(b"{\r\n \"key\": \"value\"\r\n}", vec!["key"], Value::String("new_value".to_string()), b"{\r\n \"key\": \"new_value\"\r\n}")] + #[case(b"{\r\n \"key\": \"value\"\r\n}", vec!["another"], Value::String("new_value".to_string()), b"{\r\n \"another\": \"new_value\",\r\n \"key\": \"value\"\r\n}")] #[case(b"{\"key\":\"no_spaces\"}", vec!["key"], Value::String("with_spaces".to_string()), b"{\"key\":\"with_spaces\"}")] // Escaped characters diff --git a/packages/zpm-switch/src/commands/proxy.rs b/packages/zpm-switch/src/commands/proxy.rs index 2823ac2f..fac74e4a 100644 --- a/packages/zpm-switch/src/commands/proxy.rs +++ b/packages/zpm-switch/src/commands/proxy.rs @@ -84,7 +84,7 @@ async fn proxy_completer_async(ctx: &CompletionContext<'_>) -> Vec { cmd }, PackageManagerReference::Local(params) => { - std::process::Command::new(params.path.to_file_string()) + std::process::Command::new(params.path.to_path_buf()) }, }; diff --git a/packages/zpm-switch/src/commands/switch/daemon_open.rs b/packages/zpm-switch/src/commands/switch/daemon_open.rs index 5532acf6..389223c2 100644 --- a/packages/zpm-switch/src/commands/switch/daemon_open.rs +++ b/packages/zpm-switch/src/commands/switch/daemon_open.rs @@ -123,7 +123,7 @@ impl DaemonOpenCommand { .arg("daemon") .arg("--auth-token") .arg(&auth_token) - .current_dir(detected_root.to_file_string()) + .current_dir(detected_root.to_path_buf()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()); @@ -135,8 +135,11 @@ impl DaemonOpenCommand { binary.env("USERPROFILE", userprofile); } - use std::os::unix::process::CommandExt; - binary.process_group(0); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + binary.process_group(0); + } let mut child = binary diff --git a/packages/zpm-switch/src/commands/switch/explicit.rs b/packages/zpm-switch/src/commands/switch/explicit.rs index d034b9a7..aae8f1df 100644 --- a/packages/zpm-switch/src/commands/switch/explicit.rs +++ b/packages/zpm-switch/src/commands/switch/explicit.rs @@ -32,7 +32,7 @@ impl ExplicitCommand { => install_package_manager(params).await?, PackageManagerReference::Local(params) - => Command::new(params.path.to_file_string()), + => Command::new(params.path.to_path_buf()), }; binary.stdout(Stdio::inherit()); diff --git a/packages/zpm-switch/src/daemons.rs b/packages/zpm-switch/src/daemons.rs index 52b064e5..3761a74a 100644 --- a/packages/zpm-switch/src/daemons.rs +++ b/packages/zpm-switch/src/daemons.rs @@ -49,18 +49,9 @@ pub fn register_daemon(entry: &DaemonEntry) -> Result<(), Error> { = JsonDocument::to_string(entry)?; daemon_path.fs_write_atomic(move |tmp_path| { - use std::os::unix::fs::OpenOptionsExt; - use std::io::Write; - - let mut file - = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(tmp_path.to_path_buf())?; - - file.write_all(data.as_bytes())?; + tmp_path + .fs_write(data.as_bytes())? + .fs_set_mode(0o600)?; Ok::<(), zpm_utils::PathError>(()) })?; @@ -121,64 +112,11 @@ pub fn list_daemons() -> Result, Error> { } pub fn is_process_alive(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(pid as i32, 0) == 0 } - } - - #[cfg(windows)] - { - use std::ptr::null_mut; - unsafe { - let handle = winapi::um::processthreadsapi::OpenProcess( - winapi::um::winnt::PROCESS_QUERY_LIMITED_INFORMATION, - 0, - pid, - ); - if handle.is_null() { - false - } else { - winapi::um::handleapi::CloseHandle(handle); - true - } - } - } - - #[cfg(not(any(unix, windows)))] - { - true - } + zpm_utils::is_process_alive(pid) } pub fn kill_process(pid: u32) -> bool { - #[cfg(unix)] - { - unsafe { libc::kill(pid as i32, libc::SIGTERM) == 0 } - } - - #[cfg(windows)] - { - use std::ptr::null_mut; - unsafe { - let handle = winapi::um::processthreadsapi::OpenProcess( - winapi::um::winnt::PROCESS_TERMINATE, - 0, - pid, - ); - if handle.is_null() { - false - } else { - let result = winapi::um::processthreadsapi::TerminateProcess(handle, 1) != 0; - winapi::um::handleapi::CloseHandle(handle); - result - } - } - } - - #[cfg(not(any(unix, windows)))] - { - false - } + zpm_utils::terminate_process(pid) } /// Kill a daemon process and all its children (process group). @@ -227,8 +165,27 @@ pub fn kill_daemon_gracefully(pid: u32) -> bool { #[cfg(windows)] { - // On Windows, just use TerminateProcess (no graceful shutdown) - kill_process(pid) + // Windows has no SIGTERM equivalent for arbitrary processes. taskkill + // terminates the full process tree, including daemon task children. + let result + = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status(); + + if !result.is_ok_and(|status| status.success()) { + return false; + } + + // Don't unregister the daemon until its process has actually exited. + for _ in 0..10 { + if !is_process_alive(pid) { + return true; + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + !is_process_alive(pid) } #[cfg(not(any(unix, windows)))] diff --git a/packages/zpm-switch/src/yarn.rs b/packages/zpm-switch/src/yarn.rs index ced33aaa..1d5f4db7 100644 --- a/packages/zpm-switch/src/yarn.rs +++ b/packages/zpm-switch/src/yarn.rs @@ -216,6 +216,14 @@ mod tests { assert_eq!(meta.args, vec_of(&["install"])); } + #[cfg(windows)] + #[test] + fn native_windows_paths_are_explicit_path_args() { + let meta = extract_bin_meta(Some(vec_of(&[r"D:\a\zpm\zpm\tests\acceptance-tests", "jest"]))); + assert!(meta.cwd.is_some()); + assert_eq!(meta.args, vec_of(&["jest"])); + } + #[test] fn missing_cwd_arg_does_not_get_set() { // No `--cwd` at all — cwd should remain unset and args diff --git a/packages/zpm-sync/src/lib.rs b/packages/zpm-sync/src/lib.rs index 16c3b847..e7f039d6 100644 --- a/packages/zpm-sync/src/lib.rs +++ b/packages/zpm-sync/src/lib.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, collections::BTreeMap, os::unix::fs::PermissionsExt, sync::Arc}; +use std::{borrow::Cow, collections::BTreeMap, sync::Arc}; use itertools::Itertools; use serde::Deserialize; @@ -72,9 +72,17 @@ pub struct SyncCheck { pub struct SyncTree<'a> { pub dry_run: bool, + link_type: LinkType, nodes: Vec>, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum LinkType { + #[default] + Symlink, + Junction, +} + pub enum FileOp { Delete(Path), CreateFolder(Path), @@ -111,6 +119,7 @@ impl<'a> SyncTree<'a> { pub fn new() -> Self { Self { dry_run: true, + link_type: LinkType::Symlink, nodes: vec![SyncNode::Folder { template: None, children: BTreeMap::new(), @@ -118,6 +127,11 @@ impl<'a> SyncTree<'a> { } } + pub fn with_link_type(mut self, link_type: LinkType) -> Self { + self.link_type = link_type; + self + } + pub fn root_entries(&self) -> Result, SyncError> { let node = &self.nodes[0]; @@ -289,12 +303,12 @@ impl<'a> SyncTree<'a> { }, SyncNode::File {data, is_exec} => { - let expected_x - = if *is_exec {0o111} else {0o000}; + let is_exec_up_to_date + = cfg!(windows) || path.fs_is_executable()? == *is_exec; let is_file_up_to_date = metadata.is_file() - && (metadata.permissions().mode() & 0o111) == expected_x + && is_exec_up_to_date && metadata.len() == data.len() as u64 && data == &path.fs_read_with_size(metadata.len())?; @@ -306,12 +320,40 @@ impl<'a> SyncTree<'a> { SyncNode::Symlink {target_path} => { let symlink_target - = metadata.is_symlink() - .then(|| path.fs_read_link()) - .transpose()?; + = if metadata.is_symlink() { + match path.fs_read_link() { + Ok(target) => Some(target), + Err(error) => return Err(error.into()), + } + } else if self.link_type == LinkType::Junction && metadata.is_dir() { + path.fs_read_link().ok() + } else { + None + }; let is_symlink_up_to_date - = symlink_target.as_ref() == Some(target_path); + = match (self.link_type, symlink_target) { + (LinkType::Junction, Some(actual_target)) => { + let expected_target = if target_path.is_absolute() { + target_path.clone() + } else { + path.dirname().unwrap_or_default().with_join(target_path) + }; + + let actual_target = if actual_target.is_absolute() { + actual_target + } else { + path.dirname().unwrap_or_default().with_join(&actual_target) + }; + + match (actual_target.fs_canonicalize(), expected_target.fs_canonicalize()) { + (Ok(actual_canonical), Ok(expected_canonical)) => actual_canonical == expected_canonical, + _ => false, + } + }, + (_, Some(actual_target)) => actual_target == *target_path, + (_, None) => false, + }; Ok(SyncCheck { must_remove: !is_symlink_up_to_date, @@ -368,7 +410,8 @@ impl<'a> SyncTree<'a> { .collect_vec(); let mut template_tree - = SyncTree::from_entries(&zip_entries)?; + = SyncTree::from_entries(&zip_entries)? + .with_link_type(self.link_type); template_tree.dry_run = self.dry_run; @@ -425,7 +468,7 @@ impl<'a> SyncTree<'a> { path.fs_write(data)?; if *is_exec { - path.fs_set_permissions(std::fs::Permissions::from_mode(0o755))?; + path.fs_set_mode(0o755)?; } } } @@ -438,7 +481,10 @@ impl<'a> SyncTree<'a> { if self.dry_run { file_ops.push(FileOp::CreateSymlink(path.clone(), target_path.clone())); } else { - path.fs_symlink(target_path)?; + match self.link_type { + LinkType::Symlink => path.fs_symlink(target_path)?, + LinkType::Junction => path.fs_junction(target_path)?, + }; } } @@ -488,3 +534,53 @@ impl<'a> From> for SyncNode<'a> { } } } + +#[cfg(test)] +mod tests { + use std::{str::FromStr, time::{SystemTime, UNIX_EPOCH}}; + + use super::*; + + #[test] + fn junction_mode_accepts_equivalent_relative_targets() -> Result<(), Box> { + let nonce + = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_nanos(); + + let root + = Path::try_from(std::env::temp_dir().join(format!("zpm-sync-{nonce}")))?; + + let result = (|| -> Result<(), Box> { + root.fs_create_dir_all()?; + root.with_join_str("links").fs_create_dir_all()?; + root.with_join_str("store/pkg").fs_create_dir_all()?; + + let link_path + = root.with_join_str("links/pkg"); + + link_path.fs_symlink(&Path::from_str("../links/../store/pkg")?)?; + + let check + = SyncTree::new() + .with_link_type(LinkType::Junction) + .check(&link_path, &SyncNode::Symlink { + target_path: Path::from_str("../store/pkg")?, + })?; + + assert!(!check.must_remove); + assert!(!check.must_create); + + Ok(()) + })(); + + let cleanup_result + = root.fs_rm(); + + if result.is_ok() { + cleanup_result?; + } + + result + } +} diff --git a/packages/zpm-utils/Cargo.toml b/packages/zpm-utils/Cargo.toml index 5e8373f2..c3cd5631 100644 --- a/packages/zpm-utils/Cargo.toml +++ b/packages/zpm-utils/Cargo.toml @@ -30,3 +30,7 @@ zpm-macro-enum = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" + +[target.'cfg(windows)'.dependencies] +junction = { workspace = true } +windows-sys = { workspace = true } diff --git a/packages/zpm-utils/src/path.rs b/packages/zpm-utils/src/path.rs index 36b42f6d..1476e981 100644 --- a/packages/zpm-utils/src/path.rs +++ b/packages/zpm-utils/src/path.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, io::{Read, Write}, os::unix::ffi::OsStrExt, str::{FromStr, Split}, sync::atomic::{AtomicU64, Ordering}, time::SystemTime}; +use std::{collections::BTreeMap, io::{Read, Write}, str::{FromStr, Split}, sync::atomic::{AtomicU64, Ordering}, time::SystemTime}; use rkyv::Archive; @@ -6,6 +6,182 @@ use crate::{diff_data, impl_file_string_from_str, impl_file_string_serialization static ATOMIC_WRITE_NONCE: AtomicU64 = AtomicU64::new(0); +#[cfg(any(windows, test))] +fn to_portable_path(value: &str) -> String { + let value = value.replace('\\', "/"); + + if let Some(value) = value.strip_prefix("//?/UNC/") { + format!("/unc/?/UNC/{}", value) + } else if value.starts_with("//?/") + && value.as_bytes().get(5) == Some(&b':') + && value.as_bytes().get(4).map_or(false, u8::is_ascii_alphabetic) + { + value[3..].to_string() + } else if value.as_bytes().get(1) == Some(&b':') + && value.as_bytes().first().map_or(false, u8::is_ascii_alphabetic) + { + format!("/{}", value) + } else if let Some(value) = value.strip_prefix("//./") { + format!("/unc/.dot/{}", value) + } else if let Some(value) = value.strip_prefix("//") { + format!("/unc/{}", value) + } else { + value + } +} + +#[cfg(any(windows, test))] +fn from_portable_path(value: &str) -> String { + if value.as_bytes().get(2) == Some(&b':') + && value.as_bytes().get(1).map_or(false, u8::is_ascii_alphabetic) + { + value[1..].replace('/', "\\") + } else if let Some(value) = value.strip_prefix("/unc/.dot/") { + format!("\\\\.\\{}", value.replace('/', "\\")) + } else if let Some(value) = value.strip_prefix("/unc/?/UNC/") { + format!("\\\\?\\UNC\\{}", value.replace('/', "\\")) + } else if let Some(value) = value.strip_prefix("/unc/?/") { + format!("\\\\?\\{}", value.replace('/', "\\")) + } else if let Some(value) = value.strip_prefix("/unc/") { + format!("\\\\{}", value.replace('/', "\\")) + } else { + value.replace('/', "\\") + } +} + +#[cfg(any(windows, test))] +fn windows_path_root(value: &str) -> Option { + if value.as_bytes().get(2) == Some(&b':') + && value.as_bytes().get(1).map_or(false, u8::is_ascii_alphabetic) + && value.starts_with('/') + { + return Some(value[..3].to_ascii_lowercase()); + } + + let unc_path + = value.strip_prefix("/unc/")?; + + let mut parts + = unc_path.split('/'); + + Some(format!( + "/unc/{}/{}", + parts.next()?.to_ascii_lowercase(), + parts.next()?.to_ascii_lowercase(), + )) +} + +#[cfg(windows)] +fn fs_set_mode_0600(path: &std::path::Path) -> Result<(), std::io::Error> { + use std::{ffi::OsStr, iter, mem, os::windows::ffi::OsStrExt, ptr::{null, null_mut}}; + use windows_sys::Win32::{ + Foundation::{CloseHandle, ERROR_SUCCESS, GENERIC_ALL, HANDLE, LocalFree}, + Security::{ + Authorization::{ + EXPLICIT_ACCESS_W, GRANT_ACCESS, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, + SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, + TRUSTEE_W, + }, + DACL_SECURITY_INFORMATION, GetTokenInformation, NO_INHERITANCE, + PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER, TokenUser, + }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + + struct OwnedHandle(HANDLE); + + impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + + struct OwnedAcl(*mut windows_sys::Win32::Security::ACL); + + impl Drop for OwnedAcl { + fn drop(&mut self) { + unsafe { + LocalFree(self.0.cast()); + } + } + } + + fn win32_error(code: u32) -> std::io::Error { + std::io::Error::from_raw_os_error(code as i32) + } + + unsafe { + let mut token = null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = OwnedHandle(token); + + let mut token_user_len = 0; + GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut token_user_len); + if token_user_len == 0 { + return Err(std::io::Error::last_os_error()); + } + + let token_user_unit_size + = mem::size_of::(); + let token_user_units + = (token_user_len as usize + token_user_unit_size - 1) / token_user_unit_size; + let mut token_user_data = vec![0usize; token_user_units]; + if GetTokenInformation(token.0, TokenUser, token_user_data.as_mut_ptr().cast(), token_user_len, &mut token_user_len) == 0 { + return Err(std::io::Error::last_os_error()); + } + + let token_user + = &*(token_user_data.as_ptr().cast::()); + let user_sid + = token_user.User.Sid; + + let explicit_access = EXPLICIT_ACCESS_W { + grfAccessPermissions: GENERIC_ALL, + grfAccessMode: GRANT_ACCESS, + grfInheritance: NO_INHERITANCE, + Trustee: TRUSTEE_W { + pMultipleTrustee: null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: user_sid.cast(), + }, + }; + + let mut acl = null_mut(); + let result + = SetEntriesInAclW(1, &explicit_access, null(), &mut acl); + if result != ERROR_SUCCESS { + return Err(win32_error(result)); + } + let acl = OwnedAcl(acl); + + let wide_path = OsStr::new(path) + .encode_wide() + .chain(iter::once(0)) + .collect::>(); + + let result = SetNamedSecurityInfoW( + wide_path.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + acl.0, + null_mut(), + ); + if result != ERROR_SUCCESS { + return Err(win32_error(result)); + } + } + + Ok(()) +} + #[derive(Debug)] pub struct SyncEntry { pub rel_path: Path, @@ -36,11 +212,19 @@ pub struct ExplicitPath { pub raw_path: RawPath, } +fn is_explicit_path_parameter(s: &str) -> bool { + is_explicit_path_parameter_for_platform(s, cfg!(windows)) +} + +fn is_explicit_path_parameter_for_platform(s: &str, windows: bool) -> bool { + s.contains('/') || (windows && s.contains('\\')) +} + impl FromFileString for ExplicitPath { type Error = PathError; fn from_file_string(s: &str) -> Result { - if !s.contains('/') { + if !is_explicit_path_parameter(s) { return Err(PathError::InvalidExplicitPathParameter(s.to_string())); } @@ -151,7 +335,14 @@ impl Path { } pub fn home_dir() -> Result, PathError> { - Ok(std::env::var("HOME") + #[cfg(windows)] + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")); + + #[cfg(not(windows))] + let home = std::env::var("HOME"); + + Ok(home .ok() .map(|s| Path::try_from(s)) .transpose()?) @@ -291,6 +482,10 @@ impl Path { } pub fn dirname<'a>(&'a self) -> Option { + if self.is_root() { + return None; + } + let mut slice_len = self.path.len(); @@ -306,6 +501,9 @@ impl Path { = &self.path[..slice_len]; if let Some(last_slash) = slice.rfind('/') { + if cfg!(windows) && last_slash == 3 && slice.as_bytes().get(2) == Some(&b':') { + return Some(Path::from_str(&slice[..=last_slash]).unwrap()); + } if last_slash > 0 { return Some(Path::from_str(&slice[..last_slash]).unwrap()); } else { @@ -384,11 +582,23 @@ impl Path { } pub fn to_path_buf(&self) -> std::path::PathBuf { + #[cfg(windows)] + return std::path::PathBuf::from(from_portable_path(&self.path)); + + #[cfg(not(windows))] std::path::PathBuf::from(&self.path) } + pub fn to_native_string(&self) -> String { + self.to_path_buf().to_string_lossy().into_owned() + } + pub fn is_root(&self) -> bool { self.path == "/" + || (cfg!(windows) && self.path.len() == 4 && self.path.as_bytes().get(2) == Some(&b':') && self.path.ends_with('/')) + || (cfg!(windows) && self.path.strip_prefix("/unc/") + .map(|path| path.trim_end_matches('/').split('/').count() == 2) + .unwrap_or(false)) } pub fn is_absolute(&self) -> bool { @@ -425,7 +635,7 @@ impl Path { } pub fn sys_set_current_dir(&self) -> Result<(), PathError> { - std::env::set_current_dir(&self.path)?; + std::env::set_current_dir(self.to_path_buf())?; Ok(()) } @@ -439,12 +649,12 @@ impl Path { pub unsafe fn sys_set_current_dir_with_pwd(&self) -> Result<(), PathError> { self.sys_set_current_dir()?; // SAFETY: caller contract guarantees single-threaded startup. - unsafe { std::env::set_var("PWD", self.as_str()); } + unsafe { std::env::set_var("PWD", self.to_path_buf()); } Ok(()) } pub fn fs_canonicalize(&self) -> Result { - Ok(Path::try_from(std::fs::canonicalize(&self.path)?)?) + Ok(Path::try_from(std::fs::canonicalize(self.to_path_buf())?)?) } pub fn fs_create_parent(&self) -> Result<&Self, PathError> { @@ -456,26 +666,66 @@ impl Path { } pub fn fs_create_dir_all(&self) -> Result<&Self, PathError> { - std::fs::create_dir_all(&self.path)?; + std::fs::create_dir_all(self.to_path_buf())?; Ok(self) } pub fn fs_create_dir(&self) -> Result<&Self, PathError> { - std::fs::create_dir(&self.path)?; + std::fs::create_dir(self.to_path_buf())?; Ok(self) } pub fn fs_set_permissions(&self, permissions: std::fs::Permissions) -> Result<&Self, PathError> { - std::fs::set_permissions(&self.path, permissions)?; + std::fs::set_permissions(self.to_path_buf(), permissions)?; + Ok(self) + } + + pub fn fs_set_mode(&self, mode: u32) -> Result<&Self, PathError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + self.fs_set_permissions(std::fs::Permissions::from_mode(mode))?; + } + + #[cfg(windows)] + { + if mode == 0o600 { + fs_set_mode_0600(&self.to_path_buf())?; + } + } + + #[cfg(not(any(unix, windows)))] + { + let _ = mode; + } + Ok(self) } + pub fn fs_is_executable(&self) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + return Ok(self.fs_metadata()?.permissions().mode() & 0o111 != 0); + } + + #[cfg(windows)] + { + Ok(false) + } + + #[cfg(not(any(unix, windows)))] + { + Ok(false) + } + } + pub fn fs_symlink_metadata(&self) -> Result { - Ok(std::fs::symlink_metadata(&self.path)?) + Ok(std::fs::symlink_metadata(self.to_path_buf())?) } pub fn fs_metadata(&self) -> Result { - Ok(std::fs::metadata(&self.path)?) + Ok(std::fs::metadata(self.to_path_buf())?) } pub fn fs_exists(&self) -> bool { @@ -495,6 +745,11 @@ impl Path { } pub fn fs_is_real_dir(&self) -> bool { + #[cfg(windows)] + if junction::exists(self.to_path_buf()).unwrap_or(false) { + return false; + } + self.fs_symlink_metadata().map(|m| m.is_dir()).unwrap_or(false) } @@ -766,6 +1021,9 @@ impl Path { } pub fn fs_expect>(&self, expected_data: T, is_exec: bool) -> Result<&Self, PathError> { + #[cfg(windows)] + let _ = is_exec; + let current_content = self.fs_read() .ok_missing()?; @@ -809,6 +1067,9 @@ impl Path { } pub fn fs_change>(&self, data: T, is_exec: bool) -> Result<&Self, PathError> { + #[cfg(windows)] + let _ = is_exec; + let path_buf = self.to_path_buf(); let update_content = self.fs_read() @@ -919,6 +1180,22 @@ impl Path { } pub fn fs_rm(&self) -> Result<&Self, PathError> { + #[cfg(windows)] + if junction::exists(self.to_path_buf()).unwrap_or(false) { + junction::delete(self.to_path_buf())?; + return Ok(self); + } + + #[cfg(windows)] + if self.fs_is_symlink() { + match self.fs_metadata() { + Ok(metadata) if metadata.is_dir() => std::fs::remove_dir(self.to_path_buf())?, + _ => std::fs::remove_file(self.to_path_buf())?, + } + + return Ok(self); + } + match self.fs_is_real_dir() { true => std::fs::remove_dir_all(self.to_path_buf()), false => std::fs::remove_file(self.to_path_buf()), @@ -928,11 +1205,54 @@ impl Path { } pub fn fs_symlink(&self, target: &Path) -> Result<&Self, PathError> { + #[cfg(unix)] std::os::unix::fs::symlink(&target.path, &self.path)?; + + #[cfg(windows)] + { + let resolved_target = if target.is_absolute() { + target.clone() + } else { + self.dirname().unwrap_or_default().with_join(target) + }; + + if resolved_target.fs_is_dir() { + std::os::windows::fs::symlink_dir(target.to_path_buf(), self.to_path_buf())?; + } else { + std::os::windows::fs::symlink_file(target.to_path_buf(), self.to_path_buf())?; + } + } + + Ok(self) + } + + pub fn fs_junction(&self, target: &Path) -> Result<&Self, PathError> { + #[cfg(windows)] + { + let resolved_target = if target.is_absolute() { + target.clone() + } else { + self.dirname().unwrap_or_default().with_join(target) + }; + if resolved_target.fs_is_dir() { + junction::create(resolved_target.to_path_buf(), self.to_path_buf())?; + } else { + self.fs_symlink(target)?; + } + } + + #[cfg(not(windows))] + self.fs_symlink(target)?; + Ok(self) } pub fn fs_read_link(&self) -> Result { + #[cfg(windows)] + if junction::exists(self.to_path_buf()).unwrap_or(false) { + return Ok(Path::try_from(junction::get_target(self.to_path_buf())?)?); + } + Ok(Path::try_from(std::fs::read_link(&self.to_path_buf())?)?) } @@ -1107,6 +1427,17 @@ impl Path { } } + pub fn relative_to_if_same_root(&self, other: &Path) -> Path { + #[cfg(any(windows, test))] + if let (Some(self_root), Some(other_root)) = (windows_path_root(&self.path), windows_path_root(&other.path)) { + if self_root != other_root { + return self.clone(); + } + } + + self.relative_to(other) + } + fn normalize(&mut self) { self.path = resolve_path(&self.path); } @@ -1130,7 +1461,14 @@ impl TryFrom<&std::ffi::OsStr> for Path { type Error = PathError; fn try_from(value: &std::ffi::OsStr) -> Result { - Ok(Path::from_str(std::str::from_utf8(value.as_bytes())?)?) + let value + = value.to_str() + .ok_or(PathError::InvalidUtf8Path)?; + + #[cfg(windows)] + let value = to_portable_path(value); + + Ok(Path::from_str(&value)?) } } @@ -1154,7 +1492,10 @@ impl FromFileString for Path { type Error = PathError; fn from_file_string(s: &str) -> Result { - Ok(Path {path: resolve_path(s)}) + #[cfg(windows)] + let s = to_portable_path(s); + + Ok(Path {path: resolve_path(&s)}) } } @@ -1174,7 +1515,43 @@ impl ToHumanString for Path { mod tests { use std::str::FromStr; - use super::Path; + use super::{Path, from_portable_path, is_explicit_path_parameter_for_platform, to_portable_path}; + + #[test] + fn converts_windows_paths() { + assert_eq!(to_portable_path(r"C:\work\project"), "/C:/work/project"); + assert_eq!(to_portable_path(r"\\server\share\project"), "/unc/server/share/project"); + assert_eq!(to_portable_path(r"\\.\pipe\yarn"), "/unc/.dot/pipe/yarn"); + assert_eq!(to_portable_path(r"\\?\C:\work\project"), "/C:/work/project"); + assert_eq!(to_portable_path(r"\\?\UNC\server\share\project"), "/unc/?/UNC/server/share/project"); + + assert_eq!(from_portable_path("/C:/work/project"), r"C:\work\project"); + assert_eq!(from_portable_path("/unc/server/share/project"), r"\\server\share\project"); + assert_eq!(from_portable_path("/unc/.dot/pipe/yarn"), r"\\.\pipe\yarn"); + assert_eq!(from_portable_path("/unc/?/C:/work/project"), r"\\?\C:\work\project"); + assert_eq!(from_portable_path("/unc/?/UNC/server/share/project"), r"\\?\UNC\server\share\project"); + assert_eq!(from_portable_path(".pnpm/no-deps/node_modules/no-deps"), r".pnpm\no-deps\node_modules\no-deps"); + } + + #[test] + fn classifies_backslash_paths_as_explicit_only_on_windows() { + assert!(is_explicit_path_parameter_for_platform("./workspace", false)); + assert!(is_explicit_path_parameter_for_platform(r"D:\a\zpm\zpm\tests\acceptance-tests", true)); + assert!(!is_explicit_path_parameter_for_platform(r"foo\bar", false)); + } + + #[test] + fn keeps_windows_link_targets_absolute_across_roots() { + assert_eq!( + Path::from_str("/D:/fixtures/no-deps").unwrap().relative_to_if_same_root(&Path::from_str("/C:/project/node_modules").unwrap()), + Path::from_str("/D:/fixtures/no-deps").unwrap(), + ); + + assert_eq!( + Path::from_str("/C:/project/.store/no-deps").unwrap().relative_to_if_same_root(&Path::from_str("/C:/project/node_modules").unwrap()), + Path::from_str("../.store/no-deps").unwrap(), + ); + } #[test] fn normalizes_repeated_trailing_separators() { diff --git a/packages/zpm-utils/src/path_iterators.rs b/packages/zpm-utils/src/path_iterators.rs index de72e5b2..cff2b7dd 100644 --- a/packages/zpm-utils/src/path_iterators.rs +++ b/packages/zpm-utils/src/path_iterators.rs @@ -53,7 +53,7 @@ impl<'a> Iterator for PathIterator<'a> { let mut sub_path = &self.path_str[0..next_slash_idx]; - if sub_path.ends_with('/') && sub_path.len() > 1 { + if sub_path.ends_with('/') && sub_path.len() > 1 && !(cfg!(windows) && sub_path.len() == 4 && sub_path.as_bytes().get(2) == Some(&b':')) { sub_path = &sub_path[..sub_path.len() - 1]; } @@ -90,7 +90,7 @@ impl<'a> DoubleEndedIterator for PathIterator<'a> { let mut sub_path = &self.path_str[0..back_idx]; - if sub_path.ends_with('/') && sub_path.len() > 1 { + if sub_path.ends_with('/') && sub_path.len() > 1 && !(cfg!(windows) && sub_path.len() == 4 && sub_path.as_bytes().get(2) == Some(&b':')) { sub_path = &sub_path[..sub_path.len() - 1]; } diff --git a/packages/zpm-utils/src/path_resolve.rs b/packages/zpm-utils/src/path_resolve.rs index 09bd32bb..9cecd2e0 100644 --- a/packages/zpm-utils/src/path_resolve.rs +++ b/packages/zpm-utils/src/path_resolve.rs @@ -35,6 +35,6 @@ pub fn resolve_path(input: &str) -> String { if path == vec![""] { return "/".to_string(); } else { - format!("{}", path.join("/")) + path.join("/") } } diff --git a/packages/zpm-utils/src/process.rs b/packages/zpm-utils/src/process.rs index 07c45f61..7f2da55f 100644 --- a/packages/zpm-utils/src/process.rs +++ b/packages/zpm-utils/src/process.rs @@ -1,4 +1,7 @@ -use std::process::Command; +use std::{path::PathBuf, process::{Command, ExitStatus}}; + +#[cfg(any(windows, test))] +use std::{ffi::OsString, path::Path}; use shlex::{try_quote, QuoteError}; /// RAII guard to ignore SIGINT and SIGTERM while waiting for a child process. @@ -81,3 +84,168 @@ pub fn to_shell_line(cmd: &Command) -> Result { // Glue it together Ok(format!("({})", parts.join(" "))) } + +#[cfg(any(windows, test))] +fn windows_path_exts(pathext: Option) -> Vec { + let pathext = pathext + .and_then(|value| value.into_string().ok()) + .unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".to_string()); + + pathext.split(';') + .filter(|ext| !ext.is_empty()) + .map(|ext| { + if ext.starts_with('.') { + ext.to_ascii_lowercase() + } else { + format!(".{}", ext.to_ascii_lowercase()) + } + }) + .collect() +} + +#[cfg(any(windows, test))] +fn has_windows_executable_extension(path: &Path, pathexts: &[String]) -> bool { + let Some(extension) = path.extension().and_then(|ext| ext.to_str()) else { + return false; + }; + + let extension = format!(".{}", extension).to_ascii_lowercase(); + pathexts.iter().any(|pathext| pathext == &extension) +} + +#[cfg(any(windows, test))] +fn windows_executable_candidates(path: PathBuf, pathexts: &[String]) -> Vec { + if has_windows_executable_extension(&path, pathexts) { + vec![path] + } else { + pathexts.iter() + .map(move |ext| path.with_extension(&ext[1..])) + .collect() + } +} + +#[cfg(any(windows, test))] +fn resolve_windows_spawn_program_with_env(program: &str, path_env: Option, pathext: Option) -> Option { + let program_path = Path::new(program); + let pathexts = windows_path_exts(pathext); + + if program_path.components().count() > 1 { + return windows_executable_candidates(program_path.to_path_buf(), &pathexts) + .into_iter() + .find(|candidate| candidate.is_file()); + } + + std::env::split_paths(&path_env.unwrap_or_default()) + .flat_map(|path| windows_executable_candidates(path.join(program), &pathexts)) + .find(|candidate| candidate.is_file()) +} + +#[cfg(windows)] +pub fn resolve_spawn_program(program: &str, path_env: &str) -> PathBuf { + resolve_windows_spawn_program_with_env(program, Some(OsString::from(path_env)), std::env::var_os("PATHEXT")) + .unwrap_or_else(|| PathBuf::from(program)) +} + +#[cfg(not(windows))] +pub fn resolve_spawn_program(program: &str, _path_env: &str) -> PathBuf { + PathBuf::from(program) +} + +pub fn exit_status_from_code(code: i32) -> ExitStatus { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + ExitStatus::from_raw(code << 8) + } + + #[cfg(windows)] + { + use std::os::windows::process::ExitStatusExt; + ExitStatus::from_raw(code as u32) + } + + #[cfg(not(any(unix, windows)))] + panic!("synthetic exit statuses are not supported on this platform") +} + +#[cfg(test)] +mod tests { + use super::resolve_windows_spawn_program_with_env; + + #[test] + fn resolves_windows_cmd_shims_from_path() { + let root = std::env::temp_dir().join(format!("zpm-process-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + + std::fs::write(root.join("node-gyp"), "#!/bin/sh\n").unwrap(); + let shim = root.join("node-gyp.cmd"); + std::fs::write(&shim, "@echo off\n").unwrap(); + + assert_eq!( + resolve_windows_spawn_program_with_env("node-gyp", Some(root.into_os_string()), Some(".EXE;.CMD".into())), + Some(shim), + ); + + let _ = std::fs::remove_dir_all(std::env::temp_dir().join(format!("zpm-process-test-{}", std::process::id()))); + } +} + +pub fn is_process_alive(pid: u32) -> bool { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, 0) == 0 } + } + + #[cfg(windows)] + { + use windows_sys::Win32::{Foundation::{CloseHandle, STILL_ACTIVE}, System::Threading::{GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}}; + + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return false; + } + + let mut exit_code = 0; + let result = GetExitCodeProcess(handle, &mut exit_code); + let _ = CloseHandle(handle); + result != 0 && exit_code == STILL_ACTIVE as u32 + } + } + + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + true + } +} + +pub fn terminate_process(pid: u32) -> bool { + #[cfg(unix)] + { + unsafe { libc::kill(pid as i32, libc::SIGTERM) == 0 } + } + + #[cfg(windows)] + { + use windows_sys::Win32::{Foundation::CloseHandle, System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}}; + + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); + if handle.is_null() { + return false; + } + + let result = TerminateProcess(handle, 1) != 0; + let _ = CloseHandle(handle); + result + } + } + + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + false + } +} diff --git a/packages/zpm-utils/src/system.rs b/packages/zpm-utils/src/system.rs index a887a285..5efddbd7 100644 --- a/packages/zpm-utils/src/system.rs +++ b/packages/zpm-utils/src/system.rs @@ -45,6 +45,9 @@ const LIBC: Option = Some(Libc::Musl); #[cfg(target_env = "")] const LIBC: Option = None; +#[cfg(target_env = "msvc")] +const LIBC: Option = None; + fn detect_libc() -> Option { let ldd_contents = Path::from_str(LDD_PATH).unwrap() diff --git a/packages/zpm/build.rs b/packages/zpm/build.rs index 03d67ffa..42021772 100644 --- a/packages/zpm/build.rs +++ b/packages/zpm/build.rs @@ -6,6 +6,14 @@ use std::path::Path; fn main() { println!("cargo::rustc-check-cfg=cfg(target_vendor, values(\"browserpod\"))"); + if env::var_os("CARGO_CFG_TARGET_OS").is_some_and(|value| value == "windows") + && env::var_os("CARGO_CFG_TARGET_ENV").is_some_and(|value| value == "msvc") + { + // MSVC executables default to a 1 MiB main-thread stack, which is too + // small for project and configuration hydration in larger workspaces. + println!("cargo::rustc-link-arg-bin=yarn-bin=/STACK:8388608"); + } + let out_dir = env::var("OUT_DIR").unwrap(); let dest_path = Path::new(&out_dir).join("ui_assets.rs"); diff --git a/packages/zpm/src/build.rs b/packages/zpm/src/build.rs index dc7958a6..69ecac9d 100644 --- a/packages/zpm/src/build.rs +++ b/packages/zpm/src/build.rs @@ -108,7 +108,7 @@ impl BuildRequest { .with_project(project) .enable_trust_check() .with_package(project, &self.locator)? - .with_env_variable("INIT_CWD", cwd_abs.as_str()) + .with_env_variable("INIT_CWD", &cwd_abs.to_native_string()) .with_cwd(cwd_abs.clone()); crate::report::if_active(|report| { diff --git a/packages/zpm/src/builtins/node.rs b/packages/zpm/src/builtins/node.rs index 3f0302e7..fa1ce2a7 100644 --- a/packages/zpm/src/builtins/node.rs +++ b/packages/zpm/src/builtins/node.rs @@ -11,11 +11,56 @@ use crate::{ error::Error, fetchers::PackageData, install::{FetchResult, InstallContext, IntoResolutionResult, ResolutionResult}, manifest::bin::BinField, npm::NpmEntryExt, resolvers::Resolution }; -static PLATFORM_VARIANTS: &[(System, &str, &str)] = &[ - (System::new(Some(Cpu::X86_64), Some(Os::Linux), None), "linux-x64", "bin/node"), - (System::new(Some(Cpu::Aarch64), Some(Os::Linux), None), "linux-arm64", "bin/node"), - (System::new(Some(Cpu::X86_64), Some(Os::MacOS), None), "darwin-x64", "bin/node"), - (System::new(Some(Cpu::Aarch64), Some(Os::MacOS), None), "darwin-arm64", "bin/node"), +#[derive(Clone, Copy)] +enum NodeArchive { + Tgz, + Zip, +} + +struct PlatformVariant { + system: System, + file_name: &'static str, + bin_file: &'static str, + archive: NodeArchive, +} + +static PLATFORM_VARIANTS: &[PlatformVariant] = &[ + PlatformVariant { + system: System::new(Some(Cpu::X86_64), Some(Os::Linux), None), + file_name: "linux-x64", + bin_file: "bin/node", + archive: NodeArchive::Tgz, + }, + PlatformVariant { + system: System::new(Some(Cpu::Aarch64), Some(Os::Linux), None), + file_name: "linux-arm64", + bin_file: "bin/node", + archive: NodeArchive::Tgz, + }, + PlatformVariant { + system: System::new(Some(Cpu::X86_64), Some(Os::MacOS), None), + file_name: "darwin-x64", + bin_file: "bin/node", + archive: NodeArchive::Tgz, + }, + PlatformVariant { + system: System::new(Some(Cpu::Aarch64), Some(Os::MacOS), None), + file_name: "darwin-arm64", + bin_file: "bin/node", + archive: NodeArchive::Tgz, + }, + PlatformVariant { + system: System::new(Some(Cpu::X86_64), Some(Os::Windows), None), + file_name: "win-x64", + bin_file: "node.exe", + archive: NodeArchive::Zip, + }, + PlatformVariant { + system: System::new(Some(Cpu::Aarch64), Some(Os::Windows), None), + file_name: "win-arm64", + bin_file: "node.exe", + archive: NodeArchive::Zip, + }, ]; pub async fn resolve_nodejs_version(context: &InstallContext<'_>, range: &zpm_semver::Range) -> Result, Error> { @@ -71,9 +116,9 @@ pub async fn resolve_nodejs_locator(context: &InstallContext<'_>, locator: &Loca } fn build_nodejs_parent_resolution(context: &InstallContext<'_>, locator: Locator, version: zpm_semver::Version) -> Result { - let variants = PLATFORM_VARIANTS.iter().map(|(_, file_name, _)| { + let variants = PLATFORM_VARIANTS.iter().map(|variant| { let name - = format!("@yarnpkg/node-{}", file_name); + = format!("@yarnpkg/node-{}", variant.file_name); let range = zpm_semver::Range::exact(version.clone()); @@ -94,9 +139,9 @@ fn build_nodejs_parent_resolution(context: &InstallContext<'_>, locator: Locator } pub async fn resolve_nodejs_variant_descriptor(context: &InstallContext<'_>, descriptor: &Descriptor, range: &zpm_semver::Range) -> Result { - let (system, _, _) + let variant = PLATFORM_VARIANTS.iter() - .find(|(_, file_name, _)| descriptor.ident.as_str() == &format!("@yarnpkg/node-{}", file_name)) + .find(|variant| descriptor.ident.as_str() == &format!("@yarnpkg/node-{}", variant.file_name)) .ok_or(Error::Unsupported)?; let version @@ -110,29 +155,29 @@ pub async fn resolve_nodejs_variant_descriptor(context: &InstallContext<'_>, des let mut resolution = Resolution::new_empty(locator, version); - resolution.requirements = system.to_requirements(); + resolution.requirements = variant.system.to_requirements(); resolution.into_resolution_result(context) } pub async fn resolve_nodejs_variant_locator(context: &InstallContext<'_>, locator: &Locator, version: &zpm_semver::Version) -> Result { - let (system, _, _) + let variant = PLATFORM_VARIANTS.iter() - .find(|(_, file_name, _)| locator.ident.as_str() == &format!("@yarnpkg/node-{}", file_name)) + .find(|variant| locator.ident.as_str() == &format!("@yarnpkg/node-{}", variant.file_name)) .ok_or(Error::Unsupported)?; let mut resolution = Resolution::new_empty(locator.clone(), version.clone()); - resolution.requirements = system.to_requirements(); + resolution.requirements = variant.system.to_requirements(); resolution.into_resolution_result(context) } pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Locator, version: &zpm_semver::Version, is_mock_request: bool) -> Result { - let (system, file_name, bin_file) + let variant = PLATFORM_VARIANTS.iter() - .find(|(_, file_name, _)| locator.ident.as_str() == &format!("@yarnpkg/node-{}", file_name)) + .find(|variant| locator.ident.as_str() == &format!("@yarnpkg/node-{}", variant.file_name)) .ok_or(Error::Unsupported)?; if is_mock_request { @@ -151,8 +196,12 @@ pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Lo let version_str = version.to_file_string(); - let url - = format!("{}/v{}/node-v{}-{}.tar.gz", project.config.settings.node_dist_url.value, version_str, version_str, file_name); + let url = match variant.archive { + NodeArchive::Tgz + => format!("{}/v{}/node-v{}-{}.tar.gz", project.config.settings.node_dist_url.value, version_str, version_str, variant.file_name), + NodeArchive::Zip + => format!("{}/v{}/node-v{}-{}.zip", project.config.settings.node_dist_url.value, version_str, version_str, variant.file_name), + }; let package_cache = context.package_cache .expect("The package cache is required for fetching npm packages"); @@ -166,11 +215,13 @@ pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Lo let locator_ident = locator.ident.clone(); let bin_file - = bin_file.to_string(); + = variant.bin_file.to_string(); let system_os - = system.os.clone(); + = variant.system.os.clone(); let system_arch - = system.arch.clone(); + = variant.system.arch.clone(); + let archive + = variant.archive; let cached_blob = package_cache.ensure_blob(locator.clone(), ".zip", || async move { let bytes @@ -180,9 +231,6 @@ pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Lo .bytes().await?; let archive = tokio::task::spawn_blocking(move || -> Result, Error> { - let tar_data - = zpm_formats::tar::unpack_tgz(&bytes)?; - #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GeneratedManifest<'a> { @@ -209,15 +257,34 @@ pub async fn fetch_nodejs_locator<'a>(context: &InstallContext<'a>, locator: &Lo let serialized_manifest = JsonDocument::to_string(&manifest)?; - let entries - = zpm_formats::tar::entries_from_tar(&tar_data)? - .into_iter() - .strip_first_segment() - .filter(|entry| entry.name.as_str() == bin_file.as_str()) - .chain(once(Entry::new_file(Path::from_str("package.json").unwrap(), Cow::Owned(serialized_manifest.into_bytes())))) - .prepare_npm_entries(&package_subdir_for_entries)?; - - Ok(cache_packer.pack(entries)?) + match archive { + NodeArchive::Tgz => { + let tar_data + = zpm_formats::tar::unpack_tgz(&bytes)?; + + let entries + = zpm_formats::tar::entries_from_tar(&tar_data)? + .into_iter() + .strip_first_segment() + .filter(|entry| entry.name.as_str() == bin_file.as_str()) + .chain(once(Entry::new_file(Path::from_str("package.json").unwrap(), Cow::Owned(serialized_manifest.into_bytes())))) + .prepare_npm_entries(&package_subdir_for_entries)?; + + Ok(cache_packer.pack(entries)?) + }, + + NodeArchive::Zip => { + let entries + = zpm_formats::zip::entries_from_zip(&bytes)? + .into_iter() + .strip_first_segment() + .filter(|entry| entry.name.as_str() == bin_file.as_str()) + .chain(once(Entry::new_file(Path::from_str("package.json").unwrap(), Cow::Owned(serialized_manifest.into_bytes())))) + .prepare_npm_entries(&package_subdir_for_entries)?; + + Ok(cache_packer.pack(entries)?) + }, + } }).await??; Ok(archive) diff --git a/packages/zpm/src/commands/bin.rs b/packages/zpm/src/commands/bin.rs index 1162ef53..6cf2b14f 100644 --- a/packages/zpm/src/commands/bin.rs +++ b/packages/zpm/src/commands/bin.rs @@ -1,6 +1,4 @@ use clipanion::cli; -use zpm_utils::ToFileString; - use crate::{error::Error, project}; /// List binaries available to the current workspace @@ -48,7 +46,7 @@ impl Bin { }, }; - println!("{}", path.to_file_string()); + println!("{}", path.to_native_string()); Ok(()) } diff --git a/packages/zpm/src/commands/debug/flamegraph.rs b/packages/zpm/src/commands/debug/flamegraph.rs index 3f2d48f2..f6c5e125 100644 --- a/packages/zpm/src/commands/debug/flamegraph.rs +++ b/packages/zpm/src/commands/debug/flamegraph.rs @@ -1,7 +1,7 @@ use std::process::{Command, ExitStatus}; use clipanion::cli; -use zpm_utils::{Path, ToFileString}; +use zpm_utils::Path; use crate::{error::Error}; @@ -31,7 +31,7 @@ impl Flamegraph { = Path::current_exe()?; let result = Command::new(samply_path.to_path_buf()) - .args(["record", ¤t_exe.to_file_string()]) + .args(["record", ¤t_exe.to_native_string()]) .args(&self.args) .status()?; diff --git a/packages/zpm/src/commands/link.rs b/packages/zpm/src/commands/link.rs index aaf97a1f..0bc0df41 100644 --- a/packages/zpm/src/commands/link.rs +++ b/packages/zpm/src/commands/link.rs @@ -58,17 +58,28 @@ impl Link { let mut document = JsonDocument::new(manifest_content)?; + let root_canonical_path + = root_path.fs_canonicalize()?; + let shell_path + = root_path.with_join(&project.shell_cwd); + for destination in &self.destinations { + let destination_path = if destination.is_absolute() { + destination.clone() + } else { + shell_path.with_join(destination) + }; + let canonical_destination - = destination.fs_canonicalize()?; + = destination_path.fs_canonicalize()?; // Prevent linking a project to itself - if root_path.contains(&canonical_destination) || canonical_destination.contains(root_path) { + if root_canonical_path.contains(&canonical_destination) || canonical_destination.contains(&root_canonical_path) { return Err(Error::CannotLinkToSelf); } let target_workspace - = Workspace::from_root_path(&canonical_destination)?; + = Workspace::from_root_path(&destination_path)?; if self.all { let child_workspaces @@ -76,7 +87,7 @@ impl Link { if let Some(name) = &target_workspace.manifest.name { if self.private || !target_workspace.manifest.private.unwrap_or(false) { - self.add_resolution(&mut document, name, &canonical_destination, root_path)?; + self.add_resolution(&mut document, name, &destination_path, root_path)?; } } @@ -94,9 +105,9 @@ impl Link { } else { let name = target_workspace.manifest.name.as_ref() - .ok_or_else(|| Error::LinkedPackageMissingName(canonical_destination.clone()))?; + .ok_or_else(|| Error::LinkedPackageMissingName(destination_path.clone()))?; - self.add_resolution(&mut document, name, &canonical_destination, root_path)?; + self.add_resolution(&mut document, name, &destination_path, root_path)?; } } @@ -107,7 +118,7 @@ impl Link { fn add_resolution(&self, document: &mut JsonDocument, name: &Ident, workspace_path: &Path, root_path: &Path) -> Result<(), Error> { let portal_path = if self.relative { - workspace_path.relative_to(root_path) + workspace_path.relative_to_if_same_root(root_path) } else { workspace_path.clone() }; diff --git a/packages/zpm/src/commands/python.rs b/packages/zpm/src/commands/python.rs index cf2e2971..eba676be 100644 --- a/packages/zpm/src/commands/python.rs +++ b/packages/zpm/src/commands/python.rs @@ -2,8 +2,6 @@ use std::process::ExitStatus; use clipanion::cli; use zpm_config::IslandLinker; -use zpm_utils::ToFileString; - use crate::{error::Error, project, script::ScriptEnvironment}; fn prepend_env_path(key: &str, value: &str, separator: char) -> String { @@ -19,7 +17,7 @@ fn prepend_env_path(key: &str, value: &str, separator: char) -> String { fn build_site_packages_pythonpath(site_packages_path: &zpm_utils::Path, separator: char) -> String { let mut entries - = vec![site_packages_path.to_file_string()]; + = vec![site_packages_path.to_native_string()]; if let Ok(read_dir) = site_packages_path.fs_read_dir() { for entry in read_dir.flatten() { @@ -44,7 +42,7 @@ fn build_site_packages_pythonpath(site_packages_path: &zpm_utils::Path, separato entries.push( site_packages_path .with_join_str(dirname.as_ref()) - .to_file_string(), + .to_native_string(), ); } } @@ -120,7 +118,7 @@ impl Python { }; let path - = prepend_env_path("PATH", &bin_path.to_file_string(), path_separator); + = prepend_env_path("PATH", &bin_path.to_native_string(), path_separator); let pythonpath = prepend_env_path( @@ -130,7 +128,7 @@ impl Python { ); env = env - .with_env_variable("VIRTUAL_ENV", &venv_path.to_file_string()) + .with_env_variable("VIRTUAL_ENV", &venv_path.to_native_string()) .with_env_variable("PYTHONPATH", &pythonpath) .with_env_variable("PATH", &path); } diff --git a/packages/zpm/src/commands/run.rs b/packages/zpm/src/commands/run.rs index 425be09c..8ee50de6 100644 --- a/packages/zpm/src/commands/run.rs +++ b/packages/zpm/src/commands/run.rs @@ -1,8 +1,8 @@ -use std::{os::unix::process::ExitStatusExt, process::ExitStatus}; +use std::process::ExitStatus; use indexmap::IndexMap; use zpm_parsers::JsonDocument; -use zpm_utils::Path; +use zpm_utils::{Path, exit_status_from_code}; use clipanion::cli; use crate::{commands::tasks::run_silent_dependencies::TaskRunSilentDependencies, error::Error, project, script::ScriptEnvironment}; @@ -33,7 +33,7 @@ impl RunList { .lazy_install().await?; list_scripts(&project, self.json)?; - Ok(ExitStatus::from_raw(0)) + Ok(exit_status_from_code(0)) } } @@ -195,7 +195,7 @@ impl Run { Error::BinaryNotFound(name) }) } else { - Ok(ExitStatus::from_raw(0)) + Ok(exit_status_from_code(0)) } } else { Err(maybe_binary.unwrap_err()) diff --git a/packages/zpm/src/commands/tasks/runner.rs b/packages/zpm/src/commands/tasks/runner.rs index 250cd7c6..d17d9efa 100644 --- a/packages/zpm/src/commands/tasks/runner.rs +++ b/packages/zpm/src/commands/tasks/runner.rs @@ -1,9 +1,9 @@ -use std::{collections::HashSet, io::Write, os::unix::process::ExitStatusExt, process::ExitStatus, sync::Arc}; +use std::{collections::HashSet, io::Write, process::ExitStatus, sync::Arc}; /// Create an ExitStatus from a logical exit code. /// On Unix, `from_raw` expects a wait status where the exit code is in bits 8-15. pub fn exit_status_from_code(code: i32) -> ExitStatus { - ExitStatus::from_raw(code << 8) + zpm_utils::exit_status_from_code(code) } use async_trait::async_trait; diff --git a/packages/zpm/src/commands/workspace.rs b/packages/zpm/src/commands/workspace.rs index 3559de12..871f94be 100644 --- a/packages/zpm/src/commands/workspace.rs +++ b/packages/zpm/src/commands/workspace.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, process::ExitCode}; +use std::process::ExitCode; use zpm_primitives::Ident; use clipanion::{cli, prelude::*}; @@ -35,7 +35,7 @@ impl Workspace { let cwd = workspace.path.clone(); - std::env::set_current_dir(PathBuf::from(cwd.as_str()))?; + std::env::set_current_dir(cwd.to_path_buf())?; let env = self.cli_environment.clone().with_argv(self.args.clone()); Ok(tokio::task::block_in_place(move || { diff --git a/packages/zpm/src/constraints/mod.rs b/packages/zpm/src/constraints/mod.rs index 2fbaf3c4..c08bc66b 100644 --- a/packages/zpm/src/constraints/mod.rs +++ b/packages/zpm/src/constraints/mod.rs @@ -1,6 +1,6 @@ use structs::{ConstraintsDependency, ConstraintsPackage, ConstraintsWorkspace}; use zpm_parsers::JsonDocument; -use zpm_utils::{Path, ToFileString}; +use zpm_utils::Path; use crate::{ constraints::structs::{ConstraintsContext, ConstraintsOutput}, error::Error, install::InstallState, project::{Project, Workspace}, resolvers::Resolution, script::ScriptEnvironment @@ -53,7 +53,7 @@ pub async fn check_constraints(project: &Project, fix: bool) -> Result Result { let current_dir = current_exe.dirname().ok_or(())?; let sibling_switch - = current_dir.with_join_str("yarn"); + = current_dir.with_join_str(&format!("yarn{}", std::env::consts::EXE_SUFFIX)); if sibling_switch.fs_exists() { - Ok(sibling_switch.to_file_string()) + Ok(sibling_switch.to_native_string()) } else { Err(()) } diff --git a/packages/zpm/src/daemon/coordinator.rs b/packages/zpm/src/daemon/coordinator.rs index 7e27e5b6..850615c5 100644 --- a/packages/zpm/src/daemon/coordinator.rs +++ b/packages/zpm/src/daemon/coordinator.rs @@ -101,7 +101,7 @@ async fn run_daemon_internal( = mpsc::unbounded_channel::(); let project_cwd_for_loop - = project.project_cwd.to_file_string(); + = project.project_cwd.to_path_buf(); tokio::spawn(async move { run_coordinator_loop( @@ -180,7 +180,7 @@ async fn run_coordinator_loop( default_warmup_period: Duration, file_notify_tx: mpsc::UnboundedSender, taskfile_notify_tx: mpsc::UnboundedSender, - project_cwd: String, + project_cwd: std::path::PathBuf, ) { let mut state = CoordinatorState::new( @@ -188,7 +188,7 @@ async fn run_coordinator_loop( max_closed_tasks, file_notify_tx, taskfile_notify_tx, - std::path::PathBuf::from(project_cwd), + project_cwd, ); initialize_taskfile_watcher(&mut state.taskfile_watcher, &project); @@ -928,7 +928,7 @@ fn is_binary_extension(path: &Path) -> bool { fn read_file_content(path: &Path) -> Option<(String, String)> { if is_binary_extension(path) { - let bytes = std::fs::read(path.to_file_string()).ok()?; + let bytes = std::fs::read(path.to_path_buf()).ok()?; let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); Some((encoded, "base64".to_string())) } else { diff --git a/packages/zpm/src/daemon/coordinator_state/mod.rs b/packages/zpm/src/daemon/coordinator_state/mod.rs index 8b89024e..5a89d466 100644 --- a/packages/zpm/src/daemon/coordinator_state/mod.rs +++ b/packages/zpm/src/daemon/coordinator_state/mod.rs @@ -141,9 +141,9 @@ impl CoordinatorState { ) -> TransitionEffects { if self.graph.try_complete_task(task_id) { self.on_task_completed(task_id, exit_code) - } else if self.graph.has_failed_subtask(task_id) { + } else if let Some(subtask_exit_code) = self.graph.failed_subtask_exit_code(task_id) { // Subtask already failed - fail the parent (no signal for propagated failure) - self.fail_task(task_id, 1, None) + self.fail_task(task_id, subtask_exit_code, None) } else { // Task stays in WaitingForSubtasks until all subtasks complete TransitionEffects::default() @@ -192,7 +192,7 @@ impl CoordinatorState { ) -> TransitionEffects { let mut effects = TransitionEffects::default(); - self.graph.mark_failed(task_id); + self.graph.mark_failed(task_id, exit_code); let close = self.close_task(task_id); effects.notifications.push(DaemonNotification::TaskCompleted { diff --git a/packages/zpm/src/daemon/coordinator_state/task_graph.rs b/packages/zpm/src/daemon/coordinator_state/task_graph.rs index 90a824e8..2c154fa3 100644 --- a/packages/zpm/src/daemon/coordinator_state/task_graph.rs +++ b/packages/zpm/src/daemon/coordinator_state/task_graph.rs @@ -57,6 +57,8 @@ pub struct TaskInfo { pub is_target: bool, /// For long-lived tasks: has the warm-up period completed? pub warm_up_complete: bool, + /// Exit code that caused the task to fail, if any. + pub failure_exit_code: Option, } impl Default for TaskInfo { @@ -65,6 +67,7 @@ impl Default for TaskInfo { state: TaskState::Pending, is_target: false, warm_up_complete: false, + failure_exit_code: None, } } } @@ -360,11 +363,19 @@ impl TaskGraph { .unwrap_or(false) } - pub fn has_failed_subtask(&self, task_id: &ContextualTaskId) -> bool { + pub fn failed_subtask_exit_code(&self, task_id: &ContextualTaskId) -> Option { if let Some(subtasks) = self.subtasks.get(task_id) { - subtasks.iter().any(|s| self.is_failed_or_cancelled(s)) + subtasks + .iter() + .find(|subtask| self.is_failed_or_cancelled(subtask)) + .map(|subtask| { + self.tasks + .get(subtask) + .and_then(|info| info.failure_exit_code) + .unwrap_or(1) + }) } else { - false + None } } @@ -404,8 +415,10 @@ impl TaskGraph { self.ensure_task_info(task_id).state = TaskState::Completed; } - pub fn mark_failed(&mut self, task_id: &ContextualTaskId) { - self.ensure_task_info(task_id).state = TaskState::Failed; + pub fn mark_failed(&mut self, task_id: &ContextualTaskId, exit_code: i32) { + let info = self.ensure_task_info(task_id); + info.state = TaskState::Failed; + info.failure_exit_code = Some(exit_code); } pub fn mark_cancelled(&mut self, task_id: &ContextualTaskId) { @@ -457,4 +470,3 @@ impl TaskGraph { self.subtasks.len() } } - diff --git a/packages/zpm/src/daemon/coordinator_state/taskfile_watcher.rs b/packages/zpm/src/daemon/coordinator_state/taskfile_watcher.rs index 2590ab31..b8ebeda5 100644 --- a/packages/zpm/src/daemon/coordinator_state/taskfile_watcher.rs +++ b/packages/zpm/src/daemon/coordinator_state/taskfile_watcher.rs @@ -4,7 +4,7 @@ use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use tokio::sync::mpsc; use zpm_primitives::Ident; use zpm_tasks::TaskFile; -use zpm_utils::{Path, ToFileString}; +use zpm_utils::Path; pub struct TaskfileWatcher { watcher: RecommendedWatcher, @@ -47,7 +47,7 @@ impl TaskfileWatcher { ws_set.remove(&workspace); if ws_set.is_empty() { self.file_to_workspaces.remove(path); - let _ = self.watcher.unwatch(std::path::Path::new(&path.to_file_string())); + let _ = self.watcher.unwatch(&path.to_path_buf()); } } } @@ -65,7 +65,7 @@ impl TaskfileWatcher { if is_new { let _ = self .watcher - .watch(std::path::Path::new(&path.to_file_string()), RecursiveMode::NonRecursive); + .watch(&path.to_path_buf(), RecursiveMode::NonRecursive); } } diff --git a/packages/zpm/src/daemon/platform.rs b/packages/zpm/src/daemon/platform.rs index 30b85d7d..3e221892 100644 --- a/packages/zpm/src/daemon/platform.rs +++ b/packages/zpm/src/daemon/platform.rs @@ -7,11 +7,16 @@ pub fn kill_process_group(pid: u32) { } } -#[cfg(not(unix))] -pub fn kill_process_group(_pid: u32) { - unimplemented!("daemon platform operations are not supported on this OS") +#[cfg(windows)] +pub fn kill_process_group(pid: u32) { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status(); } +#[cfg(not(any(unix, windows)))] +pub fn kill_process_group(_pid: u32) {} + /// Send SIGKILL to a process (Unix) or terminate the process (Windows). #[cfg(unix)] pub fn kill_process(pid: u32) { @@ -21,18 +26,26 @@ pub fn kill_process(pid: u32) { } } -#[cfg(not(unix))] -pub fn kill_process(_pid: u32) { - unimplemented!("daemon platform operations are not supported on this OS") +#[cfg(windows)] +pub fn kill_process(pid: u32) { + zpm_utils::terminate_process(pid); } +#[cfg(not(any(unix, windows)))] +pub fn kill_process(_pid: u32) {} + /// Check if a process is still alive. #[cfg(unix)] pub fn is_process_alive(pid: u32) -> bool { unsafe { libc::kill(pid as i32, 0) == 0 } } -#[cfg(not(unix))] +#[cfg(windows)] +pub fn is_process_alive(pid: u32) -> bool { + zpm_utils::is_process_alive(pid) +} + +#[cfg(not(any(unix, windows)))] pub fn is_process_alive(_pid: u32) -> bool { - unimplemented!("daemon platform operations are not supported on this OS") + true } diff --git a/packages/zpm/src/fetchers/exec.rs b/packages/zpm/src/fetchers/exec.rs index c76f9d8b..34db8334 100644 --- a/packages/zpm/src/fetchers/exec.rs +++ b/packages/zpm/src/fetchers/exec.rs @@ -73,7 +73,7 @@ pub async fn fetch_locator<'a>(context: &InstallContext<'a>, locator: &Locator, ScriptEnvironment::new()? .without_pnp_loader() .with_cwd(parent_context_directory.clone()) - .run_exec("node", [wrapper_path.to_file_string(), script_path.to_file_string()]) + .run_exec("node", [wrapper_path.to_native_string(), script_path.to_native_string()]) .await? .ok()?; @@ -142,8 +142,8 @@ fn is_exec_allowed(context: &InstallContext<'_>, locator: &Locator) -> bool { fn make_wrapper(temp_dir: &Path, build_dir: &Path, locator: &str) -> Result { let exec_env = serde_json::json!({ - "tempDir": temp_dir.to_file_string(), - "buildDir": build_dir.to_file_string(), + "tempDir": temp_dir.to_native_string(), + "buildDir": build_dir.to_native_string(), "locator": locator, }); diff --git a/packages/zpm/src/git.rs b/packages/zpm/src/git.rs index 65ebaeb5..4179369d 100644 --- a/packages/zpm/src/git.rs +++ b/packages/zpm/src/git.rs @@ -58,6 +58,11 @@ pub async fn detect_git_operation(p: &Path) -> Result, Erro static DIFF_PATH_NORMALIZER: LazyLock = LazyLock::new(|| Regex::new(r"^/?(.*)/?$").unwrap()); pub async fn diff_folders(original: &Path, user: &Path) -> Result { + let original_native + = original.to_native_string(); + let user_native + = user.to_native_string(); + let diff_command = ScriptEnvironment::new()? // These variables aim to ignore the global git config so we get predictable output // https://git-scm.com/docs/git#Documentation/git.txt-codeGITCONFIGNOSYSTEMcode @@ -76,8 +81,8 @@ pub async fn diff_folders(original: &Path, user: &Path) -> Result "--no-index", "--no-renames", "--text", - original.as_str(), - user.as_str() + &original_native, + &user_native ]) .await? @@ -94,23 +99,62 @@ pub async fn diff_folders(original: &Path, user: &Path) -> Result let diff = String::from_utf8(diff_command.stdout)?; + Ok(normalize_diff_paths(&diff, original, user, &original_native, &user_native)) +} + +fn normalize_diff_paths(diff: &str, original: &Path, user: &Path, original_native: &str, user_native: &str) -> String { let original_path_normalized = DIFF_PATH_NORMALIZER.replace(original.as_str(), "/$1/").to_string(); let user_path_normalized = DIFF_PATH_NORMALIZER.replace(user.as_str(), "/$1/").to_string(); + let original_native_normalized + = DIFF_PATH_NORMALIZER.replace(&original_native.replace('\\', "/"), "/$1/").to_string(); + let user_native_normalized + = DIFF_PATH_NORMALIZER.replace(&user_native.replace('\\', "/"), "/$1/").to_string(); + let original_native_raw_normalized + = DIFF_PATH_NORMALIZER.replace(&original_native, "/$1/").to_string(); + let user_native_raw_normalized + = DIFF_PATH_NORMALIZER.replace(&user_native, "/$1/").to_string(); + let original_path_escaped = regex::escape(&original_path_normalized); let user_path_escaped = regex::escape(&user_path_normalized); + let original_native_escaped + = regex::escape(&original_native_normalized); + let user_native_escaped + = regex::escape(&user_native_normalized); + let original_native_raw_escaped + = regex::escape(&original_native_raw_normalized); + let user_native_raw_escaped + = regex::escape(&user_native_raw_normalized); let regex - = Regex::new(format!("(a|b)({}|{})", original_path_escaped, user_path_escaped).as_str()).unwrap(); + = Regex::new(format!( + "(a|b)({}|{}|{}|{}|{}|{})", + original_path_escaped, + user_path_escaped, + original_native_escaped, + user_native_escaped, + original_native_raw_escaped, + user_native_raw_escaped, + ).as_str()).unwrap(); let diff = regex.replace_all(&diff, "$1/").to_string(); - Ok(diff) + let diff_header_regex + = Regex::new(r#"(?m)^diff --git "([ab]/[^"]+)" "([ab]/[^"]+)"$"#).unwrap(); + let diff + = diff_header_regex.replace_all(&diff, "diff --git $1 $2").to_string(); + + let diff_file_header_regex + = Regex::new(r#"(?m)^(---|\+\+\+) "([ab]/[^"]+)"$"#).unwrap(); + let diff + = diff_file_header_regex.replace_all(&diff, "$1 $2").to_string(); + + diff } fn glob_to_regex(glob: &str) -> String { @@ -347,16 +391,24 @@ async fn download_into(source: &GitSource, commit: &str, download_dir: &Path, ht } async fn git_clone_into(source: &GitSource, commit: &str, clone_dir: &Path, config: &HttpConfig, approved_repos: &[Setting]) -> Result<(), Error> { - repeat_until_ok(source.to_urls(), |clone_url| async move { - validate_repo_url(&clone_url, config, approved_repos)?; + let clone_dir_native + = clone_dir.to_native_string(); - ScriptEnvironment::new()? - .with_env(make_git_env()) - .run_exec("git", &["clone", "-c", "core.autocrlf=false", &clone_url, clone_dir.as_str()]) - .await? - .ok()?; + repeat_until_ok(source.to_urls(), |clone_url| { + let clone_dir_native + = clone_dir_native.clone(); + + async move { + validate_repo_url(&clone_url, config, approved_repos)?; + + ScriptEnvironment::new()? + .with_env(make_git_env()) + .run_exec("git", &["clone", "-c", "core.autocrlf=false", &clone_url, &clone_dir_native]) + .await? + .ok()?; - Ok::<(), Error>(()) + Ok::<(), Error>(()) + } }).await?; ScriptEnvironment::new()? @@ -368,3 +420,39 @@ async fn git_clone_into(source: &GitSource, commit: &str, clone_dir: &Path, conf Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_quoted_windows_diff_paths() { + let original + = Path::from_file_string("/C:/Users/RUNNER~1/AppData/Local/Temp/patch-0/original").unwrap(); + let user + = Path::from_file_string("/C:/Users/RUNNER~1/AppData/Local/Temp/patch-0/user").unwrap(); + + let diff = concat!( + "diff --git \"a/C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\patch-0\\original/index.js\" \"b/C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\patch-0\\user/index.js\"\n", + "index bb9c6f6876154493527577cd78279aa6bb686ebb..fa87b36f4ac82fadaebf78bd20958ed44bfdc3c6 100644\n", + "--- \"a/C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\patch-0\\original/index.js\"\n", + "+++ \"b/C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\patch-0\\user/index.js\"\n", + ); + + assert_eq!( + normalize_diff_paths( + diff, + &original, + &user, + r"C:\Users\RUNNER~1\AppData\Local\Temp\patch-0\original", + r"C:\Users\RUNNER~1\AppData\Local\Temp\patch-0\user", + ), + concat!( + "diff --git a/index.js b/index.js\n", + "index bb9c6f6876154493527577cd78279aa6bb686ebb..fa87b36f4ac82fadaebf78bd20958ed44bfdc3c6 100644\n", + "--- a/index.js\n", + "+++ b/index.js\n", + ), + ); + } +} diff --git a/packages/zpm/src/install.rs b/packages/zpm/src/install.rs index 4cb7994e..e843101a 100644 --- a/packages/zpm/src/install.rs +++ b/packages/zpm/src/install.rs @@ -742,6 +742,10 @@ fn check_resolution_cache(ctx: &InstallContext<'_>, lockfile: &Lockfile, descrip = lockfile.entries.get(locator) .unwrap_or_else(|| panic!("Expected a matching resolution to be found in the lockfile for any resolved locator; not found for {}.", locator.to_print_string())); + if !lockfile_variants_cover_systems(ctx, lockfile, &entry.resolution) { + return Ok(Some(CacheHit::Pinned(locator.clone()))); + } + return Ok(Some(CacheHit::Full(entry.resolution.clone().into_resolution_result(ctx)?))); } } @@ -753,6 +757,24 @@ fn check_resolution_cache(ctx: &InstallContext<'_>, lockfile: &Lockfile, descrip Ok(None) } +fn lockfile_variants_cover_systems(ctx: &InstallContext<'_>, lockfile: &Lockfile, resolution: &Resolution) -> bool { + let Some(systems) = ctx.systems else { + return true; + }; + + if resolution.variants.is_empty() { + return true; + } + + systems.iter().all(|system| { + resolution.variants.iter().any(|variant| { + lockfile.resolutions.get(variant) + .and_then(|locator| lockfile.entries.get(locator)) + .is_some_and(|entry| entry.resolution.requirements.validate_system(system)) + }) + }) +} + // Legacy types kept for compatibility with resolver/fetcher function signatures. // These will be removed once the resolver/fetcher modules are refactored. #[derive(Clone, Debug)] diff --git a/packages/zpm/src/linker/helpers.rs b/packages/zpm/src/linker/helpers.rs index fdf4cc1b..fe446b30 100644 --- a/packages/zpm/src/linker/helpers.rs +++ b/packages/zpm/src/linker/helpers.rs @@ -1,4 +1,4 @@ -use std::{collections::{BTreeMap, BTreeSet}, fs::Permissions, os::unix::fs::PermissionsExt, vec}; +use std::{collections::{BTreeMap, BTreeSet}, vec}; use zpm_formats::iter_ext::IterExt; use zpm_parsers::JsonDocument; @@ -69,8 +69,7 @@ pub fn fs_remove_nm(nm_path: Path) -> Result<(), Error> { continue; } - path.fs_rm() - .unwrap(); + path.fs_rm()?; } if !has_dot_entries { @@ -154,7 +153,7 @@ fn fs_extract_archive_impl(destination: &Path, package_data: &PackageData, mut m ExtractMode::Classic => { target_path .fs_write(&entry.data)? - .fs_set_permissions(Permissions::from_mode(entry.mode as u32))?; + .fs_set_mode(entry.mode as u32)?; }, } } @@ -170,16 +169,21 @@ fn fs_extract_archive_impl(destination: &Path, package_data: &PackageData, mut m /// Ensures `target` is a hardlink to `source` (no-op if they already /// share an inode). `source` must exist. fn ensure_hardlink(target: &Path, source: &Path) -> Result<(), Error> { - use std::os::unix::fs::MetadataExt; - - let dest_meta = target.fs_symlink_metadata().ok(); - let source_meta = source.fs_metadata().ok(); - - let already_linked = match (&dest_meta, &source_meta) { - (Some(d), Some(s)) => d.dev() == s.dev() && d.ino() == s.ino(), - _ => false, + #[cfg(unix)] + let already_linked = { + use std::os::unix::fs::MetadataExt; + + let dest_meta = target.fs_symlink_metadata().ok(); + let source_meta = source.fs_metadata().ok(); + match (&dest_meta, &source_meta) { + (Some(d), Some(s)) => d.dev() == s.dev() && d.ino() == s.ino(), + _ => false, + } }; + #[cfg(windows)] + let already_linked = false; + if already_linked { return Ok(()); } @@ -203,7 +207,7 @@ fn write_canonical(target: &Path, data: &[u8], mode_bits: u32) -> Result<(), Err target .fs_write(data)? - .fs_set_permissions(Permissions::from_mode(mode_bits))?; + .fs_set_mode(mode_bits)?; Ok(()) } @@ -239,7 +243,6 @@ const CAS_DEFAULT_MODE: u32 = 0o644; fn link_into_cas(target_path: &Path, data: &[u8], mode: u32, index_root: &Path) -> Result<(), Error> { use sha1::{Digest, Sha1}; - use std::os::unix::fs::MetadataExt; let mode_bits = mode & 0o777; @@ -267,9 +270,21 @@ fn link_into_cas(target_path: &Path, data: &[u8], mode: u32, index_root: &Path) if !needs_rewrite { // mtime != SAFE_TIME ⇒ external write since the last install. if let Ok(metadata) = index_path.fs_metadata() { - if metadata.mtime() != CAS_SAFE_TIME_SECS { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.mtime() != CAS_SAFE_TIME_SECS { + needs_rewrite = true; + } + } + + #[cfg(windows)] + { + let safe_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(CAS_SAFE_TIME_SECS as u64); + if metadata.modified().ok() != Some(safe_time) { needs_rewrite = true; } + } } } @@ -277,7 +292,7 @@ fn link_into_cas(target_path: &Path, data: &[u8], mode: u32, index_root: &Path) // Write through the existing path: cross-project hardlinks // inherit the repair without losing inode identity. index_path.fs_write(data)?; - index_path.fs_set_permissions(Permissions::from_mode(mode_bits))?; + index_path.fs_set_mode(mode_bits)?; set_safe_mtime(&index_path)?; } diff --git a/packages/zpm/src/linker/nm/mod.rs b/packages/zpm/src/linker/nm/mod.rs index 6deac142..37405868 100644 --- a/packages/zpm/src/linker/nm/mod.rs +++ b/packages/zpm/src/linker/nm/mod.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use zpm_primitives::{VersionFilter, Ident, Locator, Reference}; -use zpm_sync::{SyncItem, SyncTemplate, SyncTree}; +use zpm_sync::{LinkType, SyncItem, SyncTemplate, SyncTree}; use zpm_utils::{FromFileString, IoResultExt, Path, ToHumanString}; use crate::{ @@ -146,7 +146,7 @@ fn register_workspace_symlinks_at( let target_path = workspace_dir - .relative_to(&host_abs_path.with_join(&symlink_path).dirname().unwrap_or_default()); + .relative_to_if_same_root(&host_abs_path.with_join(&symlink_path).dirname().unwrap_or_default()); let symlink_location = host_abs_path.with_join(&symlink_path); @@ -241,8 +241,13 @@ fn generate_workspace_node_modules( = workspace_dir .with_join_str("node_modules"); + let link_type = match project.config.settings.win_link_type.value { + zpm_config::WinLinkType::Symlinks => LinkType::Symlink, + zpm_config::WinLinkType::Junctions => LinkType::Junction, + }; let mut workspace_nm_tree - = SyncTree::new(); + = SyncTree::new() + .with_link_type(link_type); workspace_nm_tree.dry_run = false; @@ -349,7 +354,7 @@ fn generate_workspace_node_modules( } let target_path - = package_directory.relative_to(&child_abs_path.dirname().unwrap()); + = package_directory.relative_to_if_same_root(&child_abs_path.dirname().unwrap()); workspace_nm_tree.register_entry(child_rel_path, SyncItem::Symlink { target_path: target_path.clone(), diff --git a/packages/zpm/src/linker/pnpm.rs b/packages/zpm/src/linker/pnpm.rs index c9a2f9e1..7a45f042 100644 --- a/packages/zpm/src/linker/pnpm.rs +++ b/packages/zpm/src/linker/pnpm.rs @@ -18,6 +18,15 @@ fn matches_patterns(ident: &Ident, patterns: &[IdentGlob]) -> bool { patterns.iter().any(|pattern| pattern.check(ident)) } +fn create_link(project: &Project, link_path: &Path, target_path: &Path) -> Result<(), Error> { + match project.config.settings.win_link_type.value { + zpm_config::WinLinkType::Symlinks => link_path.fs_symlink(target_path)?, + zpm_config::WinLinkType::Junctions => link_path.fs_junction(target_path)?, + }; + + Ok(()) +} + /// Collect all packages that should be hoisted based on patterns. /// Returns a map from ident to the locator that should be hoisted (picks the first one found for conflicts). fn collect_hoistable_packages<'a>(tree: &'a ResolutionTree, patterns: &[IdentGlob], locations_by_package: &BTreeMap) -> BTreeMap { @@ -195,14 +204,14 @@ pub async fn link_project_pnpm<'a>(project: &'a Project, install: &'a Install) - .expect("Failed to get directory name"); let symlink_target = package_abs_path - .relative_to(&link_abs_dirname); + .relative_to_if_same_root(&link_abs_dirname); - link_abs_path - .fs_rm_file() + let link_path = link_abs_path + .fs_rm() .ok_missing()? .unwrap_or(&link_abs_path) - .fs_create_parent()? - .fs_symlink(&symlink_target)?; + .fs_create_parent()?; + create_link(project, link_path, &symlink_target)?; } // Track which packages are direct dependencies of workspaces @@ -241,14 +250,14 @@ pub async fn link_project_pnpm<'a>(project: &'a Project, install: &'a Install) - let symlink_target = package_abs_path - .relative_to(&link_abs_dirname); + .relative_to_if_same_root(&link_abs_dirname); - link_abs_path - .fs_rm_file() + let link_path = link_abs_path + .fs_rm() .ok_missing()? .unwrap_or(&link_abs_path) - .fs_create_parent()? - .fs_symlink(&symlink_target)?; + .fs_create_parent()?; + create_link(project, link_path, &symlink_target)?; } // Second pass: create symlinks in node_modules directories @@ -314,14 +323,14 @@ pub async fn link_project_pnpm<'a>(project: &'a Project, install: &'a Install) - // ../.pnpm/@types-no-deps-npm-1.0.0-xyz/node_modules/@types/no-deps let symlink_target = dep_abs_path - .relative_to(&link_abs_dirname); + .relative_to_if_same_root(&link_abs_dirname); - link_abs_path - .fs_rm_file() + let link_path = link_abs_path + .fs_rm() .ok_missing()? .unwrap_or(&link_abs_path) - .fs_create_parent()? - .fs_symlink(&symlink_target)?; + .fs_create_parent()?; + create_link(project, link_path, &symlink_target)?; } if !has_explicit_self_dependency && !locator.reference.is_workspace_reference() { diff --git a/packages/zpm/src/lockfile.rs b/packages/zpm/src/lockfile.rs index a38970c6..24c50282 100644 --- a/packages/zpm/src/lockfile.rs +++ b/packages/zpm/src/lockfile.rs @@ -433,7 +433,7 @@ pub fn from_pnpm_node_modules(project_cwd: &Path, config: &Configuration) -> Res let output = std::process::Command::new("pnpm") .args(["list", "-r", "--json", depth_flag]) - .current_dir(project_cwd.as_str()) + .current_dir(project_cwd.to_path_buf()) .output() .map_err(|_| Error::PnpmNodeModulesReadError)?; diff --git a/packages/zpm/src/prepare.rs b/packages/zpm/src/prepare.rs index b94dc1a3..cac99c17 100644 --- a/packages/zpm/src/prepare.rs +++ b/packages/zpm/src/prepare.rs @@ -236,9 +236,10 @@ async fn prepare_yarn_classic_project(folder_path: &Path, params: &PrepareParams let pack_path = folder_path .with_join_str("package.tgz"); + let pack_path_arg = pack_path.to_native_string(); let pack_args = match ¶ms.workspace { - Some(workspace) => vec!["workspace", workspace.as_str(), "pack", "--filename", pack_path.as_str()], - None => vec!["pack", "--filename", pack_path.as_str()], + Some(workspace) => vec!["workspace", workspace.as_str(), "pack", "--filename", pack_path_arg.as_str()], + None => vec!["pack", "--filename", pack_path_arg.as_str()], }; run_prepared_command( @@ -290,7 +291,8 @@ async fn prepare_yarn_modern_project(folder_path: &Path, params: &PrepareParams) } pack_args.push("--filename"); - pack_args.push(pack_path.as_str()); + let pack_path_arg = pack_path.to_native_string(); + pack_args.push(pack_path_arg.as_str()); run_prepared_command( make_yarn_command(zpm_switch::ReleaseLine::Berry).await?, diff --git a/packages/zpm/src/project.rs b/packages/zpm/src/project.rs index b32b0044..85b628aa 100644 --- a/packages/zpm/src/project.rs +++ b/packages/zpm/src/project.rs @@ -1306,10 +1306,12 @@ impl Project { = detect_git_operation(&self.project_cwd) .await? .unwrap_or(GitOperation::Merge); + let lockfile_path_native + = lockfile_path.to_native_string(); ScriptEnvironment::new()? .with_cwd(self.project_cwd.clone()) - .run_exec("git", vec!["checkout", git_operation.true_theirs(), lockfile_path.as_str()]) + .run_exec("git", vec!["checkout", git_operation.true_theirs(), &lockfile_path_native]) .await? .ok() .map_err(|e| Error::LockfileAutofixGitError(e.to_string()))?; diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index 916c2fee..34a0998d 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -1,9 +1,9 @@ -use std::{collections::BTreeMap, ffi::OsStr, fs::Permissions, io::Read, os::unix::{fs::PermissionsExt, process::ExitStatusExt}, process::{ExitStatus, Output}, sync::{Arc, LazyLock}}; +use std::{collections::BTreeMap, ffi::OsStr, io::Read, process::{ExitStatus, Output}, sync::{Arc, LazyLock}}; use serde::{Deserialize, Serialize}; use zpm_parsers::JsonDocument; use zpm_primitives::Locator; -use zpm_utils::{FromFileString, Hash64, Path, ToFileString, shell_escape, to_shell_line}; +use zpm_utils::{FromFileString, Hash64, Path, ToFileString, exit_status_from_code, resolve_spawn_program, shell_escape, to_shell_line}; use itertools::Itertools; use regex::Regex; use tokio::process::Command; @@ -23,7 +23,7 @@ fn make_python_entry_point_snippet(binary_name: &str, package_path: &Path, modul let binary_name = serde_json::to_string(binary_name).expect("expected valid binary name"); let package_path - = serde_json::to_string(&package_path.to_file_string()).expect("expected valid package path"); + = serde_json::to_string(&package_path.to_native_string()).expect("expected valid package path"); let module = serde_json::to_string(module).expect("expected valid python module"); let object @@ -42,6 +42,16 @@ fn quote_path_if_needed(path: &str) -> String { } } +fn node_esm_loader_path(path: &Path) -> String { + if cfg!(windows) { + url::Url::from_file_path(path.to_path_buf()) + .expect("expected valid file URL") + .to_string() + } else { + path.to_native_string() + } +} + fn make_executable_wrapper(bin_dir: &Path, name: &str, argv0: &str, args: &[String]) -> Result<(), Error> { if cfg!(windows) { let escaped_args = args @@ -59,24 +69,24 @@ fn make_executable_wrapper(bin_dir: &Path, name: &str, argv0: &str, args: &[Stri bin_dir .with_join_str(format!("{}.cmd", name)) .fs_write_text(&cmd_script)?; - } else { - let escaped_args = args - .iter() - .map(|arg| format!("'{}'", arg.replace("'", "'\"'\"'"))) - .collect_vec() - .join(" "); + } - let sh_script = format!( - "#!/bin/sh\nexec \"{}\" {} \"$@\"\n", - argv0, - escaped_args, - ); + let escaped_args = args + .iter() + .map(|arg| format!("'{}'", arg.replace("'", "'\"'\"'"))) + .collect_vec() + .join(" "); - bin_dir - .with_join_str(name) - .fs_write_text(&sh_script)? - .fs_set_permissions(Permissions::from_mode(0o755))?; - } + let sh_script = format!( + "#!/bin/sh\nexec \"{}\" {} \"$@\"\n", + argv0, + escaped_args, + ); + + bin_dir + .with_join_str(name) + .fs_write_text(&sh_script)? + .fs_set_mode(0o755)?; Ok(()) } @@ -211,7 +221,7 @@ impl ScriptBinaries { pub fn with_standard(mut self) -> Result { let self_path = get_self_path()? - .to_file_string(); + .to_native_string(); self.binaries.push(ScriptBinary { name: "run".to_string(), @@ -251,12 +261,12 @@ impl ScriptBinaries { self.binaries.push(ScriptBinary { name: name.clone(), argv0: "node".to_string(), - args: vec![binary_path_abs.to_file_string()], + args: vec![binary_path_abs.to_native_string()], }); } else { self.binaries.push(ScriptBinary { name: name.clone(), - argv0: binary_path_abs.to_file_string(), + argv0: binary_path_abs.to_native_string(), args: vec![], }); } @@ -295,7 +305,7 @@ pub enum ScriptResult { impl ScriptResult { pub fn new_success() -> Self { Self::Success(Output { - status: ExitStatus::from_raw(0), + status: exit_status_from_code(0), stdout: Vec::new(), stderr: Vec::new(), }) @@ -440,7 +450,7 @@ impl ScriptEnvironment { let self_path = get_self_path()?; - value.env.insert("npm_execpath".to_string(), Some(self_path.to_file_string())); + value.env.insert("npm_execpath".to_string(), Some(self_path.to_native_string())); value.env.insert("npm_config_user_agent".to_string(), Some(format!("yarn/{}", zpm_switch::get_bin_version()))); Ok(value) @@ -532,18 +542,18 @@ impl ScriptEnvironment { } if let Some(pnp_path) = project.pnp_path().if_exists() { - self.append_env("NODE_OPTIONS", ' ', &format!("--require {}", pnp_path.to_file_string())); + self.append_env("NODE_OPTIONS", ' ', &format!("--require {}", pnp_path.to_native_string())); } if let Some(pnp_loader_path) = project.pnp_loader_path().if_exists() { - self.append_env("NODE_OPTIONS", ' ', &format!("--experimental-loader {}", pnp_loader_path.to_file_string())); + self.append_env("NODE_OPTIONS", ' ', &format!("--experimental-loader {}", node_esm_loader_path(&pnp_loader_path))); } self.refresh_package_map(project); - self.env.insert("PROJECT_CWD".to_string(), Some(project.project_cwd.to_file_string())); - self.env.insert("INIT_CWD".to_string(), Some(project.project_cwd.with_join(&project.shell_cwd).to_file_string())); - self.env.insert("CACHE_CWD".to_string(), Some(project.preferred_cache_path().to_file_string())); + self.env.insert("PROJECT_CWD".to_string(), Some(project.project_cwd.to_native_string())); + self.env.insert("INIT_CWD".to_string(), Some(project.project_cwd.with_join(&project.shell_cwd).to_native_string())); + self.env.insert("CACHE_CWD".to_string(), Some(project.preferred_cache_path().to_native_string())); self.trust_check_project_cwd = Some(project.project_cwd.clone()); self @@ -598,7 +608,7 @@ impl ScriptEnvironment { } if let Some(package_map_path) = project.package_map_path(package_map_workspace).if_exists() { - self.append_env("NODE_OPTIONS", ' ', &format!("--experimental-package-map={}", quote_path_if_needed(&package_map_path.to_file_string()))); + self.append_env("NODE_OPTIONS", ' ', &format!("--experimental-package-map={}", quote_path_if_needed(&package_map_path.to_native_string()))); } } @@ -665,7 +675,7 @@ impl ScriptEnvironment { self.env.insert("npm_package_name".to_string(), Some(locator.ident.to_file_string())); self.env.insert("npm_package_version".to_string(), Some(resolution.version.to_file_string())); - self.env.insert("npm_package_json".to_string(), Some(manifest_location_abs.to_file_string())); + self.env.insert("npm_package_json".to_string(), Some(manifest_location_abs.to_native_string())); Ok(()) } @@ -731,7 +741,20 @@ impl ScriptEnvironment { /// Prepares a command with the current environment settings. fn prepare_command(&mut self, program: &str, args: &[String]) -> Result<(Command, Path), Error> { - let mut cmd = Command::new(program); + let bin_dir = self.install_binaries()?; + + let env_path = self.env.get("PATH") + .cloned() + .unwrap_or_else(|| std::env::var("PATH").ok()) + .unwrap_or_default(); + + let path_separator = if cfg!(windows) {';'} else {':'}; + let next_env_path = match env_path.is_empty() { + true => bin_dir.to_native_string(), + false => format!("{}{}{}", bin_dir.to_native_string(), path_separator, env_path), + }; + + let mut cmd = Command::new(resolve_spawn_program(program, &next_env_path)); cmd.current_dir(self.cwd.to_path_buf()); @@ -746,20 +769,8 @@ impl ScriptEnvironment { }; } - let bin_dir = self.install_binaries()?; - - let env_path = self.env.get("PATH") - .cloned() - .unwrap_or_else(|| std::env::var("PATH").ok()) - .unwrap_or_default(); - - let next_env_path = match env_path.is_empty() { - true => bin_dir.to_file_string(), - false => format!("{}:{}", bin_dir.to_file_string(), env_path), - }; - cmd.env("PATH", next_env_path); - cmd.env("BERRY_BIN_FOLDER", bin_dir.to_file_string()); + cmd.env("BERRY_BIN_FOLDER", bin_dir.to_native_string()); cmd.args(args); if self.stdin.is_some() { @@ -863,14 +874,14 @@ impl ScriptEnvironment { Binary::Path {path, kind: BinaryKind::Node} => { let mut node_args = self.node_args.clone(); - node_args.push(path.to_file_string()); + node_args.push(path.to_native_string()); node_args.extend(args.into_iter().map(|arg| arg.as_ref().to_string())); self.run_exec("node", node_args).await }, Binary::Path {path, kind: BinaryKind::Default} => { - self.run_exec(&path.to_file_string(), args).await + self.run_exec(&path.to_native_string(), args).await }, Binary::PythonEntryPoint {name, package_path, module, object} => { diff --git a/packages/zpm/src/trust.rs b/packages/zpm/src/trust.rs index 4af994fe..fb634429 100644 --- a/packages/zpm/src/trust.rs +++ b/packages/zpm/src/trust.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeMap, sync::LazyLock}; use tokio::{process::Command, sync::Mutex}; -use zpm_utils::{DataType, FromFileString, Path, ToFileString}; +use zpm_utils::{DataType, FromFileString, Path}; use crate::{ error::Error, @@ -64,8 +64,8 @@ fn get_switch_path() -> Option { async fn check_project_trust(switch_path: &Path, project_cwd: &Path) -> Result, Error> { let status - = Command::new(switch_path.to_file_string()) - .args(["switch", "trust", "--check", project_cwd.as_str()]) + = Command::new(switch_path.to_path_buf()) + .args(["switch", "trust", "--check", &project_cwd.to_native_string()]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -81,8 +81,8 @@ async fn check_project_trust(switch_path: &Path, project_cwd: &Path) -> Result Result<(), Error> { let status - = Command::new(switch_path.to_file_string()) - .args(["switch", "trust", "--set", "true", project_cwd.as_str()]) + = Command::new(switch_path.to_path_buf()) + .args(["switch", "trust", "--set", "true", &project_cwd.to_native_string()]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() diff --git a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts index e9e4d7d2..7aa8571e 100644 --- a/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts +++ b/tests/acceptance-tests/pkg-tests-core/sources/utils/tests.ts @@ -178,6 +178,7 @@ export enum RequestType { BulkAdvisories = `bulkAdvisories`, NodeDistIndex = `nodeDistIndex`, NodeDistTarball = `nodeDistTarball`, + NodeDistZip = `nodeDistZip`, OtelTraces = `otelTraces`, YarnSwitchInfo = `yarnSwitchInfo`, YarnSwitchTarball = `yarnSwitchTarball`, @@ -230,6 +231,9 @@ export type Request = { } | { type: RequestType.NodeDistTarball; name: string; +} | { + type: RequestType.NodeDistZip; + name: string; } | { type: RequestType.OtelTraces; body?: unknown; @@ -1071,6 +1075,20 @@ export const startPackageServer = ({type}: {type: keyof typeof packageServerUrls stream.pipeline(tar, gzip, response, () => {}); }, + async [RequestType.NodeDistZip](parsedRequest, request, response) { + if (parsedRequest.type !== RequestType.NodeDistZip) + throw new Error(`Assertion failed: Invalid request type`); + + const zip = buildZipFromEntries([{ + name: `${parsedRequest.name}/node.exe` as PortablePath, + mode: 0o755, + data: Buffer.from(`#!/usr/bin/env bash\necho "${parsedRequest.name}"\n`), + }]); + + response.writeHead(200, {[`Content-Type`]: `application/octet-stream`}); + response.end(zip); + }, + async [RequestType.OtelTraces](parsedRequest, request, response) { if (parsedRequest.type !== RequestType.OtelTraces) throw new Error(`Assertion failed: Invalid request type`); @@ -1194,6 +1212,11 @@ exit 0 type: RequestType.NodeDistTarball, name: match[2]!, }; + } else if ((match = url.match(/^\/node\/dist\/v([0-9]+\.[0-9]+\.[0-9]+)\/(node-v(\1)-[a-z0-9-]+)\.zip$/))) { + return { + type: RequestType.NodeDistZip, + name: match[2]!, + }; } else if (url === `/v1/traces`) { return { type: RequestType.OtelTraces, diff --git a/tests/acceptance-tests/pkg-tests-fixtures/packages/@hapi__joi-17.99.0/package.json b/tests/acceptance-tests/pkg-tests-fixtures/packages/@hapi__joi-17.99.0/package.json new file mode 100644 index 00000000..255da8f7 --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-fixtures/packages/@hapi__joi-17.99.0/package.json @@ -0,0 +1,4 @@ +{ + "name": "@hapi/joi", + "version": "17.99.0" +} diff --git a/tests/acceptance-tests/pkg-tests-fixtures/packages/@types__hapi_joi-17.99.0/package.json b/tests/acceptance-tests/pkg-tests-fixtures/packages/@types__hapi_joi-17.99.0/package.json new file mode 100644 index 00000000..bf8c747e --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-fixtures/packages/@types__hapi_joi-17.99.0/package.json @@ -0,0 +1,4 @@ +{ + "name": "@types/hapi__joi", + "version": "17.99.0" +} diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts index 6a951f89..1b7b2dfb 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/tasks/run.test.ts @@ -2270,6 +2270,7 @@ describe(`Commands`, () => { // When a subtask fails, the parent should fail with the subtask's exit code await xfs.writeFilePromise(ppath.join(path, `taskfile`), [ `failing-subtask:`, + ` sleep 1`, ` exit 55`, ``, `parent:`, diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts index fe5c488f..d9e71662 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/features/lazyInstalls.test.ts @@ -266,6 +266,10 @@ describe(`Features`, () => { test( `it should treat transitive workspace dependencies as covered by focused installs`, makeTemporaryEnv({}, async ({path, run, source}) => { + await yarn.writeConfiguration(path, { + lazyInstallMode: `focused`, + }); + await xfs.writeJsonPromise(ppath.join(path, Filename.manifest), { private: true, workspaces: [`packages/*`], @@ -307,6 +311,10 @@ describe(`Features`, () => { test( `it should include optional workspace dependencies in focused install coverage`, makeTemporaryEnv({}, async ({path, run, source}) => { + await yarn.writeConfiguration(path, { + lazyInstallMode: `focused`, + }); + await xfs.writeJsonPromise(ppath.join(path, Filename.manifest), { private: true, workspaces: [`packages/*`], diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts index 0c40d341..add30550 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/features/nodejsVersioning.test.ts @@ -175,7 +175,7 @@ describe(`Features`, () => { }, async ({path, run, source}) => { await xfs.writeJsonPromise(ppath.join(path, Filename.rc), { supportedArchitectures: { - os: [`linux`, `darwin`], + os: [`linux`, `darwin`, `win32`], cpu: [`x64`], }, }); @@ -193,6 +193,30 @@ describe(`Features`, () => { expect(nodeFiles).toEqual([ expect.stringMatching(/@yarnpkg-node-darwin-x64-builtin-22\.0\.0-/), expect.stringMatching(/@yarnpkg-node-linux-x64-builtin-22\.0\.0-/), + expect.stringMatching(/@yarnpkg-node-win-x64-builtin-22\.0\.0-/), + ]); + }), + ); + + test( + `it should fetch the Windows @yarnpkg/node package on win32`, + makeTemporaryEnv({ + dependencies: { + [`@yarnpkg/node`]: `builtin:^22.0.0`, + }, + }, async ({path, run, source}) => { + await run(`install`, { + env: { + YARN_CPU_OVERRIDE: `x64`, + YARN_OS_OVERRIDE: `win32`, + }, + }); + + const allCachedFiles = await xfs.readdirPromise(ppath.join(path, `.yarn/cache`)); + const nodeFiles = allCachedFiles.sort().filter(file => file.startsWith(`@yarnpkg-node-`)); + + expect(nodeFiles).toEqual([ + expect.stringMatching(/@yarnpkg-node-win-x64-builtin-22\.0\.0-/), ]); }), ); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/plugins/plugin-typescript.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/plugins/plugin-typescript.test.ts index ac27bc49..779683b5 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/plugins/plugin-typescript.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/plugins/plugin-typescript.test.ts @@ -167,14 +167,14 @@ describe(`Plugins`, () => { makeTemporaryEnv({}, { tsEnableAutoTypes: true, }, async ({path, run, source}) => { - await run(`add`, `@babel/traverse@7.99.0`); + await run(`add`, `@hapi/joi@17.99.0`); await expect(readManifest(path)).resolves.toMatchObject({ dependencies: { - [`@babel/traverse`]: `7.99.0`, + [`@hapi/joi`]: `17.99.0`, }, devDependencies: { - [`@types/babel__traverse`]: `^7`, + [`@types/hapi__joi`]: `^17`, }, }); }), diff --git a/website/src/docs/concepts/intermediary/nodejs-management.md b/website/src/docs/concepts/intermediary/nodejs-management.md index df8547c7..9c9914f3 100644 --- a/website/src/docs/concepts/intermediary/nodejs-management.md +++ b/website/src/docs/concepts/intermediary/nodejs-management.md @@ -81,6 +81,7 @@ The `@yarnpkg/node` package automatically downloads the correct binary for your - Linux (x64, arm64) - macOS (x64, arm64) +- Windows (x64, arm64) When working in a team with mixed platforms, Yarn will store metadata about all required platform variants in the lockfile, but each developer will only downloads the binary they need for their platform (configurable through `supportedArchitectures`).