From 12eb25bb18fdfe14a535f557708c36d33fa6bb34 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 17:27:53 -0800 Subject: [PATCH 01/10] Add ABI-compatible Rust native implementations and side-by-side CI Port the Unix native APIs and Windows launcher/hosting code to Rust while preserving the original C ABI and leaving the C++ WSMan plugin unchanged. Document .NET 11 migration paths and package separate GNU and host-linked musl libraries for Linux x64 and arm64. Build C++ and Rust side-by-side in GitHub Actions, enforce pedantic Clippy, and add Pester v5 packaged-binary tests with NUnit reports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust-native.yml | 251 ++++++++++++ .gitignore | 1 + README.md | 5 + rust/.cargo/config.toml | 11 + rust/.gitignore | 2 + rust/Cargo.lock | 186 +++++++++ rust/Cargo.toml | 17 + rust/README.md | 181 +++++++++ rust/build-linux.py | 50 +++ rust/libpsl-native/Cargo.toml | 16 + rust/libpsl-native/src/accounts.rs | 119 ++++++ rust/libpsl-native/src/filesystem.rs | 294 ++++++++++++++ rust/libpsl-native/src/lib.rs | 68 ++++ rust/libpsl-native/src/logging.rs | 136 +++++++ rust/libpsl-native/src/process.rs | 282 +++++++++++++ rust/libpsl-native/src/time.rs | 56 +++ rust/libpsl-native/tests/abi.cpp | 164 ++++++++ rust/libpsl-native/tests/abi.ps1 | 50 +++ rust/libpsl-native/tests/native.rs | 440 ++++++++++++++++++++ rust/tests/Invoke-Tests.ps1 | 43 ++ rust/tests/NativeLibrary.Tests.ps1 | 169 ++++++++ rust/tests/NativeMethods.cs | 100 +++++ rust/tests/WindowsLauncher.Tests.ps1 | 102 +++++ rust/windows/Cargo.toml | 34 ++ rust/windows/README.md | 113 ++++++ rust/windows/build.rs | 117 ++++++ rust/windows/src/common.rs | 344 ++++++++++++++++ rust/windows/src/hosting.rs | 553 ++++++++++++++++++++++++++ rust/windows/src/launcher.rs | 291 ++++++++++++++ rust/windows/src/lib.rs | 14 + rust/windows/src/main.rs | 15 + rust/windows/src/policy.rs | 228 +++++++++++ rust/windows/src/shell.rs | 169 ++++++++ rust/windows/src/win.rs | 314 +++++++++++++++ rust/windows/tests/launcher-smoke.ps1 | 64 +++ 35 files changed, 4999 insertions(+) create mode 100644 .github/workflows/rust-native.yml create mode 100644 rust/.cargo/config.toml create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/build-linux.py create mode 100644 rust/libpsl-native/Cargo.toml create mode 100644 rust/libpsl-native/src/accounts.rs create mode 100644 rust/libpsl-native/src/filesystem.rs create mode 100644 rust/libpsl-native/src/lib.rs create mode 100644 rust/libpsl-native/src/logging.rs create mode 100644 rust/libpsl-native/src/process.rs create mode 100644 rust/libpsl-native/src/time.rs create mode 100644 rust/libpsl-native/tests/abi.cpp create mode 100644 rust/libpsl-native/tests/abi.ps1 create mode 100644 rust/libpsl-native/tests/native.rs create mode 100644 rust/tests/Invoke-Tests.ps1 create mode 100644 rust/tests/NativeLibrary.Tests.ps1 create mode 100644 rust/tests/NativeMethods.cs create mode 100644 rust/tests/WindowsLauncher.Tests.ps1 create mode 100644 rust/windows/Cargo.toml create mode 100644 rust/windows/README.md create mode 100644 rust/windows/build.rs create mode 100644 rust/windows/src/common.rs create mode 100644 rust/windows/src/hosting.rs create mode 100644 rust/windows/src/launcher.rs create mode 100644 rust/windows/src/lib.rs create mode 100644 rust/windows/src/main.rs create mode 100644 rust/windows/src/policy.rs create mode 100644 rust/windows/src/shell.rs create mode 100644 rust/windows/src/win.rs create mode 100644 rust/windows/tests/launcher-smoke.ps1 diff --git a/.github/workflows/rust-native.yml b/.github/workflows/rust-native.yml new file mode 100644 index 0000000..bef9913 --- /dev/null +++ b/.github/workflows/rust-native.yml @@ -0,0 +1,251 @@ +name: C++ and Rust native + +on: + push: + branches: [master] + paths: ["rust/**", "src/**", ".gitmodules", ".github/workflows/rust-native.yml"] + pull_request: + paths: ["rust/**", "src/**", ".gitmodules", ".github/workflows/rust-native.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + linux: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x64 + triple: x86_64 + - runner: ubuntu-24.04-arm + arch: arm64 + triple: aarch64 + runs-on: ${{ matrix.runner }} + defaults: + run: + working-directory: rust + shell: bash + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + submodules: true + - name: Install build tools + run: | + rustup update stable --no-self-update + rustup default stable + rustup component add clippy rustfmt + rustup target add ${{ matrix.triple }}-unknown-linux-gnu ${{ matrix.triple }}-unknown-linux-musl + cargo install cargo-zigbuild --version 0.24.1 --locked + python3 -m venv "$RUNNER_TEMP/zig" + "$RUNNER_TEMP/zig/bin/pip" install ziglang==0.15.2 + echo "$RUNNER_TEMP/zig/bin" >> "$GITHUB_PATH" + "$RUNNER_TEMP/zig/bin/python" -c 'import pathlib, ziglang; print(pathlib.Path(ziglang.__file__).parent)' >> "$GITHUB_PATH" + - name: Build existing C++ library + run: | + cmake -S ../src/libpsl-native -B "$RUNNER_TEMP/cpp" -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build "$RUNNER_TEMP/cpp" --target psl-native --parallel 2 + mkdir -p ../artifacts/cpp/linux-${{ matrix.arch }} + cp ../src/powershell-unix/libpsl-native.so ../artifacts/cpp/linux-${{ matrix.arch }}/ + - name: Format, pedantic Clippy, and test + run: | + cargo fmt --all -- --check + for libc in gnu musl; do + cargo clippy --locked --workspace --all-targets --target "${{ matrix.triple }}-unknown-linux-$libc" -- -D warnings -D clippy::pedantic + done + cargo test --locked --workspace + - name: Build both host-libc variants + run: | + python3 build-linux.py --output ../artifacts/rust --target linux-${{ matrix.arch }} --target linux-musl-${{ matrix.arch }} + - name: Verify glibc ABI and ELF linkage + run: | + c++ -std=c++11 -I../src/libpsl-native/src libpsl-native/tests/abi.cpp -ldl -o target/psl-abi + target/psl-abi ../artifacts/rust/linux-${{ matrix.arch }}/libpsl-native.so + readelf -d ../artifacts/rust/linux-${{ matrix.arch }}/libpsl-native.so | grep -F 'Shared library: [libc.so.6]' + readelf -d ../artifacts/rust/linux-musl-${{ matrix.arch }}/libpsl-native.so | grep -F 'Shared library: [libc.so]' + for library in ../artifacts/rust/*/*.so; do + test "$(nm -D --defined-only "$library" | wc -l)" -eq 30 + readelf -d "$library" | grep -F 'Library soname: [libpsl-native.so]' + done + - name: Test in a real musl process + run: | + docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src/rust alpine:3.22 sh -ec ' + apk add --no-cache g++ cmake cargo rust + CARGO_TARGET_DIR=target/alpine cargo test --locked -p psl-native + cmake -S ../src/libpsl-native -B target/cpp-musl -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build target/cpp-musl --target psl-native --parallel 2 + mkdir -p ../artifacts/cpp/linux-musl-${{ matrix.arch }} + cp ../src/powershell-unix/libpsl-native.so ../artifacts/cpp/linux-musl-${{ matrix.arch }}/ + g++ -std=c++11 -I../src/libpsl-native/src libpsl-native/tests/abi.cpp -ldl -o target/alpine/psl-abi + target/alpine/psl-abi ../artifacts/rust/linux-musl-${{ matrix.arch }}/libpsl-native.so + ' + - name: Ensure PowerShell is available + run: | + if ! command -v pwsh >/dev/null; then + mkdir -p "$RUNNER_TEMP/pwsh" + curl --fail --location --retry 3 "https://github.com/PowerShell/PowerShell/releases/download/v7.7.0/powershell-7.7.0-linux-${{ matrix.arch }}.tar.gz" -o "$RUNNER_TEMP/pwsh.tar.gz" + tar -xzf "$RUNNER_TEMP/pwsh.tar.gz" -C "$RUNNER_TEMP/pwsh" + chmod +x "$RUNNER_TEMP/pwsh/pwsh" + echo "$RUNNER_TEMP/pwsh" >> "$GITHUB_PATH" + fi + - name: Run Pester v5 on packaged Rust library + shell: pwsh + run: | + if (-not (Get-Module -ListAvailable Pester | Where-Object Version -EQ '5.5.0')) { + Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -Force + } + ./tests/Invoke-Tests.ps1 -LibraryPath ../artifacts/rust/linux-${{ matrix.arch }}/libpsl-native.so -ResultPath ../artifacts/test-results/linux-${{ matrix.arch }}.xml + - name: Run Pester v5 in a real musl process (x64 host only) + if: matrix.arch == 'x64' + run: | + mkdir -p "$RUNNER_TEMP/pwsh-musl" + curl --fail --location --retry 3 https://github.com/PowerShell/PowerShell/releases/download/v7.7.0/powershell-7.7.0-linux-musl-x64.tar.gz -o "$RUNNER_TEMP/pwsh-musl.tar.gz" + tar -xzf "$RUNNER_TEMP/pwsh-musl.tar.gz" -C "$RUNNER_TEMP/pwsh-musl" + chmod +x "$RUNNER_TEMP/pwsh-musl/pwsh" + docker run --rm -v "$GITHUB_WORKSPACE:/src" \ + -v "$RUNNER_TEMP/pwsh-musl:/opt/pwsh:ro" \ + -v "$HOME/.local/share/powershell/Modules:/root/.local/share/powershell/Modules:ro" \ + -w /src/rust alpine:3.22 sh -ec ' + apk add --no-cache ca-certificates icu-libs krb5-libs libatomic libgcc libintl libssl3 libstdc++ ncurses-terminfo-base tzdata userspace-rcu zlib + /opt/pwsh/pwsh -NoLogo -NoProfile -File ./tests/Invoke-Tests.ps1 \ + -LibraryPath ../artifacts/rust/linux-musl-x64/libpsl-native.so \ + -ResultPath ../artifacts/test-results/linux-musl-x64.xml + ' + - name: Publish side-by-side Linux binaries and Pester results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: native-linux-${{ matrix.arch }} + path: artifacts/ + + windows: + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + arch: [x64, x86] + configuration: [coreclr, framework] + include: + - arch: x64 + target: x86_64-pc-windows-msvc + - arch: x86 + target: i686-pc-windows-msvc + - configuration: coreclr + features: "" + - configuration: framework + features: "--no-default-features" + defaults: + run: + working-directory: rust + shell: pwsh + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Configure MSVC and Windows SDK + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $installation = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $installation) { throw 'MSVC installation not found.' } + $variables = & cmd.exe /s /c "`"$installation\Common7\Tools\VsDevCmd.bat`" -arch=${{ matrix.arch }} -host_arch=x64 >nul && set" + if ($LASTEXITCODE) { throw 'Visual Studio environment initialization failed.' } + foreach ($line in $variables) { + if ($line -match '^(INCLUDE|LIB|LIBPATH|PATH|WindowsSdkDir|WindowsSDKVersion|VCToolsInstallDir|VCINSTALLDIR|VSINSTALLDIR)=') { + $line | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } + } + - name: Install Rust + run: | + $PSNativeCommandUseErrorActionPreference = $true + rustup update stable --no-self-update + rustup default stable + rustup component add clippy rustfmt + rustup target add ${{ matrix.target }} + - name: Build, lint, and test launcher + run: | + $PSNativeCommandUseErrorActionPreference = $true + cargo fmt --all -- --check + cargo clippy --locked --workspace --all-targets --target ${{ matrix.target }} ${{ matrix.features }} -- -D warnings -D clippy::pedantic + if ($LASTEXITCODE) { throw 'Rust lint failed.' } + cargo test --locked -p powershell-native-windows --target ${{ matrix.target }} ${{ matrix.features }} + if ($LASTEXITCODE) { throw 'Rust tests failed.' } + cargo build --locked --release -p powershell-native-windows --target ${{ matrix.target }} ${{ matrix.features }} + if ($LASTEXITCODE) { throw 'Rust build failed.' } + $destination = "../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}" + New-Item -ItemType Directory -Force $destination > $null + Copy-Item ./target/${{ matrix.target }}/release/powershell.exe $destination + - name: Build existing C++ CoreCLR binaries + run: | + $PSNativeCommandUseErrorActionPreference = $true + $plugin = (Resolve-Path ../src/powershell-native/nativemsh/pwrshplugin).Path + mc.exe -o -d -c -U "$plugin/pwrshpluginerrorcodes.mc" -h $plugin -r $plugin + $platform = if ('${{ matrix.arch }}' -eq 'x86') { 'Win32' } else { 'x64' } + cmake -S ../src/powershell-native -B "$env:RUNNER_TEMP/cpp" -G 'Visual Studio 17 2022' -A $platform -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DBUILD_ONECORE=ON -DBUILD_TARGET_ARCH=${{ matrix.arch }} + cmake --build "$env:RUNNER_TEMP/cpp" --config Release --parallel 2 + $destination = "../artifacts/cpp/win-${{ matrix.arch }}-coreclr" + New-Item -ItemType Directory -Force $destination > $null + Copy-Item ../src/powershell-native/Bin/RELEASE/CoreClr/powershell.exe $destination + Copy-Item ../src/powershell-native/Bin/RELEASE/CoreClr/pwrshplugin.dll $destination + - name: Run Pester v5 on packaged Rust launcher + run: | + if (-not (Get-Module -ListAvailable Pester | Where-Object Version -EQ '5.5.0')) { + Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -Force + } + ./tests/Invoke-Tests.ps1 -ExecutablePath ../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}/powershell.exe -Configuration ${{ matrix.configuration }} -Architecture ${{ matrix.arch }} -ResultPath ../artifacts/test-results/win-${{ matrix.arch }}-${{ matrix.configuration }}.xml + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: native-win-${{ matrix.arch }}-${{ matrix.configuration }} + path: artifacts/ + + macos: + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + arch: arm64 + - runner: macos-15-intel + arch: x64 + runs-on: ${{ matrix.runner }} + defaults: + run: + working-directory: rust + shell: bash + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + submodules: true + - name: Install Rust + run: | + rustup update stable --no-self-update + rustup default stable + rustup component add clippy rustfmt + - name: Build existing C++ library + run: | + cmake -S ../src/libpsl-native -B "$RUNNER_TEMP/cpp" -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build "$RUNNER_TEMP/cpp" --target psl-native --parallel 2 + mkdir -p ../artifacts/cpp/osx-${{ matrix.arch }} + cp ../src/powershell-unix/libpsl-native.dylib ../artifacts/cpp/osx-${{ matrix.arch }}/ + - name: Build and verify Rust + run: | + cargo fmt --all -- --check + cargo clippy --locked --workspace --all-targets -- -D warnings -D clippy::pedantic + cargo test --locked --workspace + cargo build --locked --release -p psl-native + c++ -std=c++11 -I../src/libpsl-native/src libpsl-native/tests/abi.cpp -o target/psl-abi + mkdir -p ../artifacts/rust/osx-${{ matrix.arch }} + cp target/release/libpsl_native.dylib ../artifacts/rust/osx-${{ matrix.arch }}/libpsl-native.dylib + target/psl-abi ../artifacts/rust/osx-${{ matrix.arch }}/libpsl-native.dylib + - name: Run Pester v5 on packaged Rust library + shell: pwsh + run: | + if (-not (Get-Module -ListAvailable Pester | Where-Object Version -EQ '5.5.0')) { + Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -Force + } + ./tests/Invoke-Tests.ps1 -LibraryPath ../artifacts/rust/osx-${{ matrix.arch }}/libpsl-native.dylib -ResultPath ../artifacts/test-results/osx-${{ matrix.arch }}.xml + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: always() + with: + name: native-osx-${{ matrix.arch }} + path: artifacts/ diff --git a/.gitignore b/.gitignore index e06cb4c..dde8a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dotnet-install.sh dotnet-uninstall-pkgs.sh src/powershell-unix +/artifacts/ diff --git a/README.md b/README.md index 9121018..298921a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,11 @@ Interface to C code (and C++ by way of `extern C`). ## Build +An additive [Rust implementation](rust/README.md) provides the same native ABI, +with .NET migration notes and separate x64/arm64 glibc and musl Linux artifacts. +It also includes a Rust Windows launcher; the WSMan plugin remains unchanged. +The CMake build below continues to build the original C++ implementation. + [CMake][] is used to build the project, which results in a `libpsl-native.so` library on Linux, and `libpsl-native.dylib` on OS X. diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml new file mode 100644 index 0000000..51b294c --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.x86_64-unknown-linux-musl] +rustflags = ["-C", "target-feature=-crt-static", "-C", "link-arg=-Wl,-z,relro,-z,now", "-C", "link-arg=-Wl,-soname,libpsl-native.so"] + +[target.aarch64-unknown-linux-musl] +rustflags = ["-C", "target-feature=-crt-static", "-C", "link-arg=-Wl,-z,relro,-z,now", "-C", "link-arg=-Wl,-soname,libpsl-native.so"] + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-Wl,-z,relro,-z,now", "-C", "link-arg=-Wl,-soname,libpsl-native.so"] + +[target.aarch64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-Wl,-z,relro,-z,now", "-C", "link-arg=-Wl,-soname,libpsl-native.so"] diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..b608df4 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,2 @@ +/target/ +/artifacts/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..79198b8 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,186 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "powershell-native-windows" +version = "0.1.0" +dependencies = [ + "windows", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-native" +version = "0.1.0" +dependencies = [ + "libc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result", + "windows-strings", + "windows-targets", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..395ee54 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,17 @@ +[workspace] +members = ["libpsl-native", "windows"] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT" +rust-version = "1.85" + +[workspace.lints.clippy] +pedantic = { level = "deny", priority = -1 } + +[profile.release] +panic = "abort" +lto = true +codegen-units = 1 +strip = "debuginfo" diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..c9c3ff1 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,181 @@ +# Rust native implementations + +The projects in this directory are additive replacements for the native sources. +The existing C++ build and packaging are unchanged. + +`windows` provides the Windows launcher and shared CoreCLR/.NET Framework +hosting code; see its [build and deployment notes](windows/README.md). +The **WSMan plugin remains in its original C++ implementation**, as requested. +This workspace does not build or replace `pwrshplugin.dll`. + +## libpsl-native + +`libpsl-native` implements all 30 declared C entry points in +`src/libpsl-native/src`. Function names, C calling conventions, one-byte native +booleans, `CommonStat` field widths/layout, and packed `private_tm` layout are +preserved. Existing C headers remain the ABI specification. + +Functions with public .NET replacements have migration comments at their +implementations, including .NET 11's `File.CreateHardLink`. Partial replacements +are explicitly distinguished from equivalent behavior. + +| Original C++ implementation | Rust module | +| --- | --- | +| `createhardlink`, `createsymlink`, `followsymlink`, `getcommonstat`, `getcommonlstat`, `getstat`, `getlstat`, `getinodedata`, `getlinkcount`, `getfileowner`, `getcomputername`, `isfile`, `isdirectory`, `issymlink`, `issamefilesystemitem`, `isexecutable` | `libpsl-native/src/filesystem.rs` | +| `getpwuid`, `getgrgid`, `getuserfrompid` | `libpsl-native/src/accounts.rs` | +| `createprocess`, `getcurrentprocessorid` (exports **GetCurrentProcessId**), `getcurrentthreadid`, `getppid`, `killprocess`, `waitpid` | `libpsl-native/src/process.rs` | +| `nativesyslog` | `libpsl-native/src/logging.rs` | +| `setdate` | `libpsl-native/src/time.rs` | +| `geterrorcategory` | `libpsl-native/src/lib.rs` | + +Returned strings use the **host libc allocator**, not Rust-owned `CString` +allocations. They can still be freed by the existing .NET LPStr marshaller. +Failures set the host's thread-local errno for `SetLastError = true` P/Invokes. +Use UTF-8 string marshalling, as in the original Unix interface. Raw `GetStat` +and `GetLStat` expose the target's native `struct stat`; managed callers should +continue using the normalized variants instead. + +The port fixes unsafe failure paths rather than reproducing memory corruption: +failed link-count queries do not read uninitialized memory; symlink buffers grow +instead of overflowing; null arguments return `EINVAL`; account lookup retries +use the `_r` function's returned error and release temporary buffers. +`SetDate` retains local-time/DST conversion but does not mutate the host's +process-wide locale. The Linux `GetPPid` unknown-result behavior is preserved. + +macOS logging uses the Apple SDK's `_os_log_impl` backend and packed public-string +argument format to preserve unified logging, subsystem/category, severity, and +`%{public}s` visibility without compiling a C/C++ shim. + +### Local builds + +Run Cargo **from this directory** so `.cargo/config.toml` is loaded: + +```sh +cd rust +cargo test -p psl-native +cargo clippy --workspace --all-targets -- -D warnings -D clippy::pedantic +cargo build --release -p psl-native +``` + +Cargo names the library `libpsl_native.dylib` on macOS and `libpsl_native.so` +on Linux. Install/package it as **`libpsl-native.dylib`** or +**`libpsl-native.so`** respectively to retain the existing P/Invoke name. +macOS builds need the Apple command-line developer tools. + +### Linux x64 and arm64 + +Per the compatibility decision, **musl is host-linked, not embedded statically**. +Embedding a second libc would invalidate the unchanged allocator/errno contract. +Rust dependencies are linked into each shared library. Choose the artifact +matching the architecture **and libc of the .NET process**; a musl library is +not a drop-in replacement in a glibc process. + +The cross-build driver uses Rust target standard libraries, Zig 0.15.2, and +`cargo-zigbuild` 0.24.1. Install missing prerequisites: + +```sh +rustup target add \ + x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu \ + x86_64-unknown-linux-musl aarch64-unknown-linux-musl +cargo install cargo-zigbuild --version 0.24.1 --locked +python3 -m pip install ziglang==0.15.2 +python3 build-linux.py +``` + +`cargo-zigbuild` can discover a Python-installed Zig; alternatively place the +`zig` executable on `PATH`. `python3 build-linux.py --target linux-musl-arm64` +builds just that variant. Do not override the musl `-crt-static` target setting. +Use `--output ../artifacts/rust` to package under a different output root. + +| Artifact directory | Architecture | Host runtime | +| --- | --- | --- | +| `artifacts/linux-x64/libpsl-native.so` | x86-64 | glibc 2.28+ | +| `artifacts/linux-arm64/libpsl-native.so` | AArch64 | glibc 2.28+ | +| `artifacts/linux-musl-x64/libpsl-native.so` | x86-64 | musl | +| `artifacts/linux-musl-arm64/libpsl-native.so` | AArch64 | musl | + +Account-name resolution follows the host libc: glibc NSS providers are not +available in musl builds. Loading musl in a glibc .NET process does not solve +that limitation and is unsupported. + +### ABI smoke test + +The independent C++ smoke test uses the **original headers**, loads the library +with `dlopen`, resolves every declared entry point, verifies metadata against +native `stat`, checks errno, and frees returned strings from the caller. +It is a test client, not part of the Rust library: + +```sh +c++ -std=c++11 -I../src/libpsl-native/src \ + libpsl-native/tests/abi.cpp -o target/psl-abi +target/psl-abi target/release/libpsl_native.dylib +``` + +On Linux append `-ldl` to the compile command and pass the `.so` path. +Run on each Linux architecture/libc to verify runtime behavior, not just +cross-compilation. Do not exercise `SetDate` with real input on a shared host: +it changes the system clock when privileged. + +With PowerShell installed, the managed smoke test additionally checks unchanged +LPStr return ownership, one-byte bools, and `SetLastError` marshalling: + +```powershell +./libpsl-native/tests/abi.ps1 -LibraryPath ./target/release/libpsl_native.dylib +``` + +## Side-by-side CI and Pester v5 + +`.github/workflows/rust-native.yml` builds the existing C++ code alongside Rust +without changing the existing Azure Pipelines or C++ sources. Each uploaded +artifact preserves separate repository-root output directories: + +| Directory | Contents | +| --- | --- | +| `artifacts/cpp//` | Existing C++ binaries, including the unchanged Windows WSMan plugin | +| `artifacts/rust//` | Rust Unix library or Windows launcher; no Rust WSMan plugin | +| `artifacts/test-results/` | Pester NUnit XML results, uploaded even when a test fails | + +Linux jobs build x64/arm64 for glibc and musl, macOS jobs build Intel/Apple +Silicon, and Windows jobs build x86/x64 launchers in both CoreCLR and Framework +configurations. The Windows C++ baseline remains its existing CoreCLR build, +packaged as `win--coreclr`; the Rust launcher is packaged as +`win--`. + +Both crates inherit a workspace-wide deny-pedantic Clippy policy. CI checks all +targets, including tests and build scripts, with warnings treated as errors; +Windows checks both feature configurations and Linux checks both libc targets. +Narrow, documented lint exceptions retain deliberate C ABI bit reinterpretations +and narrowing conversions. Formatting and native Rust tests are also enforced. + +CI installs Pester 5.5.0 and exercises the **packaged Rust binaries**, not only +intermediate Cargo outputs. Unix tests check all 30 exported names, managed +layouts, UTF-8/returned-string ownership, native booleans, errno, filesystem +behavior, identities, safe failure paths, and logging. Windows tests check PE +architecture/resources, native help, missing-runtime errors, and Framework +startup/managed exit-code propagation. The runner fails on failed, empty, or +skipped test runs. + +Pester runs on both glibc Linux architectures, both macOS architectures, every +Windows configuration, and x64 Alpine/musl using a musl PowerShell host. +PowerShell 7.7.0 has no published standalone musl-arm64 host, so that variant +instead receives native Rust tests and the original-header C++ ABI test inside +arm64 Alpine. Both musl libraries are exercised in real musl processes; they +must not be loaded into a glibc PowerShell process. Cross-compilation alone is +not a substitute for these runtime checks. + +Run Unix tests in a fresh PowerShell process matching the library's architecture +and libc: + +```powershell +Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery +./tests/Invoke-Tests.ps1 -LibraryPath ./target/release/libpsl_native.dylib -ResultPath ./test-results/native.xml +``` + +For Windows: + +```powershell +./tests/Invoke-Tests.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr -Architecture x64 -ResultPath ./test-results/windows.xml +``` + +Use `-Configuration framework` for a launcher built with `--no-default-features` +and `-Architecture x86` for an `i686-pc-windows-msvc` build. diff --git a/rust/build-linux.py b/rust/build-linux.py new file mode 100644 index 0000000..091aab7 --- /dev/null +++ b/rust/build-linux.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Build the four libc/architecture-specific shared libraries, not static archives.""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess + + +ROOT = Path(__file__).resolve().parent +TARGETS = { + "linux-x64": "x86_64-unknown-linux-gnu.2.28", + "linux-arm64": "aarch64-unknown-linux-gnu.2.28", + "linux-musl-x64": "x86_64-unknown-linux-musl", + "linux-musl-arm64": "aarch64-unknown-linux-musl", +} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", choices=TARGETS, action="append") + parser.add_argument("--output", type=Path, default=ROOT / "artifacts") + args = parser.parse_args() + for rid in args.target or TARGETS: + target = TARGETS[rid] + subprocess.run( + [ + "cargo", "zigbuild", "--locked", "--release", + "--package", "psl-native", "--target", target, + ], + cwd=ROOT, + check=True, + ) + base_target = target.split(".")[0] + target_dir = Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target")) + if not target_dir.is_absolute(): + target_dir = ROOT / target_dir + source = target_dir / base_target / "release" / "libpsl_native.so" + destination = args.output.resolve() / rid / "libpsl-native.so" + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + print(destination, flush=True) + + +if __name__ == "__main__": + main() diff --git a/rust/libpsl-native/Cargo.toml b/rust/libpsl-native/Cargo.toml new file mode 100644 index 0000000..947509f --- /dev/null +++ b/rust/libpsl-native/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "psl-native" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[lib] +name = "psl_native" +crate-type = ["cdylib", "rlib"] + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/rust/libpsl-native/src/accounts.rs b/rust/libpsl-native/src/accounts.rs new file mode 100644 index 0000000..0757246 --- /dev/null +++ b/rust/libpsl-native/src/accounts.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{copy_string, set_errno}; +use libc::{c_char, gid_t, pid_t, uid_t}; +use std::{mem::MaybeUninit, ptr}; + +fn initial_buffer_size(key: libc::c_int) -> usize { + let size = unsafe { libc::sysconf(key) }; + usize::try_from(size) + .ok() + .filter(|size| *size > 0) + .unwrap_or(2048) +} + +fn grow_buffer(size: usize) -> Option { + let next = size + .checked_mul(2) + .filter(|size| isize::try_from(*size).is_ok()); + if next.is_none() { + set_errno(libc::ENOMEM); + } + next +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetPwUid(uid: uid_t) -> *mut c_char { + // Environment.UserName resolves only the current user, not an arbitrary UID. + let mut size = initial_buffer_size(libc::_SC_GETPW_R_SIZE_MAX); + loop { + let mut storage = vec![0u8; size]; + let mut entry = MaybeUninit::::uninit(); + let mut result = ptr::null_mut(); + set_errno(0); + let error = unsafe { + libc::getpwuid_r( + uid, + entry.as_mut_ptr(), + storage.as_mut_ptr().cast(), + storage.len(), + &raw mut result, + ) + }; + if error == libc::ERANGE { + let Some(next) = grow_buffer(size) else { + return ptr::null_mut(); + }; + size = next; + continue; + } + if error != 0 { + set_errno(error); + return ptr::null_mut(); + } + if result.is_null() { + return ptr::null_mut(); + } + return unsafe { copy_string((*result).pw_name) }; + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetGrGid(gid: gid_t) -> *mut c_char { + let mut size = initial_buffer_size(libc::_SC_GETGR_R_SIZE_MAX); + loop { + let mut storage = vec![0u8; size]; + let mut entry = MaybeUninit::::uninit(); + let mut result = ptr::null_mut(); + set_errno(0); + let error = unsafe { + libc::getgrgid_r( + gid, + entry.as_mut_ptr(), + storage.as_mut_ptr().cast(), + storage.len(), + &raw mut result, + ) + }; + if error == libc::ERANGE { + let Some(next) = grow_buffer(size) else { + return ptr::null_mut(); + }; + size = next; + continue; + } + if error != 0 { + set_errno(error); + return ptr::null_mut(); + } + if result.is_null() { + return ptr::null_mut(); + } + return unsafe { copy_string((*result).gr_name) }; + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetUserFromPid(pid: pid_t) -> *mut c_char { + #[cfg(target_os = "linux")] + { + // Preserve the original proc-directory owner contract rather than reading a UID field. + let path = format!("/proc/{pid}\0"); + unsafe { crate::GetFileOwner(path.as_ptr().cast()) } + } + #[cfg(target_os = "macos")] + { + match crate::process::bsd_process_info(pid) { + Ok(info) => unsafe { GetPwUid(info.pbi_uid) }, + Err(()) => ptr::null_mut(), + } + } + #[cfg(target_os = "freebsd")] + { + match crate::process::bsd_process_info(pid) { + Ok(info) => unsafe { GetPwUid(info.ki_uid) }, + Err(_) => ptr::null_mut(), + } + } +} diff --git a/rust/libpsl-native/src/filesystem.rs b/rust/libpsl-native/src/filesystem.rs new file mode 100644 index 0000000..22d7662 --- /dev/null +++ b/rust/libpsl-native/src/filesystem.rs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{copy_string, invalid_pointer, set_errno}; +use libc::{c_char, c_int, stat}; +use std::mem::MaybeUninit; +use std::ptr; + +#[repr(C)] +#[derive(Debug, Default)] +pub struct CommonStat { + pub Inode: i64, + pub Mode: c_int, + pub UserId: c_int, + pub GroupId: c_int, + pub HardlinkCount: c_int, + pub Size: i64, + pub AccessTime: i64, + pub ModifiedTime: i64, + pub ChangeTime: i64, + pub BlockSize: i64, + pub DeviceId: c_int, + pub NumberOfBlocks: c_int, + pub IsDirectory: c_int, + pub IsFile: c_int, + pub IsSymbolicLink: c_int, + pub IsBlockDevice: c_int, + pub IsCharacterDevice: c_int, + pub IsNamedPipe: c_int, + pub IsSocket: c_int, + pub IsSetUid: c_int, + pub IsSetGid: c_int, + pub IsSticky: c_int, +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetStat(path: *const c_char, buffer: *mut stat) -> c_int { + if invalid_pointer(path) || invalid_pointer(buffer) { + return -1; + } + set_errno(0); + unsafe { libc::stat(path, buffer) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetLStat(path: *const c_char, buffer: *mut stat) -> c_int { + if invalid_pointer(path) || invalid_pointer(buffer) { + return -1; + } + set_errno(0); + unsafe { libc::lstat(path, buffer) } +} + +pub(crate) unsafe fn read_stat(path: *const c_char, follow: bool) -> Result { + let mut buffer = MaybeUninit::uninit(); + let result = if follow { + unsafe { GetStat(path, buffer.as_mut_ptr()) } + } else { + unsafe { GetLStat(path, buffer.as_mut_ptr()) } + }; + if result == 0 { + Ok(unsafe { buffer.assume_init() }) + } else { + Err(result) + } +} + +// The fixed legacy ABI deliberately narrows/reinterprets native metadata fields. +#[allow( + clippy::unnecessary_cast, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap +)] +fn common_stat(st: &stat) -> CommonStat { + let kind = st.st_mode & libc::S_IFMT; + CommonStat { + Inode: st.st_ino as i64, + Mode: st.st_mode as c_int, + UserId: st.st_uid as c_int, + GroupId: st.st_gid as c_int, + HardlinkCount: st.st_nlink as c_int, + Size: st.st_size, + AccessTime: st.st_atime, + ModifiedTime: st.st_mtime, + ChangeTime: st.st_ctime, + BlockSize: st.st_blksize as i64, + DeviceId: st.st_dev as c_int, + NumberOfBlocks: st.st_blocks as c_int, + IsDirectory: c_int::from(kind == libc::S_IFDIR), + IsFile: c_int::from(kind == libc::S_IFREG), + IsSymbolicLink: c_int::from(kind == libc::S_IFLNK), + IsBlockDevice: c_int::from(kind == libc::S_IFBLK), + IsCharacterDevice: c_int::from(kind == libc::S_IFCHR), + IsNamedPipe: c_int::from(kind == libc::S_IFIFO), + IsSocket: c_int::from(kind == libc::S_IFSOCK), + // Preserve the original ABI's equality tests, including combined flags. + IsSetUid: c_int::from(st.st_mode & 0xE00 == libc::S_ISUID), + IsSetGid: c_int::from(st.st_mode & 0xE00 == libc::S_ISGID), + IsSticky: c_int::from(st.st_mode & 0xE00 == libc::S_ISVTX), + } +} + +unsafe fn get_common(path: *const c_char, output: *mut CommonStat, follow: bool) -> c_int { + if invalid_pointer(output) { + return -1; + } + match unsafe { read_stat(path, follow) } { + Ok(st) => { + unsafe { output.write(common_stat(&st)) }; + 0 + } + Err(result) => result, + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetCommonStat(path: *const c_char, output: *mut CommonStat) -> c_int { + // Migration: FileInfo and File.GetUnixFileMode cover only part of this metadata. + unsafe { get_common(path, output, true) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetCommonLStat(path: *const c_char, output: *mut CommonStat) -> c_int { + unsafe { get_common(path, output, false) } +} + +#[unsafe(no_mangle)] +#[allow(clippy::unnecessary_cast, clippy::cast_sign_loss)] // Preserve the native dev_t bit pattern, including signed macOS device IDs. +pub unsafe extern "C" fn GetInodeData( + path: *const c_char, + device: *mut u64, + inode: *mut u64, +) -> c_int { + if invalid_pointer(device) || invalid_pointer(inode) { + return -1; + } + match unsafe { read_stat(path, true) } { + Ok(st) => { + unsafe { + device.write(st.st_dev as u64); + inode.write(st.st_ino as u64); + } + 0 + } + Err(result) => result, + } +} + +#[unsafe(no_mangle)] +#[allow( + clippy::unnecessary_cast, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap +)] // The legacy API returns a C int, even when nlink_t is wider. +pub unsafe extern "C" fn GetLinkCount(path: *const c_char, count: *mut c_int) -> c_int { + if invalid_pointer(count) { + return -1; + } + match unsafe { read_stat(path, false) } { + Ok(st) => { + unsafe { count.write(st.st_nlink as c_int) }; + 0 + } + Err(result) => result, + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn IsFile(path: *const c_char) -> bool { + // Migration (.NET 7+): Path.Exists(path), NOT File.Exists; includes dangling links. + unsafe { read_stat(path, false).is_ok() } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn IsDirectory(path: *const c_char) -> bool { + // Migration: Directory.Exists(path) follows links to directories. + unsafe { read_stat(path, true) }.is_ok_and(|st| st.st_mode & libc::S_IFMT == libc::S_IFDIR) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn IsSymLink(path: *const c_char) -> bool { + // Migration (.NET 6+): FileSystemInfo.LinkTarget != null; handle I/O errors. + unsafe { read_stat(path, false) }.is_ok_and(|st| st.st_mode & libc::S_IFMT == libc::S_IFLNK) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn IsExecutable(path: *const c_char) -> bool { + // File.GetUnixFileMode is not equivalent to access(X_OK), which also checks ACLs. + !invalid_pointer(path) && unsafe { libc::access(path, libc::X_OK) == 0 } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn IsSameFileSystemItem(first: *const c_char, second: *const c_char) -> bool { + let Ok(a) = (unsafe { read_stat(first, true) }) else { + return false; + }; + let Ok(b) = (unsafe { read_stat(second, true) }) else { + return false; + }; + a.st_dev == b.st_dev && a.st_ino == b.st_ino +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn CreateHardLink(link: *const c_char, target: *const c_char) -> c_int { + // Migration (.NET 11+): File.CreateHardLink(link, target). + if invalid_pointer(link) || invalid_pointer(target) { + return -1; + } + unsafe { libc::link(target, link) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn CreateSymLink(link: *const c_char, target: *const c_char) -> c_int { + // Migration (.NET 6+): File.CreateSymbolicLink or Directory.CreateSymbolicLink. + if invalid_pointer(link) || invalid_pointer(target) { + return -1; + } + set_errno(0); + unsafe { libc::symlink(target, link) } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn FollowSymLink(path: *const c_char) -> *mut c_char { + // Migration (.NET 6+): ResolveLinkTarget(path, true) and LinkTarget for most uses. + // Exact realpath canonicalization + raw readlink fallback differs from that API. + if !unsafe { IsSymLink(path) } { + return ptr::null_mut(); + } + let resolved = unsafe { libc::realpath(path, ptr::null_mut()) }; + if !resolved.is_null() { + return resolved; + } + let mut size = 256usize; + loop { + let output = unsafe { libc::malloc(size + 1) }.cast::(); + if output.is_null() { + set_errno(libc::ENOMEM); + return output; + } + let length = unsafe { libc::readlink(path, output, size) }; + if length < 0 { + let error = crate::errno(); + unsafe { libc::free(output.cast()) }; + set_errno(error); + return ptr::null_mut(); + } + let length = length.unsigned_abs(); + if length < size { + unsafe { output.add(length).write(0) }; + return output; + } + unsafe { libc::free(output.cast()) }; + let Some(next) = size + .checked_mul(2) + .filter(|n| *n <= isize::MAX as usize / 2) + else { + set_errno(libc::ENAMETOOLONG); + return ptr::null_mut(); + }; + size = next; + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GetFileOwner(path: *const c_char) -> *mut c_char { + match unsafe { read_stat(path, true) } { + Ok(st) => unsafe { crate::GetPwUid(st.st_uid) }, + Err(_) => ptr::null_mut(), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn GetComputerName() -> *mut c_char { + // Migration: Dns.GetHostName(); Environment.MachineName drops the Unix domain suffix. + let mut size = 256usize; + loop { + let mut buffer = vec![0 as c_char; size]; + set_errno(0); + if unsafe { libc::gethostname(buffer.as_mut_ptr(), size) } == 0 && buffer.contains(&0) { + return unsafe { copy_string(buffer.as_ptr()) }; + } + let error = crate::errno(); + if error != 0 && error != libc::ENAMETOOLONG && error != libc::EINVAL { + return ptr::null_mut(); + } + if size >= 1024 * 1024 { + set_errno(libc::ENAMETOOLONG); + return ptr::null_mut(); + } + size *= 2; + } +} diff --git a/rust/libpsl-native/src/lib.rs b/rust/libpsl-native/src/lib.rs new file mode 100644 index 0000000..d20bcf5 --- /dev/null +++ b/rust/libpsl-native/src/lib.rs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(unix)] +#![allow(non_snake_case)] +// Every exported function accepts the same raw pointers as the original C ABI. +#![allow(clippy::missing_safety_doc)] + +mod accounts; +mod filesystem; +mod logging; +mod process; +mod time; + +pub use accounts::*; +pub use filesystem::*; +pub use logging::*; +pub use process::*; +pub use time::*; + +use libc::{c_char, c_int}; + +pub(crate) fn set_errno(value: c_int) { + unsafe { + *errno_ptr() = value; + } +} + +pub(crate) fn errno() -> c_int { + unsafe { *errno_ptr() } +} + +unsafe fn errno_ptr() -> *mut c_int { + #[cfg(target_os = "linux")] + unsafe { + libc::__errno_location() + } + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + unsafe { + libc::__error() + } +} + +pub(crate) fn invalid_pointer(pointer: *const T) -> bool { + if pointer.is_null() { + set_errno(libc::EINVAL); + true + } else { + false + } +} + +// Use the host libc allocator: the existing LPStr marshaller frees these strings. +pub(crate) unsafe fn copy_string(value: *const c_char) -> *mut c_char { + unsafe { libc::strdup(value) } +} + +#[unsafe(no_mangle)] +pub extern "C" fn GetErrorCategory(error: c_int) -> c_int { + // Migration: this PowerShell-specific mapping can be a C# switch; no P/Invoke needed. + match error { + libc::EINVAL => 5, + libc::ENOENT | libc::ESRCH => 13, + libc::EINTR => 14, + libc::EACCES | libc::EPERM => 18, + _ => 0, + } +} diff --git a/rust/libpsl-native/src/logging.rs b/rust/libpsl-native/src/logging.rs new file mode 100644 index 0000000..1cc22da --- /dev/null +++ b/rust/libpsl-native/src/logging.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::invalid_pointer; +use libc::{c_char, c_int}; + +#[cfg(not(target_os = "macos"))] +mod platform { + use super::{c_char, c_int}; + use std::ffi::{CStr, CString}; + use std::sync::Mutex; + + // openlog may retain ident rather than copying it. + static IDENTITY: Mutex> = Mutex::new(None); + + pub unsafe fn open(ident: *const c_char, facility: c_int) { + let identity = unsafe { CStr::from_ptr(ident) }.to_owned(); + let mut retained = IDENTITY.lock().unwrap_or_else(|_| std::process::abort()); + unsafe { + libc::openlog( + identity.as_ptr(), + libc::LOG_NDELAY | libc::LOG_PID, + facility, + ); + }; + *retained = Some(identity); + } + + pub unsafe fn write(priority: c_int, message: *const c_char) { + // The message must never be treated as the format string. + unsafe { libc::syslog(priority, c"%s".as_ptr(), message) }; + } + + pub fn close() { + let mut retained = IDENTITY.lock().unwrap_or_else(|_| std::process::abort()); + unsafe { libc::closelog() }; + *retained = None; + } +} + +#[cfg(target_os = "macos")] +mod platform { + use super::{c_char, c_int}; + use std::ffi::c_void; + use std::sync::atomic::{AtomicPtr, Ordering}; + + static LOG: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + + // This is the SDK's os_log macro backend, not a printf-style variadic ABI. + unsafe extern "C" { + fn os_log_create(subsystem: *const c_char, category: *const c_char) -> *mut c_void; + fn os_log_type_enabled(log: *mut c_void, kind: u8) -> bool; + fn _os_log_impl( + dso: *const u8, + log: *mut c_void, + kind: u8, + format: *const c_char, + buffer: *const u8, + length: u32, + ); + static __dso_handle: u8; + } + + #[used] + #[unsafe(link_section = "__TEXT,__oslogstring,cstring_literals")] + static FORMAT: [u8; 11] = *b"%{public}s\0"; + + #[repr(C, packed)] + struct StringArgument { + summary: u8, + count: u8, + descriptor: u8, + size: u8, + value: *const c_char, + } + + pub unsafe fn open(ident: *const c_char, _facility: c_int) { + let log = unsafe { os_log_create(c"com.microsoft.powershell".as_ptr(), ident) }; + LOG.store(log, Ordering::Release); + } + + pub unsafe fn write(priority: c_int, message: *const c_char) { + let log = LOG.load(Ordering::Acquire); + if log.is_null() { + crate::set_errno(libc::EINVAL); + return; + } + let kind = match priority { + libc::LOG_EMERG | libc::LOG_ALERT | libc::LOG_CRIT => 0x11, + libc::LOG_ERR => 0x10, + libc::LOG_DEBUG => 0x02, + _ => 0x00, + }; + if unsafe { os_log_type_enabled(log, kind) } { + let argument = StringArgument { + summary: 0x02, // Has a non-scalar argument. + count: 1, + descriptor: 0x22, // String (2), explicitly public (2). + size: u8::try_from(size_of::<*const c_char>()).expect("pointer size fits u8"), + value: message, + }; + unsafe { + _os_log_impl( + &raw const __dso_handle, + log, + kind, + FORMAT.as_ptr().cast(), + (&raw const argument).cast(), + u32::try_from(size_of::()).expect("log payload size fits u32"), + ); + }; + } + } + + // Preserve the original macOS close operation, which is intentionally a no-op. + pub fn close() {} +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Native_OpenLog(ident: *const c_char, facility: c_int) { + if !invalid_pointer(ident) { + unsafe { platform::open(ident, facility) }; + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Native_SysLog(priority: c_int, message: *const c_char) { + if !invalid_pointer(message) { + unsafe { platform::write(priority, message) }; + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn Native_CloseLog() { + platform::close(); +} diff --git a/rust/libpsl-native/src/process.rs b/rust/libpsl-native/src/process.rs new file mode 100644 index 0000000..5a96930 --- /dev/null +++ b/rust/libpsl-native/src/process.rs @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{errno, invalid_pointer, set_errno}; +use libc::{c_char, c_int, pid_t}; +#[cfg(any(target_os = "macos", target_os = "freebsd"))] +use std::mem::MaybeUninit; +use std::ptr; + +#[unsafe(no_mangle)] +pub extern "C" fn GetCurrentProcessId() -> pid_t { + // Migration (.NET 5+): Environment.ProcessId. + unsafe { libc::getpid() } +} + +#[unsafe(no_mangle)] +pub extern "C" fn GetCurrentThreadId() -> pid_t { + // Environment.CurrentManagedThreadId is not an OS thread ID. + #[cfg(target_os = "linux")] + unsafe { + libc::syscall(libc::SYS_gettid) as pid_t + } + #[cfg(target_os = "macos")] + { + let mut id = 0u64; + let error = unsafe { libc::pthread_threadid_np(0, &raw mut id) }; + if error != 0 { + set_errno(error); + return 0; + } + id as pid_t + } + #[cfg(target_os = "freebsd")] + unsafe { + libc::pthread_getthreadid_np() + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn bsd_process_info(pid: pid_t) -> Result { + let mut info = MaybeUninit::::uninit(); + let length = + c_int::try_from(size_of::()).expect("proc_bsdinfo size fits int"); + let result = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + length, + ) + }; + if result != length { + if result >= 0 { + set_errno(libc::ESRCH); + } + return Err(()); + } + Ok(unsafe { info.assume_init() }) +} + +#[cfg(target_os = "freebsd")] +pub(crate) fn bsd_process_info(pid: pid_t) -> Result { + let mut mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_PID, pid]; + let mut info = MaybeUninit::::uninit(); + let mut length = size_of::(); + let result = unsafe { + libc::sysctl( + mib.as_mut_ptr(), + mib.len() as u32, + info.as_mut_ptr().cast(), + &mut length, + ptr::null_mut(), + 0, + ) + }; + if result != 0 || length == 0 { + if result == 0 { + set_errno(libc::ESRCH); + } + return Err(()); + } + Ok(unsafe { info.assume_init() }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn GetPPid(pid: pid_t) -> pid_t { + #[cfg(target_os = "macos")] + { + bsd_process_info(pid).map_or(-1, |info| info.pbi_ppid as pid_t) + } + #[cfg(target_os = "freebsd")] + { + bsd_process_info(pid).map_or(-1, |info| info.ki_ppid) + } + #[cfg(target_os = "linux")] + { + // The original Linux implementation returns UINT_MAX for every PID. + let _ = pid; + -1 + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn KillProcess(pid: pid_t) -> bool { + // Migration: Process.GetProcessById(pid).Kill() for positive, individual PIDs. + // Keep kill() for zero/negative process-group selectors and errno semantics. + unsafe { libc::kill(pid, libc::SIGKILL) == 0 } +} + +#[unsafe(no_mangle)] +pub extern "C" fn WaitPid(pid: pid_t, nohang: bool) -> pid_t { + // Migration: Process.WaitForExit[Async] for processes owned by System.Diagnostics. + // Do not mix .NET's reaper with waitpid() for the same child. + unsafe { libc::waitpid(pid, ptr::null_mut(), if nohang { libc::WNOHANG } else { 0 }) } +} + +fn retry(mut call: impl FnMut() -> c_int) -> c_int { + loop { + let result = call(); + if result >= 0 || errno() != libc::EINTR { + return result; + } + } +} + +fn close(fd: c_int) { + if fd >= 0 { + // Do not retry close after EINTR: the descriptor may already have been reused. + unsafe { libc::close(fd) }; + } +} + +fn pipe(fds: &mut [c_int; 2]) -> c_int { + #[cfg(target_os = "linux")] + let result = retry(|| unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) }); + #[cfg(not(target_os = "linux"))] + let result = retry(|| unsafe { libc::pipe(fds.as_mut_ptr()) }); + if result < 0 { + return -1; + } + for fd in fds.iter_mut() { + // Keep pipe FDs clear of stdin/out/err, including when those were closed. + if *fd <= libc::STDERR_FILENO { + let duplicate = retry(|| unsafe { libc::fcntl(*fd, libc::F_DUPFD_CLOEXEC, 3) }); + if duplicate < 0 { + let error = errno(); + close(fds[0]); + close(fds[1]); + *fds = [-1, -1]; + set_errno(error); + return -1; + } + close(*fd); + *fd = duplicate; + } + #[cfg(not(target_os = "linux"))] + if retry(|| unsafe { libc::fcntl(*fd, libc::F_SETFD, libc::FD_CLOEXEC) }) < 0 { + let error = errno(); + close(fds[0]); + close(fds[1]); + *fds = [-1, -1]; + set_errno(error); + return -1; + } + } + 0 +} + +#[allow(clippy::too_many_arguments)] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ForkAndExecProcess( + filename: *const c_char, + argv: *const *const c_char, + envp: *const *const c_char, + cwd: *const c_char, + redirectStdin: c_int, + redirectStdout: c_int, + redirectStderr: c_int, + creationFlags: c_int, + childPid: *mut c_int, + stdinFd: *mut c_int, + stdoutFd: *mut c_int, + stderrFd: *mut c_int, +) -> c_int { + // Migration: Process.Start(ProcessStartInfo) for ordinary launches/redirection. + // No managed equivalent preserves the child-only SUPPRESS_PROCESS_SIGINT flag. + for output in [childPid, stdinFd, stdoutFd, stderrFd] { + if !output.is_null() { + unsafe { output.write(-1) }; + } + } + if invalid_pointer(filename) + || invalid_pointer(argv) + || invalid_pointer(envp) + || [childPid, stdinFd, stdoutFd, stderrFd] + .iter() + .any(|p| invalid_pointer(*p)) + { + return -1; + } + if [redirectStdin, redirectStdout, redirectStderr] + .iter() + .any(|flag| *flag & !1 != 0) + { + set_errno(libc::EINVAL); + return -1; + } + if unsafe { libc::access(filename, libc::X_OK) } != 0 { + return -1; + } + + let mut pipes = [[-1; 2]; 3]; + for (index, redirect) in [redirectStdin, redirectStdout, redirectStderr] + .iter() + .enumerate() + { + if *redirect != 0 && pipe(&mut pipes[index]) != 0 { + let error = errno(); + for pair in pipes { + close(pair[0]); + close(pair[1]); + } + set_errno(error); + return -1; + } + } + + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = libc::SIG_IGN; + unsafe { libc::sigemptyset(&raw mut action.sa_mask) }; + let pid = unsafe { libc::fork() }; + if pid == 0 { + // After fork, use only async-signal-safe libc calls; no allocation, locks, + // panicking, destructors, or return to the managed/Rust runtime. + for (pair, target) in [ + (pipes[0], libc::STDIN_FILENO), + (pipes[1], libc::STDOUT_FILENO), + (pipes[2], libc::STDERR_FILENO), + ] { + let source = if target == libc::STDIN_FILENO { + pair[0] + } else { + pair[1] + }; + if source >= 0 && retry(|| unsafe { libc::dup2(source, target) }) < 0 { + unsafe { libc::_exit(if errno() != 0 { errno() } else { 1 }) }; + } + } + if !cwd.is_null() && retry(|| unsafe { libc::chdir(cwd) }) < 0 { + unsafe { libc::_exit(if errno() != 0 { errno() } else { 1 }) }; + } + if creationFlags & 1 != 0 + && unsafe { libc::sigaction(libc::SIGINT, &raw const action, ptr::null_mut()) } < 0 + { + unsafe { libc::_exit(if errno() != 0 { errno() } else { 1 }) }; + } + unsafe { + libc::execve(filename, argv, envp); + libc::_exit(if errno() != 0 { errno() } else { 1 }); + } + } + + let error = errno(); + close(pipes[0][0]); + close(pipes[1][1]); + close(pipes[2][1]); + if pid < 0 { + close(pipes[0][1]); + close(pipes[1][0]); + close(pipes[2][0]); + set_errno(error); + return -1; + } + unsafe { + childPid.write(pid); + stdinFd.write(pipes[0][1]); + stdoutFd.write(pipes[1][0]); + stderrFd.write(pipes[2][0]); + } + 0 +} diff --git a/rust/libpsl-native/src/time.rs b/rust/libpsl-native/src/time.rs new file mode 100644 index 0000000..1227e21 --- /dev/null +++ b/rust/libpsl-native/src/time.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{invalid_pointer, set_errno}; +use libc::c_int; + +#[repr(C, packed(4))] +#[derive(Clone, Copy, Default)] +pub struct PrivateTm { + pub Seconds: i32, + pub Minutes: i32, + pub Hour: i32, + pub DayOfMonth: i32, + pub Month: i32, + pub Year: i32, + pub DayOfWeek: i32, + pub DayInYear: i32, + pub IsDst: i32, +} + +pub(crate) fn time_value(time: PrivateTm) -> Result { + let mut native: libc::tm = unsafe { std::mem::zeroed() }; + native.tm_sec = time.Seconds; + native.tm_min = time.Minutes; + native.tm_hour = time.Hour; + native.tm_mday = time.DayOfMonth; + native.tm_mon = time.Month; + native.tm_year = time.Year; + native.tm_wday = time.DayOfWeek; + native.tm_yday = time.DayInYear; + native.tm_isdst = time.IsDst; + let seconds = unsafe { libc::mktime(&raw mut native) }; + if seconds == -1 { + if crate::errno() == 0 { + set_errno(libc::EOVERFLOW); + } + return Err(()); + } + Ok(libc::timeval { + tv_sec: seconds, + tv_usec: 0, + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn SetDate(time: *const PrivateTm) -> c_int { + if invalid_pointer(time) { + return -1; + } + set_errno(0); + // Preserve local-time/DST conversion without changing the process-wide locale. + match time_value(unsafe { time.read_unaligned() }) { + Ok(value) => unsafe { libc::settimeofday(&raw const value, std::ptr::null()) }, + Err(()) => -1, + } +} diff --git a/rust/libpsl-native/tests/abi.cpp b/rust/libpsl-native/tests/abi.cpp new file mode 100644 index 0000000..1c640d4 --- /dev/null +++ b/rust/libpsl-native/tests/abi.cpp @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Independent caller: all function pointer types come from the original headers. +#include +#include +#include +#include +#include +#include +#include +#include +#include "createhardlink.h" +#include "createsymlink.h" +#include "followsymlink.h" +#include "createprocess.h" +#include "getcommonstat.h" +#include "getcommonlstat.h" +#include "getcomputername.h" +#include "getcurrentprocessorid.h" +#include "getcurrentthreadid.h" +#include "geterrorcategory.h" +#include "getfileowner.h" +#include "getgrgid.h" +#include "getinodedata.h" +#include "getlinkcount.h" +#include "getlstat.h" +#include "getppid.h" +#include "getpwuid.h" +#include "getstat.h" +#include "getuserfrompid.h" +#include "isdirectory.h" +#include "isexecutable.h" +#include "isfile.h" +#include "issamefilesystemitem.h" +#include "issymlink.h" +#include "killprocess.h" +#include "nativesyslog.h" +#include "setdate.h" +#include "waitpid.h" + +template T symbol(void* library, const char* name) +{ + dlerror(); + void* address = dlsym(library, name); + const char* error = dlerror(); + if (error != nullptr) { + std::fprintf(stderr, "%s: %s\n", name, error); + abort(); + } + return reinterpret_cast(address); +} + +#define LOAD(name) auto name = symbol(library, #name); (void)name + +int main(int argc, char** argv) +{ + static_assert(sizeof(CommonStat) == 112, "CommonStat size"); + static_assert(offsetof(CommonStat, DeviceId) == 64, "CommonStat device"); + static_assert(offsetof(CommonStat, IsSticky) == 108, "CommonStat flags"); + static_assert(sizeof(private_tm) == 36, "private_tm size"); + assert(argc == 2); + void* library = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL); + if (library == nullptr) { + std::fprintf(stderr, "%s\n", dlerror()); + return 1; + } + LOAD(CreateHardLink); + LOAD(CreateSymLink); + LOAD(FollowSymLink); + LOAD(ForkAndExecProcess); + LOAD(GetCommonStat); + LOAD(GetCommonLStat); + LOAD(GetComputerName); + LOAD(GetCurrentProcessId); + LOAD(GetCurrentThreadId); + LOAD(GetErrorCategory); + LOAD(GetFileOwner); + LOAD(GetGrGid); + LOAD(GetInodeData); + LOAD(GetLinkCount); + LOAD(GetLStat); + LOAD(GetPPid); + LOAD(GetPwUid); + LOAD(GetStat); + LOAD(GetUserFromPid); + LOAD(IsDirectory); + LOAD(IsExecutable); + LOAD(IsFile); + LOAD(IsSameFileSystemItem); + LOAD(IsSymLink); + LOAD(KillProcess); + LOAD(Native_OpenLog); + LOAD(Native_SysLog); + LOAD(Native_CloseLog); + LOAD(SetDate); + LOAD(WaitPid); + + char path[] = "/tmp/psl-rust-abi-XXXXXX"; + int fd = mkstemp(path); + assert(fd >= 0); + assert(write(fd, "native", 6) == 6); + assert(close(fd) == 0); + struct stat expected = {}; + struct stat raw = {}; + CommonStat normalized = {}; + assert(stat(path, &expected) == 0); + assert(GetStat(path, &raw) == 0); + assert(raw.st_ino == expected.st_ino && raw.st_size == expected.st_size); + assert(GetCommonStat(path, &normalized) == 0); + assert(normalized.Inode == static_cast(expected.st_ino)); + assert(normalized.Size == expected.st_size && normalized.IsFile == 1); + assert(normalized.UserId == static_cast(expected.st_uid)); + uint64_t device = 0, inode = 0; + assert(GetInodeData(path, &device, &inode) == 0); + assert(device == static_cast(expected.st_dev)); + assert(inode == static_cast(expected.st_ino)); + int32_t links = 0; + assert(GetLinkCount(path, &links) == 0 && links == expected.st_nlink); + assert(IsFile(path) && !IsDirectory(path) && !IsSymLink(path)); + assert(IsSameFileSystemItem(path, path)); + assert(IsDirectory("/tmp") && IsExecutable("/bin/sh")); + assert(GetCurrentProcessId() == getpid() && GetCurrentThreadId() > 0); + + char* host = GetComputerName(); + char* owner = GetFileOwner(path); + char* user = GetPwUid(geteuid()); + char* group = GetGrGid(getegid()); + assert(host && *host && owner && user && group); + assert(strcmp(owner, user) == 0); + free(host); + free(owner); + free(user); + free(group); + errno = 0; + assert(GetCommonStat(nullptr, &normalized) == -1 && errno == EINVAL); + errno = 0; + assert(SetDate(nullptr) == -1 && errno == EINVAL); + assert(GetErrorCategory(ENOENT) == 13); + + char shell[] = "/bin/sh"; + char option[] = "-c"; + char command[] = "printf '%s' \"$PSL_ABI_TEST\""; + char setting[] = "PSL_ABI_TEST=interop"; + char* arguments[] = {shell, option, command, nullptr}; + char* environment[] = {setting, nullptr}; + int32_t pid = -1, input = -1, output = -1, error = -1; + assert(ForkAndExecProcess(shell, arguments, environment, "/tmp", 0, 1, 0, + 1, &pid, &input, &output, &error) == 0); + assert(pid > 0 && input == -1 && output >= 0 && error == -1); + char buffer[32] = {}; + assert(read(output, buffer, sizeof(buffer)) == 7); + assert(strcmp(buffer, "interop") == 0); + assert(close(output) == 0); + assert(WaitPid(pid, false) == pid); + + Native_OpenLog("powershell-rust-abi", LOG_USER); + Native_SysLog(LOG_NOTICE, "Rust C ABI literal %s %n"); + Native_CloseLog(); + assert(unlink(path) == 0); + assert(dlclose(library) == 0); + std::puts("All 30 exports resolved; original-header ABI and host allocation/errno verified."); + return 0; +} diff --git a/rust/libpsl-native/tests/abi.ps1 b/rust/libpsl-native/tests/abi.ps1 new file mode 100644 index 0000000..1336655 --- /dev/null +++ b/rust/libpsl-native/tests/abi.ps1 @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +param( + [Parameter(Mandatory)] + [string] $LibraryPath +) + +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class PslAbiSmoke +{ + [DllImport("libpsl-native", SetLastError = true)] + private static extern int GetCurrentProcessId(); + + [DllImport("libpsl-native", SetLastError = true)] + [return: MarshalAs(UnmanagedType.LPStr)] + private static extern string GetComputerName(); + + [DllImport("libpsl-native", SetLastError = true)] + [return: MarshalAs(UnmanagedType.I1)] + private static extern bool IsDirectory([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + + [DllImport("libpsl-native", SetLastError = true)] + private static extern int GetLinkCount([MarshalAs(UnmanagedType.LPUTF8Str)] string path, + out int count); + + public static void Run(string path) + { + IntPtr library = NativeLibrary.Load(path); + NativeLibrary.SetDllImportResolver(typeof(PslAbiSmoke).Assembly, + (name, assembly, searchPath) => name == "libpsl-native" ? library : IntPtr.Zero); + if (GetCurrentProcessId() != Environment.ProcessId) + throw new Exception("Process ID ABI mismatch"); + for (int i = 0; i < 100; i++) + if (string.IsNullOrEmpty(GetComputerName())) + throw new Exception("LPStr return marshalling failed"); + if (!IsDirectory("/tmp")) + throw new Exception("One-byte native bool marshalling failed"); + int count; + if (GetLinkCount(null, out count) != -1 || Marshal.GetLastPInvokeError() != 22) + throw new Exception("Host errno was not preserved"); + // Keep the handle alive: the runtime caches resolved P/Invoke addresses. + } +} +'@ +[PslAbiSmoke]::Run((Resolve-Path $LibraryPath).Path) +'Existing C# P/Invoke string/bool/errno contracts verified.' diff --git a/rust/libpsl-native/tests/native.rs b/rust/libpsl-native/tests/native.rs new file mode 100644 index 0000000..de46167 --- /dev/null +++ b/rust/libpsl-native/tests/native.rs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(unix)] +#![allow(clippy::unnecessary_cast)] // libc typedef widths differ between target platforms. + +use psl_native::*; +use std::ffi::{CStr, CString}; +use std::fs; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let directory = std::env::temp_dir().join(format!( + "psl-rust-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&directory).unwrap(); + Self(directory) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +fn path(value: &Path) -> CString { + CString::new(value.as_os_str().as_bytes()).unwrap() +} + +unsafe fn take_string(value: *mut libc::c_char) -> String { + assert!(!value.is_null(), "native string lookup failed"); + let string = unsafe { CStr::from_ptr(value) } + .to_str() + .unwrap() + .to_owned(); + unsafe { libc::free(value.cast()) }; + string +} + +fn last_errno() -> libc::c_int { + std::io::Error::last_os_error().raw_os_error().unwrap() +} + +#[test] +fn common_stat_layout_matches_c_abi() { + assert_eq!(size_of::(), 112); + assert_eq!(std::mem::offset_of!(CommonStat, Inode), 0); + assert_eq!(std::mem::offset_of!(CommonStat, Size), 24); + assert_eq!(std::mem::offset_of!(CommonStat, DeviceId), 64); + assert_eq!(std::mem::offset_of!(CommonStat, IsSticky), 108); + assert_eq!(size_of::(), 36); + assert_eq!(align_of::(), 4); +} + +#[test] +// Check the legacy ABI's deliberately signed/truncated metadata against libc. +#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] +fn metadata_links_and_identity() { + let f = Fixture::new(); + let file = f.path("file"); + fs::write(&file, b"native test").unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o751)).unwrap(); + let file_c = path(&file); + let hard = path(&f.path("hard")); + let link = path(&f.path("link")); + unsafe { + assert_eq!(CreateHardLink(hard.as_ptr(), file_c.as_ptr()), 0); + assert_eq!(CreateSymLink(link.as_ptr(), file_c.as_ptr()), 0); + assert!(IsSameFileSystemItem(file_c.as_ptr(), hard.as_ptr())); + assert!(IsSameFileSystemItem(file_c.as_ptr(), link.as_ptr())); + assert!(IsFile(file_c.as_ptr())); + assert!(!IsDirectory(file_c.as_ptr())); + assert!(!IsSymLink(hard.as_ptr())); + assert!(IsSymLink(link.as_ptr())); + assert!(IsExecutable(file_c.as_ptr())); + assert!(!IsSameFileSystemItem( + file_c.as_ptr(), + c"/nonexistent/psl-rust".as_ptr() + )); + + let mut stat = CommonStat::default(); + assert_eq!(GetCommonStat(link.as_ptr(), &raw mut stat), 0); + let metadata = fs::metadata(&file).unwrap(); + assert_eq!(stat.Inode as u64, metadata.ino()); + assert_eq!(stat.Mode as u32, metadata.mode()); + assert_eq!(stat.UserId as u32, metadata.uid()); + assert_eq!(stat.GroupId as u32, metadata.gid()); + assert_eq!(stat.HardlinkCount, 2); + assert_eq!(stat.Size, 11); + assert_eq!(stat.AccessTime, metadata.atime()); + assert_eq!(stat.ModifiedTime, metadata.mtime()); + assert_eq!(stat.ChangeTime, metadata.ctime()); + assert_eq!(stat.BlockSize as u64, metadata.blksize()); + assert_eq!(stat.DeviceId, metadata.dev() as i32); + assert_eq!(stat.NumberOfBlocks as u64, metadata.blocks()); + assert_eq!(stat.IsFile, 1); + assert_eq!(stat.IsSymbolicLink, 0); + assert_eq!(GetCommonLStat(link.as_ptr(), &raw mut stat), 0); + assert_eq!(stat.IsFile, 0); + assert_eq!(stat.IsSymbolicLink, 1); + assert_eq!(stat.HardlinkCount, 1); + + let mut device = 0; + let mut inode = 0; + assert_eq!( + GetInodeData(link.as_ptr(), &raw mut device, &raw mut inode), + 0 + ); + assert_eq!(device, metadata.dev()); + assert_eq!(inode, metadata.ino()); + let mut count = 0; + assert_eq!(GetLinkCount(file_c.as_ptr(), &raw mut count), 0); + assert_eq!(count, 2); + assert_eq!(GetLinkCount(link.as_ptr(), &raw mut count), 0); + assert_eq!(count, 1); + assert_eq!( + take_string(FollowSymLink(link.as_ptr())), + fs::canonicalize(file).unwrap().to_str().unwrap() + ); + assert!(FollowSymLink(file_c.as_ptr()).is_null()); + + let mut native = std::mem::MaybeUninit::uninit(); + assert_eq!(GetStat(file_c.as_ptr(), native.as_mut_ptr()), 0); + assert_eq!(native.assume_init().st_ino as u64, metadata.ino()); + assert_eq!(GetLStat(link.as_ptr(), native.as_mut_ptr()), 0); + assert_eq!(native.assume_init().st_mode & libc::S_IFMT, libc::S_IFLNK); + } +} + +#[test] +fn directories_dangling_links_special_entries_and_errors() { + let f = Fixture::new(); + let root = path(&f.0); + let dangling = path(&f.path("dangling")); + let directory_link = path(&f.path("directory-link")); + let missing = path(&f.path("missing")); + let fifo = path(&f.path("fifo")); + unsafe { + assert!(IsFile(root.as_ptr())); + assert!(IsDirectory(root.as_ptr())); + assert_eq!(CreateSymLink(directory_link.as_ptr(), root.as_ptr()), 0); + assert!(IsDirectory(directory_link.as_ptr())); + assert_eq!(CreateSymLink(dangling.as_ptr(), c"missing".as_ptr()), 0); + assert!(IsFile(dangling.as_ptr())); + assert!(IsSymLink(dangling.as_ptr())); + assert!(!IsDirectory(dangling.as_ptr())); + assert_eq!(take_string(FollowSymLink(dangling.as_ptr())), "missing"); + assert_eq!(libc::mkfifo(fifo.as_ptr(), 0o600), 0); + assert!(IsFile(fifo.as_ptr())); + let mut stat = CommonStat::default(); + assert_eq!(GetCommonStat(fifo.as_ptr(), &raw mut stat), 0); + assert_eq!(stat.IsNamedPipe, 1); + assert_eq!(stat.IsFile, 0); + assert!(!IsFile(missing.as_ptr())); + assert_eq!(last_errno(), libc::ENOENT); + assert_eq!(GetCommonStat(missing.as_ptr(), &raw mut stat), -1); + assert_eq!(last_errno(), libc::ENOENT); + assert_eq!(GetCommonLStat(missing.as_ptr(), &raw mut stat), -1); + assert_eq!(last_errno(), libc::ENOENT); + let mut count = 42; + assert_eq!(GetLinkCount(missing.as_ptr(), &raw mut count), -1); + assert_eq!(count, 42); + assert_eq!(GetCommonStat(root.as_ptr(), ptr::null_mut()), -1); + assert_eq!(last_errno(), libc::EINVAL); + assert_eq!(CreateHardLink(ptr::null(), root.as_ptr()), -1); + assert_eq!(last_errno(), libc::EINVAL); + assert_eq!(SetDate(ptr::null()), -1); + assert_eq!(last_errno(), libc::EINVAL); + } +} + +#[test] +fn canonicalization_includes_parent_symlinks_and_raw_fallback() { + let f = Fixture::new(); + fs::create_dir(f.path("directory")).unwrap(); + fs::write(f.path("directory/file"), b"content").unwrap(); + symlink("directory", f.path("parent")).unwrap(); + symlink("file", f.path("directory/link")).unwrap(); + symlink("not-there", f.path("directory/dangling")).unwrap(); + let long_target = "a".repeat(900); + symlink(&long_target, f.path("long-dangling")).unwrap(); + unsafe { + assert_eq!( + take_string(FollowSymLink(path(&f.path("parent/link")).as_ptr())), + fs::canonicalize(f.path("directory/file")) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + take_string(FollowSymLink(path(&f.path("parent/dangling")).as_ptr())), + "not-there" + ); + assert_eq!( + take_string(FollowSymLink(path(&f.path("long-dangling")).as_ptr())), + long_target + ); + } +} + +#[test] +fn account_lookup_host_and_thread_identity() { + unsafe { + let user = take_string(GetPwUid(libc::getuid())); + assert_ne!(user, ""); + assert_ne!(take_string(GetGrGid(libc::getgid())), ""); + assert_eq!( + take_string(GetFileOwner( + path(&std::env::current_dir().unwrap()).as_ptr() + )), + take_string(GetPwUid(fs::metadata(".").unwrap().uid())) + ); + assert_eq!( + take_string(GetUserFromPid(libc::getpid())), + take_string(GetPwUid(libc::geteuid())) + ); + assert_ne!(take_string(GetComputerName()), ""); + assert_eq!(GetCurrentProcessId(), libc::getpid()); + assert_ne!(GetCurrentThreadId(), 0); + #[cfg(target_os = "linux")] + { + assert_eq!( + libc::c_long::from(GetCurrentThreadId()), + libc::syscall(libc::SYS_gettid) + ); + assert_eq!(GetPPid(libc::getpid()), -1); + } + #[cfg(any(target_os = "macos", target_os = "freebsd"))] + assert_eq!(GetPPid(libc::getpid()), libc::getppid()); + } +} + +#[test] +fn error_category_mapping() { + for (error, category) in [ + (libc::EINVAL, 5), + (libc::ENOENT, 13), + (libc::ESRCH, 13), + (libc::EINTR, 14), + (libc::EACCES, 18), + (libc::EPERM, 18), + (libc::EBUSY, 0), + (0, 0), + ] { + assert_eq!(GetErrorCategory(error), category); + } +} + +unsafe fn spawn(args: &[&CStr], cwd: Option<&CStr>, flags: i32) -> (i32, i32, i32, i32) { + let mut pointers: Vec<_> = args.iter().map(|arg| arg.as_ptr()).collect(); + pointers.push(ptr::null()); + let env = [c"PSL_RUST_TEST=present".as_ptr(), ptr::null()]; + let mut pid = -1; + let mut input = -1; + let mut output = -1; + let mut error = -1; + assert_eq!( + unsafe { + ForkAndExecProcess( + c"/bin/sh".as_ptr(), + pointers.as_ptr(), + env.as_ptr(), + cwd.map_or(ptr::null(), CStr::as_ptr), + 1, + 1, + 1, + flags, + &raw mut pid, + &raw mut input, + &raw mut output, + &raw mut error, + ) + }, + 0, + "spawn failed: {}", + std::io::Error::last_os_error() + ); + (pid, input, output, error) +} + +unsafe fn read_fd(fd: i32) -> String { + use std::io::Read; + use std::os::fd::FromRawFd; + let mut file = unsafe { fs::File::from_raw_fd(fd) }; + let mut text = String::new(); + file.read_to_string(&mut text).unwrap(); + text +} + +#[test] +fn spawning_redirection_environment_and_working_directory() { + let fixture = Fixture::new(); + let directory = path(&fixture.0); + unsafe { + let (pid, input, output, error) = spawn( + &[c"sh", c"-c", c"read value; printf '%s:%s\\n' \"$value\" \"$PSL_RUST_TEST\"; pwd; printf 'stderr' >&2"], + Some(&directory), 0, + ); + assert_eq!(libc::write(input, c"stdin\n".as_ptr().cast(), 6), 6); + libc::close(input); + let text = read_fd(output); + assert!(text.starts_with("stdin:present\n")); + assert!(text.contains(fixture.0.file_name().unwrap().to_str().unwrap())); + assert_eq!(read_fd(error), "stderr"); + assert_eq!(WaitPid(pid, false), pid); + assert_eq!(WaitPid(pid, true), -1); + assert_eq!(last_errno(), libc::ECHILD); + } +} + +#[test] +fn child_only_sigint_suppression() { + unsafe { + let mut before: libc::sigaction = std::mem::zeroed(); + assert_eq!( + libc::sigaction(libc::SIGINT, ptr::null(), &raw mut before), + 0 + ); + // A child inheriting SIG_IGN must survive its own SIGINT. + let (pid, input, output, error) = + spawn(&[c"sh", c"-c", c"kill -INT $$; printf survived"], None, 1); + libc::close(input); + assert_eq!(read_fd(output), "survived"); + assert_eq!(read_fd(error), ""); + assert_eq!(WaitPid(pid, false), pid); + let mut after: libc::sigaction = std::mem::zeroed(); + assert_eq!( + libc::sigaction(libc::SIGINT, ptr::null(), &raw mut after), + 0 + ); + assert_eq!(before.sa_sigaction, after.sa_sigaction); + assert_eq!(before.sa_flags, after.sa_flags); + } +} + +#[test] +fn failed_exec_exits_without_returning_into_the_host() { + let fixture = Fixture::new(); + let invalid_program = fixture.path("not-an-executable-image"); + fs::write(&invalid_program, b"not an executable").unwrap(); + fs::set_permissions(&invalid_program, fs::Permissions::from_mode(0o700)).unwrap(); + let filename = path(&invalid_program); + let argv = [filename.as_ptr(), ptr::null()]; + let env = [ptr::null()]; + unsafe { + let mut pid = -1; + let mut input = -1; + let mut output = -1; + let mut error = -1; + assert_eq!( + ForkAndExecProcess( + filename.as_ptr(), + argv.as_ptr(), + env.as_ptr(), + ptr::null(), + 0, + 0, + 0, + 0, + &raw mut pid, + &raw mut input, + &raw mut output, + &raw mut error, + ), + 0 + ); + assert!(pid > 0); + assert_eq!((input, output, error), (-1, -1, -1)); + let mut status = 0; + assert_eq!(libc::waitpid(pid, &raw mut status, 0), pid); + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), libc::ENOEXEC); + } +} + +#[test] +fn nonblocking_wait_and_kill() { + unsafe { + let (pid, input, output, error) = spawn(&[c"sh", c"-c", c"read value"], None, 0); + assert_eq!(WaitPid(pid, true), 0); + assert!(KillProcess(pid)); + assert_eq!(WaitPid(pid, false), pid); + libc::close(input); + assert_eq!(read_fd(output), ""); + assert_eq!(read_fd(error), ""); + } +} + +#[test] +fn invalid_spawn_initializes_available_outputs() { + unsafe { + let mut pid = 123; + let mut input = 123; + let mut output = 123; + let mut error = 123; + assert_eq!( + ForkAndExecProcess( + ptr::null(), + ptr::null(), + ptr::null(), + ptr::null(), + 0, + 0, + 0, + 0, + &raw mut pid, + &raw mut input, + &raw mut output, + &raw mut error, + ), + -1 + ); + assert_eq!(last_errno(), libc::EINVAL); + assert_eq!((pid, input, output, error), (-1, -1, -1, -1)); + } +} + +#[test] +fn native_logging_accepts_literal_format_characters() { + unsafe { + Native_OpenLog(c"psl-native-rust-test".as_ptr(), libc::LOG_USER); + Native_SysLog(libc::LOG_DEBUG, c"literal 100% %s %n".as_ptr()); + Native_CloseLog(); + } +} diff --git a/rust/tests/Invoke-Tests.ps1 b/rust/tests/Invoke-Tests.ps1 new file mode 100644 index 0000000..d132fb9 --- /dev/null +++ b/rust/tests/Invoke-Tests.ps1 @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +[CmdletBinding(DefaultParameterSetName = 'Unix')] +param( + [Parameter(Mandatory, ParameterSetName = 'Unix')] + [string] $LibraryPath, + [Parameter(Mandatory, ParameterSetName = 'Windows')] + [string] $ExecutablePath, + [Parameter(Mandatory, ParameterSetName = 'Windows')] + [ValidateSet('coreclr', 'framework')] + [string] $Configuration, + [Parameter(Mandatory, ParameterSetName = 'Windows')] + [ValidateSet('x64', 'x86')] + [string] $Architecture, + [Parameter(Mandatory)] + [string] $ResultPath +) + +$ErrorActionPreference = 'Stop' +Import-Module Pester -MinimumVersion 5.5.0 -MaximumVersion 5.99.99 -ErrorAction Stop +$data = if ($PSCmdlet.ParameterSetName -eq 'Unix') { + if ($IsWindows) { throw 'Unix library tests cannot run in a Windows process.' } + @{ Path = "$PSScriptRoot/NativeLibrary.Tests.ps1"; Data = @{ LibraryPath = (Resolve-Path $LibraryPath).Path } } +} else { + if (-not $IsWindows) { throw 'Windows launcher tests require Windows.' } + @{ + Path = "$PSScriptRoot/WindowsLauncher.Tests.ps1" + Data = @{ ExecutablePath = (Resolve-Path $ExecutablePath).Path; Configuration = $Configuration; Architecture = $Architecture } + } +} +$settings = New-PesterConfiguration +$settings.Run.Container = New-PesterContainer @data +$settings.Run.PassThru = $true +$settings.Output.Verbosity = 'Detailed' +$settings.TestResult.Enabled = $true +$settings.TestResult.OutputFormat = 'NUnitXml' +$resultFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ResultPath) +New-Item -ItemType Directory -Force (Split-Path -Parent $resultFile) > $null +$settings.TestResult.OutputPath = $resultFile +$result = Invoke-Pester -Configuration $settings +if ($result.Result -ne 'Passed' -or $result.TotalCount -eq 0 -or $result.SkippedCount -gt 0) { + throw "Pester failed: $($result.FailedCount) failed, $($result.SkippedCount) skipped, $($result.TotalCount) total." +} diff --git a/rust/tests/NativeLibrary.Tests.ps1 b/rust/tests/NativeLibrary.Tests.ps1 new file mode 100644 index 0000000..0a9784d --- /dev/null +++ b/rust/tests/NativeLibrary.Tests.ps1 @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +param( + [Parameter(Mandatory)] + [string] $LibraryPath +) + +BeforeAll { + Add-Type -Path "$PSScriptRoot/NativeMethods.cs" + [PowerShell.Native.Tests.NativeMethods]::Load((Resolve-Path $LibraryPath).Path) +} + +Describe 'Rust Unix library exports' { + It 'exports with the original symbol name' -ForEach @( + 'CreateHardLink', 'CreateSymLink', 'FollowSymLink', 'ForkAndExecProcess', + 'GetCommonStat', 'GetCommonLStat', 'GetComputerName', 'GetCurrentProcessId', + 'GetCurrentThreadId', 'GetErrorCategory', 'GetFileOwner', 'GetGrGid', + 'GetInodeData', 'GetLinkCount', 'GetLStat', 'GetPPid', 'GetPwUid', + 'GetStat', 'GetUserFromPid', 'IsDirectory', 'IsExecutable', 'IsFile', + 'IsSameFileSystemItem', 'IsSymLink', 'KillProcess', 'Native_OpenLog', + 'Native_SysLog', 'Native_CloseLog', 'SetDate', 'WaitPid' + ).ForEach({ @{ Name = $_ } }) { + [PowerShell.Native.Tests.NativeMethods]::Export($Name) | Should -Not -Be ([IntPtr]::Zero) + } +} + +Describe 'Rust managed/native ABI' { + It 'preserves normalized stat and time layouts' { + [System.Runtime.InteropServices.Marshal]::SizeOf([type][PowerShell.Native.Tests.CommonStat]) | Should -Be 112 + [System.Runtime.InteropServices.Marshal]::OffsetOf([type][PowerShell.Native.Tests.CommonStat], 'DeviceId').ToInt32() | Should -Be 64 + [System.Runtime.InteropServices.Marshal]::OffsetOf([type][PowerShell.Native.Tests.CommonStat], 'IsSticky').ToInt32() | Should -Be 108 + [System.Runtime.InteropServices.Marshal]::SizeOf([type][PowerShell.Native.Tests.PrivateTm]) | Should -Be 36 + } + + It 'returns native process and thread identities' { + [PowerShell.Native.Tests.NativeMethods]::GetCurrentProcessId() | Should -Be $PID + [PowerShell.Native.Tests.NativeMethods]::GetCurrentThreadId() | Should -BeGreaterThan 0 + $parent = [PowerShell.Native.Tests.NativeMethods]::GetPPid($PID) + if ($IsLinux) { $parent | Should -Be -1 } else { $parent | Should -BeGreaterThan 0 } + } + + It 'marshals and releases returned UTF-8 strings repeatedly' { + foreach ($iteration in 1..100) { + [PowerShell.Native.Tests.NativeMethods]::GetComputerName() | Should -Be ([System.Net.Dns]::GetHostName()) + } + $uid = [PowerShell.Native.Tests.NativeMethods]::getuid() + $name = [PowerShell.Native.Tests.NativeMethods]::GetPwUid($uid) + $name | Should -Not -BeNullOrEmpty + [PowerShell.Native.Tests.NativeMethods]::GetUserFromPid($PID) | Should -Be $name + [PowerShell.Native.Tests.NativeMethods]::GetGrGid([PowerShell.Native.Tests.NativeMethods]::getgid()) | Should -Not -BeNullOrEmpty + } + + It 'preserves PowerShell error categories' { + [PowerShell.Native.Tests.NativeMethods]::GetErrorCategory(2) | Should -Be 13 + [PowerShell.Native.Tests.NativeMethods]::GetErrorCategory(13) | Should -Be 18 + [PowerShell.Native.Tests.NativeMethods]::GetErrorCategory(22) | Should -Be 5 + } +} + +Describe 'Rust filesystem behavior' { + BeforeEach { + $directory = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + $null = New-Item -ItemType Directory -Path $directory + $file = Join-Path $directory "native-$([char]0x03bb).txt" + [System.IO.File]::WriteAllText($file, 'native data') + $hard = Join-Path $directory 'hard' + $link = Join-Path $directory 'link' + } + + It 'preserves one-byte booleans and legacy existence semantics' { + [PowerShell.Native.Tests.NativeMethods]::IsDirectory($directory) | Should -BeTrue + [PowerShell.Native.Tests.NativeMethods]::IsDirectory($file) | Should -BeFalse + [PowerShell.Native.Tests.NativeMethods]::IsFile($directory) | Should -BeTrue + [PowerShell.Native.Tests.NativeMethods]::IsFile($file) | Should -BeTrue + [PowerShell.Native.Tests.NativeMethods]::IsFile("$file-missing") | Should -BeFalse + [PowerShell.Native.Tests.NativeMethods]::IsExecutable('/bin/sh') | Should -BeTrue + } + + It 'creates hard links with matching inode, device, and link count' { + [PowerShell.Native.Tests.NativeMethods]::CreateHardLink($hard, $file) | Should -Be 0 + [PowerShell.Native.Tests.NativeMethods]::IsSameFileSystemItem($file, $hard) | Should -BeTrue + [ulong] $device = 0; [ulong] $inode = 0 + [ulong] $otherDevice = 0; [ulong] $otherInode = 0 + [PowerShell.Native.Tests.NativeMethods]::GetInodeData($file, [ref]$device, [ref]$inode) | Should -Be 0 + [PowerShell.Native.Tests.NativeMethods]::GetInodeData($hard, [ref]$otherDevice, [ref]$otherInode) | Should -Be 0 + $device | Should -Be $otherDevice + $inode | Should -Be $otherInode + $count = 0 + [PowerShell.Native.Tests.NativeMethods]::GetLinkCount($file, [ref]$count) | Should -Be 0 + $count | Should -Be 2 + } + + It 'distinguishes stat from lstat and resolves symlinks' { + [PowerShell.Native.Tests.NativeMethods]::CreateSymLink($link, $file) | Should -Be 0 + [PowerShell.Native.Tests.NativeMethods]::IsSymLink($link) | Should -BeTrue + [PowerShell.Native.Tests.NativeMethods]::IsSameFileSystemItem($file, $link) | Should -BeTrue + [PowerShell.Native.Tests.NativeMethods]::FollowSymLink($link) | Should -Be (Get-Item $file).FullName + $stat = [PowerShell.Native.Tests.CommonStat]::new() + [PowerShell.Native.Tests.NativeMethods]::GetCommonStat($link, [ref]$stat) | Should -Be 0 + $stat.IsFile | Should -Be 1 + $stat.Size | Should -Be 11 + [PowerShell.Native.Tests.NativeMethods]::GetCommonLStat($link, [ref]$stat) | Should -Be 0 + $stat.IsSymbolicLink | Should -Be 1 + } + + It 'retains dangling link existence and raw-target fallback' { + $missing = Join-Path $directory 'missing' + [PowerShell.Native.Tests.NativeMethods]::CreateSymLink($link, $missing) | Should -Be 0 + [PowerShell.Native.Tests.NativeMethods]::IsFile($link) | Should -BeTrue + [PowerShell.Native.Tests.NativeMethods]::IsDirectory($link) | Should -BeFalse + [PowerShell.Native.Tests.NativeMethods]::FollowSymLink($link) | Should -Be $missing + } + + It 'supports native raw stat buffers and file ownership' { + $buffer = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(512) + try { + [PowerShell.Native.Tests.NativeMethods]::GetStat($file, $buffer) | Should -Be 0 + [PowerShell.Native.Tests.NativeMethods]::GetLStat($file, $buffer) | Should -Be 0 + } finally { + [System.Runtime.InteropServices.Marshal]::FreeHGlobal($buffer) + } + [PowerShell.Native.Tests.NativeMethods]::GetFileOwner($file) | Should -Be ([PowerShell.Native.Tests.NativeMethods]::GetUserFromPid($PID)) + } + + It 'preserves native errno for missing paths' { + $count = 0 + $result = [PowerShell.Native.Tests.NativeMethods]::GetLinkCount("$file-missing", [ref]$count) + $errorCode = [System.Runtime.InteropServices.Marshal]::GetLastPInvokeError() + $result | Should -Be -1 + $errorCode | Should -Be 2 + } +} + +Describe 'Rust safe failure and logging paths' { + It 'rejects null process arguments and initializes all outputs' { + $child = $inputFd = $outputFd = $errorFd = 42 + $result = [PowerShell.Native.Tests.NativeMethods]::ForkAndExecProcess( + [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, [IntPtr]::Zero, + 0, 0, 0, 0, [ref]$child, [ref]$inputFd, [ref]$outputFd, [ref]$errorFd) + $errorCode = [System.Runtime.InteropServices.Marshal]::GetLastPInvokeError() + $result | Should -Be -1 + $errorCode | Should -Be 22 + $child | Should -Be -1 + $inputFd | Should -Be -1 + $outputFd | Should -Be -1 + $errorFd | Should -Be -1 + } + + It 'rejects a null time without changing the system clock' { + $result = [PowerShell.Native.Tests.NativeMethods]::SetDate([IntPtr]::Zero) + $errorCode = [System.Runtime.InteropServices.Marshal]::GetLastPInvokeError() + $result | Should -Be -1 + $errorCode | Should -Be 22 + } + + It 'reports errors for nonexistent processes without touching other children' { + [PowerShell.Native.Tests.NativeMethods]::KillProcess([int]::MaxValue) | Should -BeFalse + [PowerShell.Native.Tests.NativeMethods]::WaitPid([int]::MaxValue, $true) | Should -Be -1 + } + + It 'accepts literal format characters in log messages' { + [PowerShell.Native.Tests.NativeMethods]::Native_OpenLog('powershell-rust-pester', 8) + try { + { [PowerShell.Native.Tests.NativeMethods]::Native_SysLog(6, 'Pester literal %s %n') } | Should -Not -Throw + } finally { + [PowerShell.Native.Tests.NativeMethods]::Native_CloseLog() + } + } +} diff --git a/rust/tests/NativeMethods.cs b/rust/tests/NativeMethods.cs new file mode 100644 index 0000000..40ec813 --- /dev/null +++ b/rust/tests/NativeMethods.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Runtime.InteropServices; + +namespace PowerShell.Native.Tests +{ + [StructLayout(LayoutKind.Sequential)] + public struct CommonStat + { + public long Inode; + public int Mode, UserId, GroupId, HardlinkCount; + public long Size, AccessTime, ModifiedTime, ChangeTime, BlockSize; + public int DeviceId, NumberOfBlocks, IsDirectory, IsFile, IsSymbolicLink; + public int IsBlockDevice, IsCharacterDevice, IsNamedPipe, IsSocket; + public int IsSetUid, IsSetGid, IsSticky; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public struct PrivateTm + { + public int Second, Minute, Hour, Day, Month, Year, WeekDay, YearDay, IsDst; + } + + public static class NativeMethods + { + private const string Library = "rust-psl-native-tests"; + private static IntPtr handle; + + public static void Load(string path) + { + handle = NativeLibrary.Load(path); + NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, + (name, assembly, searchPath) => name == Library ? handle : IntPtr.Zero); + // P/Invoke caches function addresses; retain the module until process exit. + } + + public static IntPtr Export(string name) => NativeLibrary.GetExport(handle, name); + + [DllImport(Library)] public static extern int GetCurrentProcessId(); + [DllImport(Library)] public static extern int GetCurrentThreadId(); + [DllImport(Library)] public static extern int GetPPid(int pid); + [DllImport(Library)] public static extern int GetErrorCategory(int error); + [DllImport(Library)] [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string GetComputerName(); + [DllImport(Library)] [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string GetPwUid(uint uid); + [DllImport(Library)] [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string GetGrGid(uint gid); + [DllImport(Library)] [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string GetUserFromPid(int pid); + [DllImport(Library)] [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string GetFileOwner([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + [DllImport(Library)] [return: MarshalAs(UnmanagedType.LPUTF8Str)] + public static extern string FollowSymLink([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + + [DllImport(Library, SetLastError = true)] + public static extern int GetCommonStat([MarshalAs(UnmanagedType.LPUTF8Str)] string path, out CommonStat stat); + [DllImport(Library, SetLastError = true)] + public static extern int GetCommonLStat([MarshalAs(UnmanagedType.LPUTF8Str)] string path, out CommonStat stat); + [DllImport(Library, SetLastError = true)] + public static extern int GetStat([MarshalAs(UnmanagedType.LPUTF8Str)] string path, IntPtr stat); + [DllImport(Library, SetLastError = true)] + public static extern int GetLStat([MarshalAs(UnmanagedType.LPUTF8Str)] string path, IntPtr stat); + [DllImport(Library, SetLastError = true)] + public static extern int GetLinkCount([MarshalAs(UnmanagedType.LPUTF8Str)] string path, out int count); + [DllImport(Library, SetLastError = true)] + public static extern int GetInodeData([MarshalAs(UnmanagedType.LPUTF8Str)] string path, out ulong device, out ulong inode); + [DllImport(Library, SetLastError = true)] + public static extern int CreateHardLink([MarshalAs(UnmanagedType.LPUTF8Str)] string link, [MarshalAs(UnmanagedType.LPUTF8Str)] string target); + [DllImport(Library, SetLastError = true)] + public static extern int CreateSymLink([MarshalAs(UnmanagedType.LPUTF8Str)] string link, [MarshalAs(UnmanagedType.LPUTF8Str)] string target); + + [DllImport(Library, SetLastError = true)] [return: MarshalAs(UnmanagedType.I1)] + public static extern bool IsFile([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + [DllImport(Library, SetLastError = true)] [return: MarshalAs(UnmanagedType.I1)] + public static extern bool IsDirectory([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + [DllImport(Library, SetLastError = true)] [return: MarshalAs(UnmanagedType.I1)] + public static extern bool IsSymLink([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + [DllImport(Library, SetLastError = true)] [return: MarshalAs(UnmanagedType.I1)] + public static extern bool IsExecutable([MarshalAs(UnmanagedType.LPUTF8Str)] string path); + [DllImport(Library, SetLastError = true)] [return: MarshalAs(UnmanagedType.I1)] + public static extern bool IsSameFileSystemItem([MarshalAs(UnmanagedType.LPUTF8Str)] string first, [MarshalAs(UnmanagedType.LPUTF8Str)] string second); + + [DllImport(Library, SetLastError = true)] public static extern int SetDate(IntPtr date); + [DllImport(Library, SetLastError = true)] [return: MarshalAs(UnmanagedType.I1)] + public static extern bool KillProcess(int pid); + [DllImport(Library, SetLastError = true)] + public static extern int WaitPid(int pid, [MarshalAs(UnmanagedType.I1)] bool nohang); + [DllImport(Library, SetLastError = true)] + public static extern int ForkAndExecProcess(IntPtr file, IntPtr argv, IntPtr envp, IntPtr cwd, + int stdin, int stdout, int stderr, int flags, out int child, out int input, out int output, out int error); + [DllImport(Library)] public static extern void Native_OpenLog([MarshalAs(UnmanagedType.LPUTF8Str)] string ident, int facility); + [DllImport(Library)] public static extern void Native_SysLog(int priority, [MarshalAs(UnmanagedType.LPUTF8Str)] string message); + [DllImport(Library)] public static extern void Native_CloseLog(); + [DllImport("libc")] public static extern uint getuid(); + [DllImport("libc")] public static extern uint getgid(); + } +} diff --git a/rust/tests/WindowsLauncher.Tests.ps1 b/rust/tests/WindowsLauncher.Tests.ps1 new file mode 100644 index 0000000..5691faf --- /dev/null +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +param( + [Parameter(Mandatory)] + [string] $ExecutablePath, + [Parameter(Mandatory)] + [ValidateSet('coreclr', 'framework')] + [string] $Configuration, + [Parameter(Mandatory)] + [ValidateSet('x64', 'x86')] + [string] $Architecture +) + +BeforeAll { + function Invoke-Launcher { + param([string] $Path, [string[]] $Arguments) + $process = [System.Diagnostics.Process]::new() + $started = $false + try { + $process.StartInfo.FileName = $Path + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $process.StartInfo.StandardOutputEncoding = [System.Text.Encoding]::Unicode + $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::Unicode + foreach ($argument in $Arguments) { $process.StartInfo.ArgumentList.Add($argument) } + $started = $process.Start() + if (-not $started) { throw 'Launcher did not start.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + throw 'Launcher did not finish within 30 seconds.' + } + @{ + ExitCode = $process.ExitCode + Output = $stdout.GetAwaiter().GetResult() + Error = $stderr.GetAwaiter().GetResult() + } + } finally { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() + } + } +} + +Describe 'Packaged Rust Windows launcher' { + It 'has the requested PE architecture' { + $reader = [System.IO.BinaryReader]::new([System.IO.File]::OpenRead($ExecutablePath)) + try { + $reader.ReadUInt16() | Should -Be 0x5a4d + $reader.BaseStream.Position = 0x3c + $header = $reader.ReadInt32() + $reader.BaseStream.Position = $header + $reader.ReadUInt32() | Should -Be 0x00004550 + $expected = if ($Architecture -eq 'x64') { 0x8664 } else { 0x014c } + $reader.ReadUInt16() | Should -Be $expected + } finally { + $reader.Dispose() + } + } + + It 'contains launcher version resources' { + $version = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($ExecutablePath) + $version.FileVersion | Should -Be '0.1.0' + $version.OriginalFilename | Should -Be 'powershell.exe' + $version.FileDescription | Should -Be 'PowerShell native Rust host' + } + + It 'does not package a Rust WSMan plugin' { + Join-Path (Split-Path $ExecutablePath) 'pwrshplugin.dll' | Should -Not -Exist + } + + if ($Configuration -eq 'coreclr') { + It 'prints native help for without requiring CoreCLR' -ForEach @( + @{ Argument = '-?' } + @{ Argument = '/?' } + ) { + $result = Invoke-Launcher -Path $ExecutablePath -Arguments $Argument + $result.ExitCode | Should -Be -1 -Because "$($result.Output) $($result.Error)" + $result.Output | Should -Match 'USAGE: powershell' + } + + It 'reports a missing runtime instead of succeeding' { + $isolated = Join-Path $TestDrive 'powershell.exe' + Copy-Item $ExecutablePath $isolated + $result = Invoke-Launcher -Path $isolated -Arguments '-NoProfile' + $result.ExitCode | Should -Not -Be 0 + $result.Error | Should -Match 'PowerShell native host error' + } + } else { + It 'starts installed Windows PowerShell and propagates its managed exit code' { + $result = Invoke-Launcher -Path $ExecutablePath -Arguments @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 37' + ) + $result.ExitCode | Should -Be 37 -Because "$($result.Output) $($result.Error)" + } + } +} diff --git a/rust/windows/Cargo.toml b/rust/windows/Cargo.toml new file mode 100644 index 0000000..774c313 --- /dev/null +++ b/rust/windows/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "powershell-native-windows" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true +license = "MIT" +build = "build.rs" +publish = false + +[lints] +workspace = true + +[lib] +name = "powershell_native_host" + +[[bin]] +name = "powershell" +path = "src/main.rs" + +[features] +default = ["coreclr"] +coreclr = [] + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Data_Xml_MsXml", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_Com_Urlmon", + "Win32_UI_Shell", + "Win32_UI_Shell_Common", + "Win32_UI_Shell_PropertiesSystem", +] } diff --git a/rust/windows/README.md b/rust/windows/README.md new file mode 100644 index 0000000..d77bcfa --- /dev/null +++ b/rust/windows/README.md @@ -0,0 +1,113 @@ +# Windows launcher and hosting in Rust + +This package replaces the Windows native **launcher and shared hosting code**. +It builds `powershell.exe` and the internal Rust library `powershell_native_host`. +The WSMan plugin is deliberately **not ported**: `pwrshplugin.dll` and its eleven +exports continue to use the unchanged C++ implementation and original build. +No C/C++ source is compiled as part of this Rust package. + +## Builds + +Use a Visual Studio Developer Command Prompt on Windows with Rust, the MSVC +linker/import libraries, and Windows SDK `mc.exe`/`rc.exe` on PATH. The SDK +include directories must be set by the developer environment. + +```powershell +# CoreCLR launcher (default). +cargo build --manifest-path rust/Cargo.toml -p powershell-native-windows --release --target x86_64-pc-windows-msvc + +# Legacy .NET Framework launcher. +cargo build --manifest-path rust/Cargo.toml -p powershell-native-windows --release --target x86_64-pc-windows-msvc --no-default-features +``` + +Both configurations produce `powershell.exe`; build/deploy them separately. +MSVC x86 (`i686-pc-windows-msvc`) is also supported. ARM64 is not validated; +GNU/MinGW targets are rejected. `build.rs` embeds the original launcher +messages, manifest, and icons from `src/powershell-native/nativemsh/pwrshexe`. +Keep those resource inputs present. The version resource identifies this +Rust package rather than claiming the original Windows product version. + +Cross-`cargo check` from non-Windows hosts explicitly skips resource compilation. +A successful cross-check does not establish Windows linker/runtime parity. + +## Deployment and hosting contracts + +**CoreCLR:** deploy matching `CoreCLR.dll`, managed PowerShell assemblies, and +their runtime dependencies. The host retains `coreclr_initialize`, +`coreclr_create_delegate`, `coreclr_shutdown`, and the existing managed `Start` +entry point; it does not switch existing payloads to hostfxr. +`RemotePowerShellConfig.txt` can provide `PSHOMEDIR=` and `CORECLRDIR=`. +Both paths must exist before environment expansion, as in the original host. +Otherwise the executable directory is used. TPA enumeration retains the +native-DLL exclusion list and system CoreClrPowerShellExt fallback. + +**.NET Framework:** the selected CLR and PowerShell registry configuration must +be installed. The host retains `CorBindToRuntimeEx`, `ICorRuntimeHost`, default +AppDomain activation, Automation invocation, legacy argument processing, +console-file version selection, and Windows PE restrictions. Framework CLR +destruction does not stop the CLR. + +**Shell integration:** shortcut discovery/icons and taskbar administrator/ISE +tasks are retained. Unavailable Explorer integration is nonfatal, including +Server Core, WinPE, and service sessions. + +Assuming a caller runs on .NET 11 does not remove these payload dependencies. +.NET 11 does not replace .NET Framework hosting for legacy managed payloads. + +## Source mapping and migration + +| Original component | Rust implementation | +| --- | --- | +| Shared launcher configuration, registry, version/XML parsing | `src/common.rs`, `src/policy.rs`, `src/win.rs` | +| `ClrHostWrapper`, COM CLR hosting/Automation | `src/hosting.rs` | +| `MainEntry`, `CssMainEntry`, argument processing | `src/launcher.rs`, `src/main.rs` | +| `ShellIntegration` | `src/shell.rs` | +| Launcher message/resource build steps | `build.rs`, referencing original resource inputs | +| `entrypoints`, `PwrshPlugin`, `PwrshPluginMediator`, worker hosting | **Unchanged original C++; excluded from this port** | + +Managed migration comments reference `System.Version`, +`Microsoft.Win32.RegistryKey`, `System.Xml.XmlReader`, `Environment`, `Process`, +`NativeLibrary`, `Directory`/`File`/`Path`, `Console`, and `Win32Exception`. +Legacy native COM CLR hosting and Explorer jump-list COM contracts do not have +direct equivalent managed APIs. + +## Validation + +```powershell +cargo test --manifest-path rust/Cargo.toml -p powershell-native-windows +cargo test --manifest-path rust/Cargo.toml -p powershell-native-windows --no-default-features +cargo clippy --manifest-path rust/Cargo.toml -p powershell-native-windows --all-targets -- -D warnings -D clippy::pedantic +cargo clippy --manifest-path rust/Cargo.toml -p powershell-native-windows --all-targets --no-default-features -- -D warnings -D clippy::pedantic +``` + +Portable tests cover version formats/overflow, registry-family mappings, +parameter abbreviations/Unicode dashes, native TPA exclusions, and configuration +fallback. Windows-only tests cover Automation layout and launcher behavior. +The Windows CI jobs build/link both launcher configurations and architectures +with SDK resources. Pester v5 checks the packaged executable's architecture, +version resources, native CoreCLR help and missing-runtime errors, and installed +Windows PowerShell startup/exit-code propagation through the Framework host: + +```powershell +Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery +./rust/tests/Invoke-Tests.ps1 -ExecutablePath ./rust/target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr -Architecture x64 -ResultPath ./artifacts/test-results/windows.xml +``` + +CI packages these launchers under `artifacts/rust/win--/`, +separate from the existing C++ CoreCLR binaries and WSMan plugin under +`artifacts/cpp/win--coreclr/`. Pester NUnit XML is retained on failures. +The lightweight manual smoke script is also available: + +```powershell +./rust/windows/tests/launcher-smoke.ps1 -ExecutablePath ./rust/target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr +# After building the Framework configuration: +./rust/windows/tests/launcher-smoke.ps1 -ExecutablePath ./rust/target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration framework +``` + +CoreCLR managed startup still requires Windows runtime verification with +matching managed payloads; Explorer integration requires a desktop session. + +Explicit registry version requests are honored rather than being cleared before +lookup, and Automation arrays carry their proper `VT_ARRAY | VT_BSTR` tag. +Diagnostics without insertion arguments use literal message templates or a +hexadecimal error fallback rather than unsafe missing-argument substitution. diff --git a/rust/windows/build.rs b/rust/windows/build.rs new file mode 100644 index 0000000..b08ef30 --- /dev/null +++ b/rust/windows/build.rs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::Command, +}; + +fn execute(tool: &str, arguments: &[&std::ffi::OsStr], directory: &Path) { + let status = Command::new(tool) + .args(arguments) + .current_dir(directory) + .status() + .unwrap_or_else(|e| panic!("cannot run Windows SDK tool {tool}: {e}")); + assert!(status.success(), "{tool} failed with {status}"); +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + assert!( + env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"), + "Windows builds require the MSVC ABI and Windows SDK resource tools" + ); + if !cfg!(windows) { + println!( + "cargo:warning=cross-check only: Windows resources require mc.exe/rc.exe on Windows" + ); + return; + } + let source = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()) + .join("../../src/powershell-native/nativemsh/pwrshexe") + .canonicalize() + .unwrap(); + println!("cargo:rerun-if-changed={}", source.display()); + let out = PathBuf::from(env::var_os("OUT_DIR").unwrap()); + execute( + "mc.exe", + &[ + std::ffi::OsStr::new("-h"), + out.as_os_str(), + std::ffi::OsStr::new("-r"), + out.as_os_str(), + source.join("NativeMsh.mc").as_os_str(), + ], + &out, + ); + let mut resource = fs::read_to_string(source.join("MshResources.rc")).unwrap(); + resource = resource.replace("#include \"version.rc\"", ""); + for (name, actual) in [ + ("powershell.exe.manifest", "PowerShell.exe.manifest"), + ("monad.ico", "Monad.ico"), + ( + "powershell_securitybadge.ico", + "PowerShell_securitybadge.ico", + ), + ] { + resource = resource.replace( + &format!("\"{name}\""), + &format!( + "\"{}\"", + source + .join(actual) + .display() + .to_string() + .replace('\\', "\\\\") + ), + ); + } + resource = resource.replace("\"nativemsh.rc\"", "\"NativeMsh.rc\""); + resource.push_str( + r#" +1 VERSIONINFO + FILEVERSION 0,1,0,0 + PRODUCTVERSION 0,1,0,0 + FILEFLAGSMASK 0x3fL + FILEFLAGS 0 + FILEOS 0x40004L + FILETYPE 1 + FILESUBTYPE 0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "FileDescription", "PowerShell native Rust host\0" + VALUE "FileVersion", "0.1.0\0" + VALUE "InternalName", "powershell\0" + VALUE "OriginalFilename", "powershell.exe\0" + VALUE "ProductName", "PowerShell\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +"#, + ); + let rc = out.join("powershell.rc"); + let res = out.join("powershell.res"); + fs::write(&rc, resource).unwrap(); + execute( + "rc.exe", + &[ + std::ffi::OsStr::new("/nologo"), + std::ffi::OsStr::new("/fo"), + res.as_os_str(), + rc.as_os_str(), + ], + &out, + ); + println!("cargo:rustc-link-arg-bin=powershell={}", res.display()); +} diff --git a/rust/windows/src/common.rs b/rust/windows/src/common.rs new file mode 100644 index 0000000..2893f79 --- /dev/null +++ b/rust/windows/src/common.rs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{ + policy::{ + exclude_tpa, host_config, installed_version, Version, CONSOLE_FAILURE, + INCOMPATIBLE_VERSION, INIT_FAILURE, REGISTRY_FAILURE, + }, + win::{ + expand, module_path, status_code, wide, ComApartment, Ptr, RegCloseKey, RegEnumKeyExW, + RegOpenKeyExW, RegQueryValueExW, Result, + }, +}; +use std::{ + path::{Path, PathBuf}, + ptr, +}; +use windows::{ + core::{BSTR, VARIANT}, + Win32::{ + Data::Xml::MsXml::{DOMDocument60, IXMLDOMDocument}, + System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER}, + }, +}; + +const ROOT: &str = r"SOFTWARE\Microsoft\PowerShell"; +const HKLM: Ptr = -2_147_483_646isize as Ptr; + +pub struct RegistryKey(Ptr); +impl RegistryKey { + /// Managed callers should use Microsoft.Win32.RegistryKey.OpenSubKey/GetValue. + /// + /// # Errors + /// Returns the native registry error if the key cannot be opened. + pub fn open(path: &str) -> Result { + let mut handle = ptr::null_mut(); + let error = + unsafe { RegOpenKeyExW(HKLM, wide(path).as_ptr(), 0, 0x20019, &raw mut handle) }; + if error == 0 { + Ok(Self(handle)) + } else { + Err(status_code(error)) + } + } + + /// # Errors + /// Returns a registry error for missing, invalid, or unreadable string values. + pub fn string(&self, name: &str) -> Result { + self.read_string(name, false) + } + + /// # Errors + /// Returns a registry error for missing, invalid, or concurrently changing values. + pub fn read_string(&self, name: &str, allow_expand: bool) -> Result { + let name = wide(name); + for _ in 0..4 { + let mut kind = 0; + let mut bytes = 0; + let error = unsafe { + RegQueryValueExW( + self.0, + name.as_ptr(), + ptr::null_mut(), + &raw mut kind, + ptr::null_mut(), + &raw mut bytes, + ) + }; + if error != 0 { + return Err(status_code(error)); + } + if kind != 1 && !(allow_expand && kind == 2) { + return Err(13); + } + if bytes == 0 || bytes % 2 != 0 { + return Err(13); + } + let mut buffer = vec![0u16; bytes as usize / 2 + 1]; + let error = unsafe { + RegQueryValueExW( + self.0, + name.as_ptr(), + ptr::null_mut(), + &raw mut kind, + buffer.as_mut_ptr().cast(), + &raw mut bytes, + ) + }; + if error == 234 { + continue; + } + if error != 0 { + return Err(status_code(error)); + } + if kind != 1 && !(allow_expand && kind == 2) { + return Err(13); + } + let length = buffer.iter().position(|c| *c == 0).ok_or(13u32)?; + if length == 0 { + return Err(13); + } + return Ok(String::from_utf16_lossy(&buffer[..length])); + } + Err(234) + } + + /// # Errors + /// Returns the native registry error if enumeration fails. + pub fn names(&self) -> Result> { + let mut result = Vec::new(); + let mut index = 0; + loop { + let mut buffer = [0; 256]; + let mut length = 256; + let error = unsafe { + RegEnumKeyExW( + self.0, + index, + buffer.as_mut_ptr(), + &raw mut length, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + if error == 259 { + return Ok(result); + } + if error != 0 { + return Err(status_code(error)); + } + result.push(String::from_utf16_lossy(&buffer[..length as usize])); + index += 1; + } + } +} +impl Drop for RegistryKey { + fn drop(&mut self) { + unsafe { + RegCloseKey(self.0); + } + } +} + +pub struct Engine { + pub version: Version, + pub version_text: String, + pub runtime: String, + pub key: RegistryKey, +} +impl Engine { + /// # Errors + /// Returns a host error for missing, incompatible, or malformed engine registration. + pub fn open(requested: Option) -> Result { + let selected = if let Some(version) = requested { + version + } else { + let key = RegistryKey::open(ROOT).map_err(|_| REGISTRY_FAILURE)?; + key.names() + .map_err(|_| REGISTRY_FAILURE)? + .iter() + .filter_map(|name| Version::parse(name, false)) + .filter(|version| version.major > 0) + .max_by_key(|version| version.major) + .ok_or(REGISTRY_FAILURE)? + }; + let path = format!(r"{ROOT}\{}", selected.registry_major()); + RegistryKey::open(&path).map_err(|e| { + if e == 2 { + INCOMPATIBLE_VERSION + } else { + REGISTRY_FAILURE + } + })?; + let key = RegistryKey::open(&format!(r"{path}\PowerShellEngine")) + .map_err(|_| REGISTRY_FAILURE)?; + let version_text = key + .string("PowerShellVersion") + .map_err(|_| REGISTRY_FAILURE)?; + let version = installed_version(&version_text).ok_or(REGISTRY_FAILURE)?; + if selected + .minor + .zip(version.minor) + .is_some_and(|(wanted, installed)| wanted > installed) + { + return Err(INCOMPATIBLE_VERSION); + } + let runtime = key.string("RuntimeVersion").map_err(|_| REGISTRY_FAILURE)?; + Ok(Self { + version, + version_text, + runtime, + key, + }) + } + /// # Errors + /// Returns a host error if the engine's string value cannot be read. + pub fn value(&self, name: &str) -> Result { + self.key.string(name).map_err(|_| REGISTRY_FAILURE) + } + /// # Errors + /// Returns a host error if the registered Framework installation is unavailable. + pub fn validate_framework_installation(&self) -> Result<()> { + if self + .key + .string("NetFrameworkV4IsInstalled") + .is_ok_and(|value| value.starts_with("No")) + { + Err(INIT_FAILURE) + } else { + Ok(()) + } + } +} + +pub struct HostEnvironment { + pub module: PathBuf, + pub assemblies: PathBuf, + pub runtime: PathBuf, +} +impl HostEnvironment { + /// # Errors + /// Returns a native error if the executable directory cannot be determined. + pub fn discover() -> Result { + let module = module_path(ptr::null_mut())?; + let directory = module.parent().ok_or(INIT_FAILURE)?.to_path_buf(); + let (assemblies, runtime) = + read_config(&directory).unwrap_or_else(|_| (directory.clone(), directory)); + Ok(Self { + module, + assemblies, + runtime, + }) + } + + /// Managed callers should use Directory.EnumerateFiles and Path.Combine. + /// + /// # Errors + /// Returns a host error if no trusted assemblies can be located. + pub fn trusted_assemblies(&self) -> Result { + let collect = |directory: &Path| -> Vec { + std::fs::read_dir(directory) + .into_iter() + .flatten() + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + if entry.file_type().ok()?.is_file() + && name.to_ascii_lowercase().ends_with(".dll") + && !exclude_tpa(&name) + { + Some(path) + } else { + None + } + }) + .collect() + }; + let mut paths = collect(&self.runtime); + if paths.is_empty() { + paths = collect(Path::new(&expand( + r"%windir%\system32\CoreClrPowerShellExt\v1.0\", + )?)); + } + if paths.is_empty() { + return Err(INIT_FAILURE); + } + Ok(paths + .iter() + .map(|p| p.to_string_lossy()) + .collect::>() + .join(";")) + } +} + +/// Managed callers should use File.ReadLines, Directory.Exists and Environment.ExpandEnvironmentVariables. +fn read_config(directory: &Path) -> Result<(PathBuf, PathBuf)> { + let text = std::fs::read_to_string(directory.join("RemotePowerShellConfig.txt")) + .map_err(|_| INIT_FAILURE)?; + // The original checks existence before expanding environment variables. + let (assemblies, runtime) = + host_config(&text, |value| Path::new(value).exists()).ok_or(INIT_FAILURE)?; + Ok(( + PathBuf::from(expand(&assemblies)?), + PathBuf::from(expand(&runtime)?), + )) +} + +/// Managed callers should use System.Xml.XmlReader with DtdProcessing.Prohibit. +/// +/// # Errors +/// Returns a COM or host error if document creation or parsing fails. +pub fn xml_document(source: &str, file: bool) -> Result { + unsafe { + let document: IXMLDOMDocument = + CoCreateInstance(&DOMDocument60, None, CLSCTX_INPROC_SERVER) + .map_err(|e| status_code(e.code().0))?; + document + .Setasync(windows::Win32::Foundation::VARIANT_BOOL(0)) + .map_err(|e| status_code(e.code().0))?; + let loaded = if file { + document.load(&VARIANT::from(source)) + } else { + document.loadXML(&BSTR::from(source)) + } + .map_err(|e| status_code(e.code().0))?; + if !loaded.as_bool() { + return Err(CONSOLE_FAILURE); + } + Ok(document) + } +} + +/// # Errors +/// Returns a console-file error if the requested XML node cannot be read. +pub fn xml_value(document: &IXMLDOMDocument, query: &str) -> Result { + unsafe { + document + .selectSingleNode(&BSTR::from(query)) + .and_then(|node| node.text()) + .map(|s| s.to_string()) + .map_err(|_| CONSOLE_FAILURE) + } +} + +/// # Errors +/// Returns a console-file error for invalid paths, XML, schemas, or versions. +pub fn console_version(path: &str) -> Result { + if !path.to_ascii_lowercase().ends_with(".psc1") { + return Err(CONSOLE_FAILURE); + } + let _apartment = ComApartment::new(true, false).map_err(|_| CONSOLE_FAILURE)?; + let document = xml_document(path, true).map_err(|_| CONSOLE_FAILURE)?; + if xml_value(&document, "/PSConsoleFile/@ConsoleSchemaVersion")? != "1.0" { + return Err(CONSOLE_FAILURE); + } + Version::parse( + &xml_value(&document, "/PSConsoleFile/PSVersion/text()")?, + true, + ) + .ok_or(CONSOLE_FAILURE) +} diff --git a/rust/windows/src/hosting.rs b/rust/windows/src/hosting.rs new file mode 100644 index 0000000..c5a302c --- /dev/null +++ b/rust/windows/src/hosting.rs @@ -0,0 +1,553 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{ + common::HostEnvironment, + policy::INIT_FAILURE, + win::{output, status_code, wide, wide_slice, Module, Ptr, Result, SetErrorMode, Wide}, +}; +use std::{ffi::CString, mem, ptr}; +use windows::core::GUID; + +pub const CONSOLE_ASSEMBLY: &str = "Microsoft.PowerShell.ConsoleHost, Version=3.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"; + +type Initialize = unsafe extern "system" fn( + *const u8, + *const u8, + i32, + *const *const u8, + *const *const u8, + *mut Ptr, + *mut u32, +) -> i32; +type Shutdown = unsafe extern "system" fn(Ptr, u32) -> i32; +type CreateDelegate = + unsafe extern "system" fn(Ptr, u32, *const u8, *const u8, *const u8, *mut Ptr) -> i32; + +pub struct CoreClr { + _module: Module, + handle: Ptr, + domain: u32, + shutdown: Shutdown, + create_delegate: CreateDelegate, + stopped: bool, +} +impl CoreClr { + /// # Errors + /// Returns a native or hosting error if loading or initializing `CoreCLR` fails. + pub fn launch(environment: &HostEnvironment, friendly_name: &str) -> Result { + let path = environment.runtime.join("CoreCLR.dll"); + let module = Module::load(&path)?; + module.pin()?; + let initialize: Initialize = + unsafe { mem::transmute(module.symbol(c"coreclr_initialize")?) }; + let shutdown: Shutdown = unsafe { mem::transmute(module.symbol(c"coreclr_shutdown")?) }; + let create_delegate: CreateDelegate = + unsafe { mem::transmute(module.symbol(c"coreclr_create_delegate")?) }; + let keys = [ + c"TRUSTED_PLATFORM_ASSEMBLIES".as_ptr().cast::(), + c"APP_PATHS".as_ptr().cast(), + c"APP_NI_PATHS".as_ptr().cast(), + ]; + let assemblies = + CString::new(environment.trusted_assemblies()?).map_err(|_| INIT_FAILURE)?; + let values = [ + assemblies.as_ptr().cast::(), + c"".as_ptr().cast(), + c"".as_ptr().cast(), + ]; + let mut executable = environment.assemblies.to_string_lossy().into_owned(); + if !executable.ends_with('\\') { + executable.push('\\'); + } + let executable = CString::new(executable).map_err(|_| INIT_FAILURE)?; + let friendly_name = CString::new(friendly_name).map_err(|_| INIT_FAILURE)?; + let mut handle = ptr::null_mut(); + let mut domain = 0; + let hr = unsafe { + initialize( + executable.as_ptr().cast(), + friendly_name.as_ptr().cast(), + 3, + keys.as_ptr(), + values.as_ptr(), + &raw mut handle, + &raw mut domain, + ) + }; + if hr < 0 { + return Err(status_code(hr)); + } + Ok(Self { + _module: module, + handle, + domain, + shutdown, + create_delegate, + stopped: false, + }) + } + + /// # Errors + /// Returns a hosting error if the managed entry point cannot be resolved. + pub fn delegate(&self, assembly: &str, class: &str, method: &str) -> Result { + let assembly = CString::new(assembly).map_err(|_| INIT_FAILURE)?; + let class = CString::new(class).map_err(|_| INIT_FAILURE)?; + let method = CString::new(method).map_err(|_| INIT_FAILURE)?; + let mut result = ptr::null_mut(); + let hr = unsafe { + (self.create_delegate)( + self.handle, + self.domain, + assembly.as_ptr().cast(), + class.as_ptr().cast(), + method.as_ptr().cast(), + &raw mut result, + ) + }; + if hr < 0 { + Err(status_code(hr)) + } else if result.is_null() { + Err(INIT_FAILURE) + } else { + Ok(result) + } + } + + /// # Errors + /// Returns the HRESULT if `CoreCLR` cannot shut down. + pub fn shutdown(&mut self) -> Result<()> { + if self.stopped { + return Ok(()); + } + let hr = unsafe { (self.shutdown)(self.handle, self.domain) }; + if hr < 0 { + return Err(status_code(hr)); + } + self.stopped = true; + Ok(()) + } +} +impl Drop for CoreClr { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} + +#[link(name = "mscoree")] +extern "system" { + fn CorBindToRuntimeEx( + version: Wide, + flavor: Wide, + flags: u32, + class: *const GUID, + interface: *const GUID, + result: *mut Ptr, + ) -> i32; +} +#[link(name = "oleaut32")] +extern "system" { + fn SysAllocStringLen(text: Wide, length: u32) -> *mut u16; + fn SysFreeString(text: *mut u16); + fn VariantClear(variant: *mut AutomationVariant) -> i32; + fn SafeArrayCreate(kind: u16, dimensions: u32, bounds: *const ArrayBound) -> Ptr; + fn SafeArrayPutElement(array: Ptr, indices: *const i32, value: Ptr) -> i32; + fn SafeArrayDestroy(array: Ptr) -> i32; +} + +#[repr(C)] +struct ArrayBound { + elements: u32, + lower_bound: i32, +} + +#[repr(C)] +pub union VariantValue { + pub pointer: Ptr, + pub signed: i64, + pub int: i32, + pub uint: u32, + layout: [usize; 2], +} +#[repr(C)] +pub struct AutomationVariant { + pub kind: u16, + reserved: [u16; 3], + pub value: VariantValue, +} +impl Default for AutomationVariant { + fn default() -> Self { + Self { + kind: 0, + reserved: [0; 3], + value: VariantValue { layout: [0; 2] }, + } + } +} +impl Drop for AutomationVariant { + fn drop(&mut self) { + unsafe { + VariantClear(self); + } + } +} +impl AutomationVariant { + /// # Errors + /// Returns a host error if the string is too large or cannot be allocated. + pub fn bstr(text: Option<&[u16]>) -> Result { + let string = if let Some(text) = text { + let length = u32::try_from(text.len()).map_err(|_| INIT_FAILURE)?; + let string = unsafe { SysAllocStringLen(text.as_ptr(), length) }; + if string.is_null() { + return Err(0x8007_000e); + } + string + } else { + ptr::null_mut() + }; + Ok(Self { + kind: 8, + reserved: [0; 3], + value: VariantValue { + pointer: string.cast(), + }, + }) + } + /// Managed callers should pass string[] directly; no SAFEARRAY marshaling is necessary. + /// + /// # Errors + /// Returns a host or Automation error if array conversion or allocation fails. + pub fn strings(arguments: &[Vec]) -> Result { + let count = i32::try_from(arguments.len()).map_err(|_| INIT_FAILURE)?; + let bounds = ArrayBound { + elements: count.unsigned_abs(), + lower_bound: 0, + }; + let array = unsafe { SafeArrayCreate(8, 1, &raw const bounds) }; + if array.is_null() { + return Err(0x8007_000e); + } + for (index, argument) in (0..count).zip(arguments) { + let length = argument + .iter() + .position(|c| *c == 0) + .unwrap_or(argument.len()); + let value = match Self::bstr(Some(&argument[..length])) { + Ok(value) => value, + Err(error) => { + unsafe { + SafeArrayDestroy(array); + } + return Err(error); + } + }; + let hr = unsafe { SafeArrayPutElement(array, &raw const index, value.value.pointer) }; + if hr < 0 { + unsafe { + SafeArrayDestroy(array); + } + return Err(status_code(hr)); + } + } + Ok(Self { + kind: 0x2008, + reserved: [0; 3], + value: VariantValue { pointer: array }, + }) + } + /// # Errors + /// Returns a type mismatch for a non-integer Automation result. + pub fn int_result(&self) -> Result { + unsafe { + match self.kind { + 3 | 19 | 22 | 23 => Ok(self.value.uint), + _ => Err(0x8002_0005), + } + } + } + /// # Errors + /// Returns a COM error if the variant does not contain a dispatch object. + pub fn dispatch(&self) -> Result { + if self.kind != 9 && self.kind != 13 { + return Err(0x8002_0005); + } + let object = ComObject(unsafe { self.value.pointer }); + if object.0.is_null() { + return Err(INIT_FAILURE); + } + let result = object.query(&IID_DISPATCH); + mem::forget(object); // The variant owns its original reference. + result + } +} + +#[repr(C)] +struct DispatchParameters { + arguments: *mut AutomationVariant, + named_arguments: *mut i32, + count: u32, + named_count: u32, +} +#[repr(C)] +#[derive(Default)] +struct ExceptionInfo { + code: u16, + reserved: u16, + source: *mut u16, + description: *mut u16, + help: *mut u16, + help_context: u32, + reserved_pointer: Ptr, + deferred: Ptr, + status: i32, +} +impl Drop for ExceptionInfo { + fn drop(&mut self) { + unsafe { + SysFreeString(self.source); + SysFreeString(self.description); + SysFreeString(self.help); + } + } +} + +const IID_DISPATCH: GUID = GUID::from_u128(0x00020400_0000_0000_c000_000000000046); +const IID_APPDOMAIN: GUID = GUID::from_u128(0x05f696dc_2b29_3663_ad8b_c4389cf2a713); +const CLSID_COR_RUNTIME: GUID = GUID::from_u128(0xcb2f6723_ab3a_11d2_9c40_00c04fa30a3e); +const IID_COR_RUNTIME: GUID = GUID::from_u128(0xcb2f6722_ab3a_11d2_9c40_00c04fa30a3e); +const IID_NULL: GUID = GUID::zeroed(); + +pub struct ComObject(Ptr); +impl ComObject { + unsafe fn slot(&self, index: usize) -> Ptr { + *(*(self.0 as *const *const Ptr)).add(index) + } + /// # Errors + /// Returns the HRESULT if the requested COM interface is unavailable. + pub fn query(&self, iid: &GUID) -> Result { + let query: unsafe extern "system" fn(Ptr, *const GUID, *mut Ptr) -> i32 = + unsafe { mem::transmute(self.slot(0)) }; + let mut result = ptr::null_mut(); + let hr = unsafe { query(self.0, iid, &raw mut result) }; + if hr < 0 { + Err(status_code(hr)) + } else if result.is_null() { + Err(INIT_FAILURE) + } else { + Ok(Self(result)) + } + } + + /// # Errors + /// Returns a COM or Automation error if name resolution or invocation fails. + pub fn invoke( + &self, + name: &str, + arguments: &mut [AutomationVariant], + ) -> Result { + let get_id: unsafe extern "system" fn( + Ptr, + *const GUID, + *const Wide, + u32, + u32, + *mut i32, + ) -> i32 = unsafe { mem::transmute(self.slot(5)) }; + let invoke: unsafe extern "system" fn( + Ptr, + i32, + *const GUID, + u32, + u16, + *mut DispatchParameters, + *mut AutomationVariant, + *mut ExceptionInfo, + *mut u32, + ) -> i32 = unsafe { mem::transmute(self.slot(6)) }; + let name = wide(name); + let mut id = 0; + let hr = unsafe { get_id(self.0, &IID_NULL, &name.as_ptr(), 1, 0x800, &raw mut id) }; + if hr < 0 { + return Err(status_code(hr)); + } + let mut parameters = DispatchParameters { + arguments: arguments.as_mut_ptr(), + named_arguments: ptr::null_mut(), + count: u32::try_from(arguments.len()).map_err(|_| INIT_FAILURE)?, + named_count: 0, + }; + let mut result = AutomationVariant::default(); + let mut exception = ExceptionInfo::default(); + let mut argument_error = 0; + let hr = unsafe { + invoke( + self.0, + id, + &IID_NULL, + 0x800, + 1, + &raw mut parameters, + &raw mut result, + &raw mut exception, + &raw mut argument_error, + ) + }; + if hr < 0 { + if !exception.deferred.is_null() { + let fill: unsafe extern "system" fn(*mut ExceptionInfo) -> i32 = + unsafe { mem::transmute(exception.deferred) }; + unsafe { + fill(&raw mut exception); + } + } + if !exception.description.is_null() { + let description = unsafe { wide_slice(exception.description) }; + output(description, false); + } + Err(status_code(hr)) + } else { + Ok(result) + } + } +} +impl Drop for ComObject { + fn drop(&mut self) { + let release: unsafe extern "system" fn(Ptr) -> u32 = + unsafe { mem::transmute(self.slot(2)) }; + unsafe { + release(self.0); + } + } +} + +pub struct FrameworkClr { + host: ComObject, +} +impl FrameworkClr { + /// # Errors + /// Returns the HRESULT if binding or starting the selected Framework CLR fails. + pub fn launch(runtime: &str) -> Result { + let mut host = ptr::null_mut(); + unsafe { + SetErrorMode(1); + } + let hr = unsafe { + CorBindToRuntimeEx( + wide(runtime).as_ptr(), + wide("wks").as_ptr(), + 4, + &CLSID_COR_RUNTIME, + &IID_COR_RUNTIME, + &raw mut host, + ) + }; + if hr < 0 { + return Err(status_code(hr)); + } + if host.is_null() { + return Err(INIT_FAILURE); + } + let host = ComObject(host); + let start: unsafe extern "system" fn(Ptr) -> i32 = unsafe { mem::transmute(host.slot(10)) }; + let hr = unsafe { start(host.0) }; + if hr < 0 { + return Err(status_code(hr)); + } + Ok(Self { host }) + } + + /// # Errors + /// Returns a COM or Automation error if managed object activation fails. + pub fn instance(&self, assembly: &str, class: &str) -> Result { + let get_domain: unsafe extern "system" fn(Ptr, *mut Ptr) -> i32 = + unsafe { mem::transmute(self.host.slot(13)) }; + let mut domain = ptr::null_mut(); + let hr = unsafe { get_domain(self.host.0, &raw mut domain) }; + if hr < 0 { + return Err(status_code(hr)); + } + if domain.is_null() { + return Err(INIT_FAILURE); + } + let domain = ComObject(domain).query(&IID_APPDOMAIN)?; + // _AppDomain is InterfaceIsIUnknown, NOT IDispatch. Slots 3..6 are its explicit + // dispatch compatibility members; CreateInstance(string,string) is slot 37. + // Contract: .NET Framework mscorlib System._AppDomain (iappdomain.cs). + let create: unsafe extern "system" fn(Ptr, *mut u16, *mut u16, *mut Ptr) -> i32 = + unsafe { mem::transmute(domain.slot(37)) }; + let assembly = AutomationVariant::bstr(Some(&assembly.encode_utf16().collect::>()))?; + let class = AutomationVariant::bstr(Some(&class.encode_utf16().collect::>()))?; + let mut handle = ptr::null_mut(); + let hr = unsafe { + create( + domain.0, + assembly.value.pointer.cast(), + class.value.pointer.cast(), + &raw mut handle, + ) + }; + if hr < 0 { + return Err(status_code(hr)); + } + if handle.is_null() { + return Err(INIT_FAILURE); + } + let handle = ComObject(handle).query(&IID_DISPATCH)?; + let unwrapped = handle.invoke("Unwrap", &mut [])?; + unwrapped.dispatch() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn automation_layout() { + assert_eq!( + mem::size_of::(), + if cfg!(target_pointer_width = "64") { + 24 + } else { + 16 + } + ); + assert_eq!(mem::offset_of!(AutomationVariant, value), 8); + } + + #[test] + fn automation_string_array_preserves_utf16_and_bounds() { + #[link(name = "oleaut32")] + extern "system" { + fn SafeArrayGetDim(array: Ptr) -> u32; + fn SafeArrayGetLBound(array: Ptr, dimension: u32, bound: *mut i32) -> i32; + fn SafeArrayGetUBound(array: Ptr, dimension: u32, bound: *mut i32) -> i32; + fn SafeArrayGetElement(array: Ptr, index: *const i32, value: Ptr) -> i32; + } + let arguments = [wide("first"), wide(""), wide("\u{03bb}\u{1f600}")]; + let variant = AutomationVariant::strings(&arguments).unwrap(); + assert_eq!(variant.kind, 0x2008); + unsafe { + let array = variant.value.pointer; + assert_eq!(SafeArrayGetDim(array), 1); + let mut bound = -1; + assert_eq!(SafeArrayGetLBound(array, 1, &raw mut bound), 0); + assert_eq!(bound, 0); + assert_eq!(SafeArrayGetUBound(array, 1, &raw mut bound), 0); + assert_eq!(bound, 2); + for (index, expected) in arguments.iter().enumerate() { + let mut value: *mut u16 = ptr::null_mut(); + assert_eq!( + SafeArrayGetElement( + array, + &i32::try_from(index).unwrap(), + (&raw mut value).cast() + ), + 0 + ); + let actual = wide_slice(value).to_vec(); + SysFreeString(value); + assert_eq!(actual, expected[..expected.len() - 1]); + } + } + } +} diff --git a/rust/windows/src/launcher.rs b/rust/windows/src/launcher.rs new file mode 100644 index 0000000..4a70dc2 --- /dev/null +++ b/rust/windows/src/launcher.rs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::win::{message, output, wide, GetModuleHandleW, Result, SetThreadUILanguage}; +#[cfg(feature = "coreclr")] +use crate::{ + common::HostEnvironment, + hosting::{CoreClr, CONSOLE_ASSEMBLY}, + policy::INIT_FAILURE, + win::{status_code, Wide}, +}; +#[cfg(not(feature = "coreclr"))] +use crate::{ + common::{console_version, Engine, RegistryKey}, + hosting::{AutomationVariant, FrameworkClr}, + policy::{parameter_matches, Version, BAD_COMMAND_LINE}, + win::ComApartment, +}; +#[cfg(feature = "coreclr")] +use std::mem; +use std::{os::windows::ffi::OsStrExt, ptr}; + +#[must_use] +pub fn run() -> u32 { + // Managed callers should use Environment.GetCommandLineArgs. + let arguments: Vec> = std::env::args_os() + .skip(1) + .map(|arg| arg.encode_wide().chain(Some(0)).collect()) + .collect(); + unsafe { + SetThreadUILanguage(0); + } + #[cfg(feature = "coreclr")] + let result = core(&arguments); + #[cfg(not(feature = "coreclr"))] + let result = framework(&arguments); + match result { + Ok(code) => code, + Err(code) => { + let module = unsafe { GetModuleHandleW(ptr::null()) }; + output(&message(module, code, &[]), false); + code + } + } +} + +fn text(argument: &[u16]) -> String { + String::from_utf16_lossy( + &argument[..argument + .iter() + .position(|c| *c == 0) + .unwrap_or(argument.len())], + ) +} + +#[cfg(feature = "coreclr")] +fn core(arguments: &[Vec]) -> Result { + let mut index = 0; + let mut verbose = false; + let mut debug = false; + let mut help = false; + while let Some(argument) = arguments.get(index) { + let argument = text(argument).to_ascii_lowercase(); + let matches = |name: &str| name.starts_with(&argument); + let skip_value; + if matches("-verbose") { + verbose = true; + skip_value = false; + } else if argument == "/?" || argument == "-?" { + help = true; + skip_value = false; + } else if matches("-debug") { + debug = true; + skip_value = false; + } else if argument == "-version" || matches("-psconsolefile") || matches("-runtimeversion") + { + skip_value = true; + } else { + break; + } + index += 1; + if skip_value && index < arguments.len() { + index += 1; + } + } + if debug { + output( + &wide("Attach the debugger to powershell.exe and press Enter to continue\r\n"), + true, + ); + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(|_| INIT_FAILURE)?; + } + if help { + output(&wide("USAGE: powershell [-Verbose] [-Debug] [-File ] [-Command] \r\n\r\nCoreCLR is loaded beside powershell.exe or from RemotePowerShellConfig.txt.\r\n"), true); + return Ok(u32::MAX); + } + let environment = HostEnvironment::discover()?; + let mut host = CoreClr::launch(&environment, "powershell")?; + let start: unsafe extern "system" fn(Wide, *const Wide, i32) -> i32 = unsafe { + mem::transmute(host.delegate( + CONSOLE_ASSEMBLY, + "Microsoft.PowerShell.UnmanagedPSEntry", + "Start", + )?) + }; + let managed: Vec = arguments[index..] + .iter() + .map(std::vec::Vec::as_ptr) + .collect(); + let count = i32::try_from(managed.len()).map_err(|_| INIT_FAILURE)?; + let code = status_code(unsafe { start(ptr::null(), managed.as_ptr(), count) }); + host.shutdown()?; + if verbose { + output(&wide("Execution succeeded\r\n"), true); + } + Ok(code) +} + +#[cfg(not(feature = "coreclr"))] +#[derive(Default)] +struct LegacyOptions { + version: Option, + runtime: Option, + console: Option>, + skip: usize, +} + +#[cfg(not(feature = "coreclr"))] +fn legacy_options(arguments: &[Vec]) -> Result { + let mut options = LegacyOptions::default(); + let mut index = 0; + let mut explicit_version = None; + if arguments + .first() + .is_some_and(|arg| parameter_matches(&text(arg), "version")) + { + index += 1; + let version = arguments.get(index).ok_or(BAD_COMMAND_LINE)?; + explicit_version = Some(Version::parse(&text(version), true).ok_or(BAD_COMMAND_LINE)?); + options.version = explicit_version; + index += 1; + options.skip = index; + } + let server = explicit_version.is_none() + && arguments.get(index).is_some_and(|arg| { + parameter_matches(&text(arg), "servermode") || parameter_matches(&text(arg), "s") + }); + if server { + options.version = Some(Version { + major: 2, + minor: None, + }); + // Keep -ServerMode and everything following it for the managed parser. + return Ok(options); + } + if arguments + .get(index) + .is_some_and(|arg| parameter_matches(&text(arg), "runtimeversion")) + { + index += 1; + options.runtime = Some(text(arguments.get(index).ok_or(BAD_COMMAND_LINE)?)); + index += 1; + options.skip = index; + } + if arguments + .get(index) + .is_some_and(|arg| parameter_matches(&text(arg), "psconsolefile")) + { + index += 1; + let console = arguments.get(index).ok_or(BAD_COMMAND_LINE)?; + let version = console_version(&text(console))?; + options.console = Some(console.clone()); + options.version = explicit_version.or(Some(version)); + index += 1; + options.skip = index; + } + if explicit_version.is_none() { + // The inbox host defaults to the v3 registry family, even with -PSConsoleFile. + options.version = Some(Version { + major: 3, + minor: None, + }); + } + Ok(options) +} + +#[cfg(not(feature = "coreclr"))] +fn framework(arguments: &[Vec]) -> Result { + // Shell registration is opportunistic in the original host too; an unavailable + // explorer shell (Server Core, WinPE, service session) must not prevent startup. + let _ = crate::shell::initialize(); + let options = legacy_options(arguments)?; + if options.version.is_some_and(|v| v.major <= 2) + && RegistryKey::open(r"SYSTEM\CurrentControlSet\Control\MiniNT").is_ok() + { + return Err(35); + } + let engine = Engine::open(options.version)?; + engine.validate_framework_installation()?; + let runtime = options.runtime.as_deref().unwrap_or(&engine.runtime); + if options.runtime.is_some() { + let module = unsafe { GetModuleHandleW(ptr::null()) }; + let runtime = wide(runtime); + output(&message(module, 36, &[runtime.as_ptr() as usize]), false); + } + let _apartment = ComApartment::new(true, true)?; + let host = FrameworkClr::launch(runtime)?; + let instance = host.instance( + &engine.value("ConsoleHostAssemblyName")?, + "Microsoft.PowerShell.UnmanagedPSEntry", + )?; + let console = options + .console + .as_ref() + .map(|value| &value[..value.len() - 1]); + // IDispatch argument order is reversed: string[] then the console file. + let mut parameters = [ + AutomationVariant::strings(&arguments[options.skip..])?, + AutomationVariant::bstr(console)?, + ]; + instance.invoke("Start", &mut parameters)?.int_result() +} + +#[cfg(all(test, not(feature = "coreclr")))] +mod tests { + use super::*; + + fn arguments(values: &[&str]) -> Vec> { + values.iter().map(|value| wide(value)).collect() + } + + #[test] + fn framework_keeps_managed_arguments_and_strips_native_options() { + let args = arguments(&[ + "-Version", + "2.0", + "-RuntimeVersion", + "v2.0.50727", + "-NoProfile", + ]); + let options = legacy_options(&args).unwrap(); + assert_eq!( + options.version, + Some(Version { + major: 2, + minor: Some(0) + }) + ); + assert_eq!(options.runtime.as_deref(), Some("v2.0.50727")); + assert_eq!(options.skip, 4); + } + + #[test] + fn framework_defaults_and_server_mode() { + let options = legacy_options(&arguments(&["-NoProfile"])).unwrap(); + assert_eq!( + options.version, + Some(Version { + major: 3, + minor: None + }) + ); + assert_eq!(options.skip, 0); + let options = legacy_options(&arguments(&["-ServerMode"])).unwrap(); + assert_eq!( + options.version, + Some(Version { + major: 2, + minor: None + }) + ); + assert_eq!(options.skip, 0); + } + + #[test] + fn framework_rejects_missing_or_invalid_native_option_values() { + for args in [ + vec!["-Version"], + vec!["-Version", "invalid"], + vec!["-RuntimeVersion"], + ] { + assert!(matches!( + legacy_options(&arguments(&args)), + Err(BAD_COMMAND_LINE) + )); + } + } +} diff --git a/rust/windows/src/lib.rs b/rust/windows/src/lib.rs new file mode 100644 index 0000000..e413f95 --- /dev/null +++ b/rust/windows/src/lib.rs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(windows)] +pub mod common; +#[cfg(windows)] +pub mod hosting; +#[cfg(windows)] +pub mod launcher; +pub mod policy; +#[cfg(all(windows, not(feature = "coreclr")))] +mod shell; +#[cfg(windows)] +mod win; diff --git a/rust/windows/src/main.rs b/rust/windows/src/main.rs new file mode 100644 index 0000000..7363de4 --- /dev/null +++ b/rust/windows/src/main.rs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(windows)] +fn main() { + std::process::exit(i32::from_ne_bytes( + powershell_native_host::launcher::run().to_ne_bytes(), + )); +} + +#[cfg(not(windows))] +fn main() { + eprintln!("powershell-native-windows requires Windows"); + std::process::exit(1); +} diff --git a/rust/windows/src/policy.rs b/rust/windows/src/policy.rs new file mode 100644 index 0000000..198191b --- /dev/null +++ b/rust/windows/src/policy.rs @@ -0,0 +1,228 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +pub const INIT_FAILURE: u32 = 0xffff_0000; +pub const BAD_COMMAND_LINE: u32 = 0xfffd_0000; +pub const CONSOLE_FAILURE: u32 = 0xfffc_0000; +pub const REGISTRY_FAILURE: u32 = 0xfffb_0000; +pub const INCOMPATIBLE_VERSION: u32 = 0xfffa_0000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Version { + pub major: i32, + pub minor: Option, +} + +fn component(text: &str) -> Option { + if text.is_empty() || !text.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + let text = text.trim_start_matches('0'); + if text.is_empty() { + return Some(0); + } + text.parse().ok() +} + +impl Version { + /// .NET callers should use System.Version.TryParse (and validate the required fields). + #[must_use] + pub fn parse(text: &str, allow_minor: bool) -> Option { + let mut fields = text.split('.'); + let major = component(fields.next()?)?; + let minor = match fields.next() { + Some(text) => Some(component(text)?), + None => None, + }; + if !allow_minor && minor.is_some() { + return None; + } + // NativeMsh accepts suffixes after a successfully parsed minor component. + Some(Self { major, minor }) + } + + #[must_use] + pub fn registry_major(self) -> i32 { + match self.major { + 2 => 1, + 4 | 5 => 3, + other => other, + } + } +} + +/// Strict registry version format: major.minor[.build[.revision]]. +pub fn installed_version(text: &str) -> Option { + let fields = text.split('.').map(component).collect::>>()?; + if !(2..=4).contains(&fields.len()) { + return None; + } + Some(Version { + major: fields[0], + minor: Some(fields[1]), + }) +} + +#[must_use] +pub fn parameter_matches(argument: &str, parameter: &str) -> bool { + let mut chars = argument.chars(); + if !matches!( + chars.next(), + Some('-' | '\u{2013}' | '\u{2014}' | '\u{2015}') + ) { + return false; + } + let tail = chars.as_str(); + !tail.is_empty() + && parameter + .to_ascii_lowercase() + .starts_with(&tail.to_ascii_lowercase()) +} + +#[must_use] +pub fn exclude_tpa(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.starts_with("mscordaccore_") + || [ + "clretwrc.dll", + "clrgc.dll", + "clrgcexp.dll", + "clrjit.dll", + "coreclr.dll", + "d3dcompiler_47_cor3.dll", + "getfilesiginforedist.dll", + "hostfxr.dll", + "hostpolicy.dll", + "ijwhost.dll", + "microsoft.management.infrastructure.native.unmanaged.dll", + "microsoft.powershell.graphicalhost.dll", + "pwsh.dll", + "mscordaccore.dll", + "mscordbi.dll", + "mscorrc.dll", + "msquic.dll", + "penimc_cor3.dll", + "powershell.core.instrumentation.dll", + "presentationnative_cor3.dll", + "pwrshplugin.dll", + "sni.dll", + "system.io.compression.native.dll", + "vcruntime140_cor3.dll", + "wpfgfx_cor3.dll", + ] + .contains(&name.as_str()) +} + +/// Managed callers can use File.ReadLines and Directory.Exists for this legacy configuration. +pub fn host_config(text: &str, mut exists: impl FnMut(&str) -> bool) -> Option<(String, String)> { + let mut assemblies = None; + let mut runtime = None; + for line in text.lines().map(str::trim) { + let (destination, value) = if let Some(value) = line.strip_prefix("PSHOMEDIR=") { + (&mut assemblies, value) + } else if let Some(value) = line.strip_prefix("CORECLRDIR=") { + (&mut runtime, value) + } else { + continue; + }; + let value = value.trim(); + if !value.is_empty() && exists(value) { + *destination = Some(value.to_owned()); + } + } + Some((assemblies?, runtime?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_versions_and_registry_mapping() { + for (text, major, minor) in [ + ("2", 2, None), + ("00000000000003.0", 3, Some(0)), + ("5.1.999", 5, Some(1)), + ("2147483647", i32::MAX, None), + ] { + assert_eq!(Version::parse(text, true), Some(Version { major, minor })); + } + for text in [ + "", + "-1", + "+1", + " 3", + "3.", + "3.x", + "2147483648", + "1.2147483648", + ] { + assert!(Version::parse(text, true).is_none(), "{text}"); + } + assert!(Version::parse("3.0", false).is_none()); + assert_eq!(Version::parse("2", true).unwrap().registry_major(), 1); + assert_eq!(Version::parse("5", true).unwrap().registry_major(), 3); + } + + #[test] + fn registry_requires_full_valid_dotnet_version() { + for text in ["3.0", "5.1.19041.1", "0.0"] { + assert!(installed_version(text).is_some()); + } + for text in ["3", "3.0.x", "3.0.1.2.3", "3.-1", "3.0."] { + assert!(installed_version(text).is_none()); + } + } + + #[test] + fn parameter_abbreviations_and_unicode_dashes() { + assert!(parameter_matches("\u{2014}VER", "version")); + assert!(parameter_matches("-p", "psconsolefile")); + for text in ["-", "/version", "-versionlong", "version"] { + assert!(!parameter_matches(text, "version")); + } + } + + #[test] + fn trusted_assemblies_exclude_native_images() { + assert!(exclude_tpa("CORECLR.DLL")); + assert!(exclude_tpa("mscordaccore_amd64_amd64_11.0.dll")); + assert!(!exclude_tpa("System.Management.Automation.dll")); + assert!(!exclude_tpa("System.Private.CoreLib.dll")); + } + + #[test] + fn config_requires_both_existing_paths() { + assert!(host_config("PSHOMEDIR=C:\\PowerShell", |_| true).is_none()); + assert!(host_config("PSHOMEDIR=\nCORECLRDIR=C:\\CLR", |_| true).is_none()); + assert!(host_config("pshomedir=C:\\PowerShell\nCORECLRDIR=C:\\CLR", |_| true).is_none()); + assert!( + host_config("PSHOMEDIR=C:\\missing\nCORECLRDIR=C:\\CLR", |p| !p + .ends_with("missing")) + .is_none() + ); + } + + #[test] + fn config_ignores_comments_and_keeps_last_valid_value() { + let text = "#PSHOMEDIR=bad\r\nPSHOMEDIR= first \r\nCORECLRDIR= CLR\r\n\ + PSHOMEDIR=second\r\nPSHOMEDIR=missing\r\n"; + assert_eq!( + host_config(text, |p| p != "missing"), + Some(("second".into(), "CLR".into())) + ); + } + + #[test] + fn config_checks_unexpanded_environment_variable_path() { + let mut observed = Vec::new(); + assert_eq!( + host_config("PSHOMEDIR=%PSHOME%\nCORECLRDIR=%CLR%", |p| { + observed.push(p.to_owned()); + true + }), + Some(("%PSHOME%".into(), "%CLR%".into())) + ); + assert_eq!(observed, ["%PSHOME%", "%CLR%"]); + } +} diff --git a/rust/windows/src/shell.rs b/rust/windows/src/shell.rs new file mode 100644 index 0000000..2c3e865 --- /dev/null +++ b/rust/windows/src/shell.rs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{ + common::RegistryKey, + win::{ + expand, status_code, string, wide, ComApartment, GetCurrentProcess, GetModuleHandleW, + GetStartupInfoW, IsWow64Process, LoadStringW, Result, SetConsoleTitleW, StartupInfo, + }, +}; +use std::{path::Path, ptr}; +use windows::{ + core::{Interface, GUID, PCWSTR, PROPVARIANT}, + Win32::{ + System::Com::{CoCreateInstance, IPersistFile, CLSCTX_INPROC_SERVER, STGM_READWRITE}, + UI::Shell::{ + Common::{IObjectArray, IObjectCollection}, + DestinationList, EnumerableObjectCollection, ICustomDestinationList, + IShellLinkDataList, IShellLinkW, + PropertiesSystem::{IPropertyStore, PROPERTYKEY}, + ShellLink, SLDF_ALLOW_LINK_TO_LINK, SLDF_RUNAS_USER, + }, + }, +}; + +const ISE: &str = r"%systemroot%\system32\windowspowershell\v1.0\powershell_ise.exe"; +const TITLE: PROPERTYKEY = PROPERTYKEY { + fmtid: GUID::from_u128(0xf29f85e0_4ff9_1068_ab91_08002b27b3d9), + pid: 2, +}; + +fn resource(id: u32) -> windows::core::Result { + let mut buffer = vec![0; 1024]; + let length = + unsafe { LoadStringW(GetModuleHandleW(ptr::null()), id, buffer.as_mut_ptr(), 1024) }; + if length <= 0 { + return Err(windows::core::Error::from_win32()); + } + Ok(String::from_utf16_lossy( + &buffer[..length.unsigned_abs() as usize], + )) +} + +/// Windows shell links and jump lists have no direct cross-platform .NET equivalent. +unsafe fn shortcut( + title: &str, + path: &str, + icon: &str, + index: i32, + elevation: bool, +) -> windows::core::Result { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + link.SetPath(PCWSTR(wide(path).as_ptr()))?; + link.SetArguments(PCWSTR(wide("").as_ptr()))?; + link.SetIconLocation(PCWSTR(wide(icon).as_ptr()), index)?; + let properties: IPropertyStore = link.cast()?; + properties.SetValue(&TITLE, &PROPVARIANT::from(title))?; + properties.Commit()?; + let data: IShellLinkDataList = link.cast()?; + let mut flags = data.GetFlags()? | SLDF_ALLOW_LINK_TO_LINK.0 as u32; + if elevation { + flags |= SLDF_RUNAS_USER.0 as u32; + } + data.SetFlags(flags)?; + Ok(link) +} + +fn shortcut_path(startup: &StartupInfo) -> Result { + if startup.flags & 0x800 != 0 && !startup.title.is_null() { + return Ok(unsafe { string(startup.title) }); + } + let mut wow64 = 0; + // Managed callers should use Environment.Is64BitOperatingSystem/Is64BitProcess. + unsafe { + IsWow64Process(GetCurrentProcess(), &raw mut wow64); + } + let name = if wow64 != 0 { + "ConsoleHostShortcutTargetX86" + } else { + "ConsoleHostShortcutTarget" + }; + if let Ok(key) = RegistryKey::open(r"SOFTWARE\Microsoft\PowerShell\3") { + if let Ok(path) = key.read_string(name, true) { + return expand(&path); + } + } + let candidates: &[&str] = if wow64 != 0 { + &[ + r"%AppData%\Microsoft\Windows\Start Menu\Programs\Windows PowerShell\Windows PowerShell(x86).lnk", + r"%ProgramData%\Microsoft\Windows\Start Menu\Programs\Administrative Tools\Windows PowerShell (x86).lnk", + r"%ProgramData%\Microsoft\Windows\Start Menu\Programs\Accessories\Windows PowerShell\Windows PowerShell (x86).lnk", + ] + } else { + &[ + r"%AppData%\Microsoft\Windows\Start Menu\Programs\Windows PowerShell\Windows PowerShell.lnk", + r"%ProgramData%\Microsoft\Windows\Start Menu\Programs\System Tools\Windows PowerShell.lnk", + r"%ProgramData%\Microsoft\Windows\Start Menu\Programs\Accessories\Windows PowerShell\Windows PowerShell.lnk", + ] + }; + for candidate in candidates { + let path = expand(candidate)?; + // Managed callers should use File.Exists. + if Path::new(&path).is_file() { + return Ok(path); + } + } + expand(r"%windir%\System32\WindowsPowerShell\v1.0\powershell.exe") +} + +pub fn initialize() -> Result<()> { + let mut startup = StartupInfo::default(); + unsafe { + GetStartupInfoW(&raw mut startup); + } + if startup.flags & 1 == 0 || startup.show == 0 { + return Ok(()); + } + let _apartment = ComApartment::new(false, false)?; + let result = unsafe { create_list(&startup) }; + result.map_err(|error| status_code(error.code().0)) +} + +unsafe fn create_list(startup: &StartupInfo) -> windows::core::Result<()> { + let list: ICustomDestinationList = + CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?; + let mut slots = 0; + let _removed: IObjectArray = list.BeginList(&raw mut slots)?; + let transaction = (|| { + if slots < 4 { + return Ok(()); + } + let target = shortcut_path(startup).map_err(|code| { + windows::core::Error::from_hresult(windows::core::HRESULT::from_win32(code)) + })?; + if startup.flags & 0x800 == 0 { + let title = wide(&resource(109)?); + SetConsoleTitleW(title.as_ptr()); + } + let mut icon = expand(r"%windir%\System32\WindowsPowerShell\v1.0\powershell.exe") + .unwrap_or_else(|_| target.clone()); + let mut index = 0; + let original: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + let persisted: IPersistFile = original.cast()?; + if persisted + .Load(PCWSTR(wide(&target).as_ptr()), STGM_READWRITE) + .is_ok() + { + let mut path = vec![0; 32768]; + original.GetIconLocation(&mut path, &raw mut index)?; + let end = path.iter().position(|c| *c == 0).unwrap_or(path.len()); + icon = String::from_utf16_lossy(&path[..end]); + } + let collection: IObjectCollection = + CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?; + collection.AddObject(&shortcut(&resource(118)?, &target, &icon, index, true)?)?; + let ise = expand(ISE).unwrap_or_else(|_| ISE.to_string()); + if Path::new(&ise).is_file() { + collection.AddObject(&shortcut(&resource(122)?, ISE, ISE, 0, true)?)?; + collection.AddObject(&shortcut(&resource(101)?, ISE, ISE, 0, false)?)?; + } + let array: IObjectArray = collection.cast()?; + list.AddUserTasks(&array)?; + list.CommitList() + })(); + if transaction.is_err() || slots < 4 { + let _ = list.AbortList(); + } + transaction +} diff --git a/rust/windows/src/win.rs b/rust/windows/src/win.rs new file mode 100644 index 0000000..8d12af4 --- /dev/null +++ b/rust/windows/src/win.rs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{ + ffi::{c_void, CStr}, + os::windows::ffi::{OsStrExt, OsStringExt}, + path::PathBuf, + ptr, +}; + +pub type Ptr = *mut c_void; +pub type Wide = *const u16; +pub type Result = std::result::Result; + +#[link(name = "kernel32")] +extern "system" { + pub fn LoadLibraryExW(name: Wide, file: Ptr, flags: u32) -> Ptr; + pub fn GetModuleHandleW(name: Wide) -> Ptr; + pub fn GetModuleHandleExW(flags: u32, name: Wide, module: *mut Ptr) -> i32; + pub fn GetModuleFileNameW(module: Ptr, name: *mut u16, size: u32) -> u32; + pub fn GetProcAddress(module: Ptr, name: *const u8) -> Ptr; + pub fn FreeLibrary(module: Ptr) -> i32; + pub fn GetLastError() -> u32; + pub fn ExpandEnvironmentStringsW(source: Wide, target: *mut u16, count: u32) -> u32; + pub fn SetErrorMode(mode: u32) -> u32; + pub fn SetThreadUILanguage(language: u16) -> u16; + #[cfg(not(feature = "coreclr"))] + pub fn GetStartupInfoW(info: *mut StartupInfo); + #[cfg(not(feature = "coreclr"))] + pub fn SetConsoleTitleW(title: Wide) -> i32; + #[cfg(not(feature = "coreclr"))] + pub fn IsWow64Process(process: Ptr, wow64: *mut i32) -> i32; + #[cfg(not(feature = "coreclr"))] + pub fn GetCurrentProcess() -> Ptr; + pub fn GetStdHandle(kind: u32) -> Ptr; + pub fn GetFileType(handle: Ptr) -> u32; + pub fn WriteConsoleW( + handle: Ptr, + text: Wide, + length: u32, + written: *mut u32, + reserved: Ptr, + ) -> i32; + pub fn WriteFile( + handle: Ptr, + data: *const u8, + count: u32, + written: *mut u32, + overlapped: Ptr, + ) -> i32; + pub fn FormatMessageW( + flags: u32, + source: Ptr, + id: u32, + language: u32, + buffer: *mut u16, + size: u32, + arguments: *const usize, + ) -> u32; +} +#[link(name = "user32")] +extern "system" { + #[cfg(not(feature = "coreclr"))] + pub fn LoadStringW(instance: Ptr, id: u32, buffer: *mut u16, length: i32) -> i32; +} +#[link(name = "advapi32")] +extern "system" { + pub fn RegOpenKeyExW(parent: Ptr, name: Wide, options: u32, access: u32, key: *mut Ptr) -> i32; + pub fn RegQueryValueExW( + key: Ptr, + name: Wide, + reserved: Ptr, + kind: *mut u32, + data: *mut u8, + bytes: *mut u32, + ) -> i32; + pub fn RegEnumKeyExW( + key: Ptr, + index: u32, + name: *mut u16, + length: *mut u32, + reserved: Ptr, + class: *mut u16, + class_length: *mut u32, + time: Ptr, + ) -> i32; + pub fn RegCloseKey(key: Ptr) -> i32; +} + +#[repr(C)] +#[derive(Default)] +#[cfg(not(feature = "coreclr"))] +pub struct StartupInfo { + pub size: u32, + pub reserved: *mut u16, + pub desktop: *mut u16, + pub title: *mut u16, + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + pub chars_x: u32, + pub chars_y: u32, + pub fill: u32, + pub flags: u32, + pub show: u16, + pub reserved_bytes: u16, + pub reserved_data: *mut u8, + pub input: Ptr, + pub output: Ptr, + pub error: Ptr, +} + +pub fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() +} +pub fn path_wide(path: &std::path::Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() +} +pub unsafe fn wide_slice<'a>(text: Wide) -> &'a [u16] { + if text.is_null() { + return &[]; + } + let mut size = 0; + while *text.add(size) != 0 { + size += 1; + } + std::slice::from_raw_parts(text, size) +} +#[cfg(not(feature = "coreclr"))] +pub unsafe fn string(text: Wide) -> String { + String::from_utf16_lossy(wide_slice(text)) +} + +/// Managed callers should use Environment.ExpandEnvironmentVariables. +pub fn expand(text: &str) -> Result { + let text = wide(text); + unsafe { + let size = ExpandEnvironmentStringsW(text.as_ptr(), ptr::null_mut(), 0); + if size == 0 { + return Err(GetLastError()); + } + let mut value = vec![0; size as usize]; + let count = ExpandEnvironmentStringsW(text.as_ptr(), value.as_mut_ptr(), size); + if count == 0 { + return Err(GetLastError()); + } + if count > size { + return Err(122); + } + Ok(String::from_utf16_lossy(&value[..count as usize - 1])) + } +} + +/// Managed callers should use Environment.ProcessPath or Process.MainModule.FileName. +pub fn module_path(module: Ptr) -> Result { + let mut buffer = vec![0; 512]; + loop { + let capacity = u32::try_from(buffer.len()).map_err(|_| 206u32)?; + let count = unsafe { GetModuleFileNameW(module, buffer.as_mut_ptr(), capacity) }; + if count == 0 { + return Err(unsafe { GetLastError() }); + } + if (count as usize) < buffer.len() { + return Ok(std::ffi::OsString::from_wide(&buffer[..count as usize]).into()); + } + if buffer.len() >= 32768 { + return Err(206); + } + buffer.resize(buffer.len() * 2, 0); + } +} + +pub struct Module(pub Ptr); +impl Module { + /// Managed callers should use NativeLibrary.Load/GetExport/Free. + pub fn load(path: &std::path::Path) -> Result { + let name = path_wide(path); + let handle = unsafe { LoadLibraryExW(name.as_ptr(), ptr::null_mut(), 0) }; + if handle.is_null() { + Err(unsafe { GetLastError() }) + } else { + Ok(Self(handle)) + } + } + pub fn symbol(&self, name: &CStr) -> Result { + let address = unsafe { GetProcAddress(self.0, name.as_ptr().cast()) }; + if address.is_null() { + Err(unsafe { GetLastError() }) + } else { + Ok(address) + } + } + pub fn pin(&self) -> Result<()> { + let path = module_path(self.0)?; + let name = path_wide(&path); + let mut handle = ptr::null_mut(); + if unsafe { GetModuleHandleExW(1, name.as_ptr(), &raw mut handle) } == 0 { + Err(unsafe { GetLastError() }) + } else { + Ok(()) + } + } +} +impl Drop for Module { + fn drop(&mut self) { + unsafe { + FreeLibrary(self.0); + } + } +} + +pub struct ComApartment(bool); +impl ComApartment { + pub fn new(multithreaded: bool, tolerate_changed_mode: bool) -> Result { + use windows::Win32::System::Com::{ + CoInitializeEx, COINIT_APARTMENTTHREADED, COINIT_MULTITHREADED, + }; + let hr = unsafe { + CoInitializeEx( + None, + if multithreaded { + COINIT_MULTITHREADED + } else { + COINIT_APARTMENTTHREADED + }, + ) + }; + if hr.is_ok() { + Ok(Self(true)) + } else if tolerate_changed_mode && status_code(hr.0) == 0x8001_0106 { + Ok(Self(false)) + } else { + Err(status_code(hr.0)) + } + } +} +impl Drop for ComApartment { + fn drop(&mut self) { + if self.0 { + unsafe { + windows::Win32::System::Com::CoUninitialize(); + } + } + } +} + +/// Managed callers should use Win32Exception(error).Message. +pub fn message(module: Ptr, code: u32, arguments: &[usize]) -> Vec { + let mut buffer = vec![0; 32768]; + let flags = (if module.is_null() { 0x1000 } else { 0x800 }) + | if arguments.is_empty() { 0x200 } else { 0x2000 }; + let count = unsafe { + FormatMessageW( + flags, + module, + code, + 0, + buffer.as_mut_ptr(), + 32768, + arguments.as_ptr(), + ) + }; + if count == 0 { + return wide(&format!("PowerShell native host error 0x{code:08x}\r\n")); + } + buffer.truncate(count as usize); + buffer.push(0); + buffer +} + +/// Managed callers should use Console.Write/Console.Error.Write. This preserves legacy UTF-16 +/// redirected output rather than silently changing the existing native host's encoding. +pub fn output(text: &[u16], stdout: bool) { + let count = text.iter().position(|c| *c == 0).unwrap_or(text.len()); + unsafe { + let handle = GetStdHandle(status_code(if stdout { -11 } else { -12 })); + let mut offset = 0; + while offset < count { + let chunk = u32::try_from((count - offset).min(u32::MAX as usize / 2)) + .expect("output chunk fits DWORD"); + let mut written = 0; + let result = if GetFileType(handle) & !0x8000 == 2 { + WriteConsoleW( + handle, + text[offset..].as_ptr(), + chunk, + &raw mut written, + ptr::null_mut(), + ) + } else { + let result = WriteFile( + handle, + text[offset..].as_ptr().cast(), + chunk * 2, + &raw mut written, + ptr::null_mut(), + ); + written /= 2; + result + }; + if result == 0 || written == 0 { + break; + } + + offset += written as usize; + } + } +} + +// HRESULTs and process statuses are signed/unsigned views of the same 32 bits. +pub fn status_code(value: i32) -> u32 { + u32::from_ne_bytes(value.to_ne_bytes()) +} diff --git a/rust/windows/tests/launcher-smoke.ps1 b/rust/windows/tests/launcher-smoke.ps1 new file mode 100644 index 0000000..45ca144 --- /dev/null +++ b/rust/windows/tests/launcher-smoke.ps1 @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +param( + [Parameter(Mandatory)] + [string] $ExecutablePath, + [Parameter(Mandatory)] + [ValidateSet('coreclr', 'framework')] + [string] $Configuration +) + +$ErrorActionPreference = 'Stop' +if (-not $IsWindows) { + throw 'The launcher smoke test requires Windows and PowerShell 7 or newer.' +} +$executable = (Resolve-Path $ExecutablePath).Path +$version = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($executable) +if ($version.FileVersion -ne '0.1.0' -or $version.OriginalFilename -ne 'powershell.exe') { + throw 'Launcher version resources were not embedded correctly.' +} + +$process = [System.Diagnostics.Process]::new() +$started = $false +try { + $process.StartInfo.FileName = $executable + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $process.StartInfo.StandardOutputEncoding = [System.Text.Encoding]::Unicode + $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::Unicode + $arguments = if ($Configuration -eq 'coreclr') { + @('-?') + } else { + @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 37') + } + foreach ($argument in $arguments) { + $process.StartInfo.ArgumentList.Add($argument) + } + $started = $process.Start() + if (-not $started) { throw 'Launcher process did not start.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit(30000)) { + $process.Kill($true) + $process.WaitForExit() + throw 'Launcher did not finish within 30 seconds.' + } + $output = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + if ($Configuration -eq 'coreclr') { + if ($process.ExitCode -ne -1 -or $output -notmatch 'USAGE: powershell') { + throw "CoreCLR native help failed ($($process.ExitCode)): $output $errorText" + } + } elseif ($process.ExitCode -ne 37) { + throw "Framework host did not execute managed PowerShell ($($process.ExitCode)): $output $errorText" + } +} finally { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() +} +'Launcher resources and startup smoke checks passed.' From e2647169390e649fb6bc3a8acef7d1e4b4a13194 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 17:47:04 -0800 Subject: [PATCH 02/10] Fix Rust CI failures and address launcher review feedback Isolate Pester dependencies, avoid host-mounted account assumptions in Alpine, and reject null COM variants before constructing an owner. Link the Windows CRT statically and verify PE imports. Exercise successful CoreCLR hosting with checksum-verified matching legacy payloads on x86 and x64. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust-native.yml | 45 +++++++++++++++++++------ rust/.cargo/config.toml | 6 ++++ rust/README.md | 24 +++++++++----- rust/libpsl-native/tests/native.rs | 9 ++--- rust/tests/Invoke-Tests.ps1 | 18 +++++++++- rust/tests/WindowsLauncher.Tests.ps1 | 38 ++++++++++++++++++++- rust/windows/README.md | 49 +++++++++++++++++++--------- rust/windows/src/hosting.rs | 25 ++++++++++++-- 8 files changed, 172 insertions(+), 42 deletions(-) diff --git a/.github/workflows/rust-native.yml b/.github/workflows/rust-native.yml index bef9913..38a6caf 100644 --- a/.github/workflows/rust-native.yml +++ b/.github/workflows/rust-native.yml @@ -93,9 +93,10 @@ jobs: - name: Run Pester v5 on packaged Rust library shell: pwsh run: | - if (-not (Get-Module -ListAvailable Pester | Where-Object Version -EQ '5.5.0')) { - Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -Force - } + $modules = Join-Path $env:RUNNER_TEMP 'powershell-modules' + New-Item -ItemType Directory -Force $modules > $null + Save-Module Pester -RequiredVersion 5.9.0 -Path $modules -Repository PSGallery -Force + $env:PSModulePath = $modules + [IO.Path]::PathSeparator + $env:PSModulePath ./tests/Invoke-Tests.ps1 -LibraryPath ../artifacts/rust/linux-${{ matrix.arch }}/libpsl-native.so -ResultPath ../artifacts/test-results/linux-${{ matrix.arch }}.xml - name: Run Pester v5 in a real musl process (x64 host only) if: matrix.arch == 'x64' @@ -106,7 +107,7 @@ jobs: chmod +x "$RUNNER_TEMP/pwsh-musl/pwsh" docker run --rm -v "$GITHUB_WORKSPACE:/src" \ -v "$RUNNER_TEMP/pwsh-musl:/opt/pwsh:ro" \ - -v "$HOME/.local/share/powershell/Modules:/root/.local/share/powershell/Modules:ro" \ + -v "$RUNNER_TEMP/powershell-modules:/root/.local/share/powershell/Modules:ro" \ -w /src/rust alpine:3.22 sh -ec ' apk add --no-cache ca-certificates icu-libs krb5-libs libatomic libgcc libintl libssl3 libstdc++ ncurses-terminfo-base tzdata userspace-rcu zlib /opt/pwsh/pwsh -NoLogo -NoProfile -File ./tests/Invoke-Tests.ps1 \ @@ -174,6 +175,11 @@ jobs: $destination = "../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}" New-Item -ItemType Directory -Force $destination > $null Copy-Item ./target/${{ matrix.target }}/release/powershell.exe $destination + $imports = & dumpbin.exe /nologo /dependents "$destination/powershell.exe" + if ($LASTEXITCODE) { throw 'Cannot inspect packaged launcher dependencies.' } + if ($imports -match '(?i)\b(?:vcruntime\d+\w*|msvcp\d+\w*|msvcr\d+\w*|ucrtbase[d]?)\.dll\b') { + throw "Launcher has an unbundled dynamic CRT dependency: $imports" + } - name: Build existing C++ CoreCLR binaries run: | $PSNativeCommandUseErrorActionPreference = $true @@ -188,10 +194,28 @@ jobs: Copy-Item ../src/powershell-native/Bin/RELEASE/CoreClr/pwrshplugin.dll $destination - name: Run Pester v5 on packaged Rust launcher run: | - if (-not (Get-Module -ListAvailable Pester | Where-Object Version -EQ '5.5.0')) { - Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -Force + $modules = Join-Path $env:RUNNER_TEMP 'powershell-modules' + New-Item -ItemType Directory -Force $modules > $null + Save-Module Pester -RequiredVersion 5.9.0 -Path $modules -Repository PSGallery -Force + $env:PSModulePath = $modules + [IO.Path]::PathSeparator + $env:PSModulePath + $parameters = @{} + if ('${{ matrix.configuration }}' -eq 'coreclr') { + $archive = Join-Path $env:RUNNER_TEMP 'powershell-coreclr.zip' + $payload = Join-Path $env:RUNNER_TEMP 'powershell-coreclr' + # The preserved legacy delegate API requires an unambiguous three-argument Start. + Invoke-WebRequest 'https://github.com/PowerShell/PowerShell/releases/download/v6.0.5/PowerShell-6.0.5-win-${{ matrix.arch }}.zip' -OutFile $archive + $expectedHash = if ('${{ matrix.arch }}' -eq 'x64') { + '7D45EA529CAA256F7759C8AB0AB6007528A1564799637855B2F2A171F37CFAFD' + } else { + '1C31E177F370E6F96E58760A92F1CADB95837E2184E93E13CE9C8E44C57625E4' + } + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $expectedHash) { + throw 'Legacy CoreCLR test payload checksum mismatch.' + } + Expand-Archive $archive $payload + $parameters.CoreClrPayloadPath = $payload } - ./tests/Invoke-Tests.ps1 -ExecutablePath ../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}/powershell.exe -Configuration ${{ matrix.configuration }} -Architecture ${{ matrix.arch }} -ResultPath ../artifacts/test-results/win-${{ matrix.arch }}-${{ matrix.configuration }}.xml + ./tests/Invoke-Tests.ps1 -ExecutablePath ../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}/powershell.exe -Configuration ${{ matrix.configuration }} -Architecture ${{ matrix.arch }} -ResultPath ../artifacts/test-results/win-${{ matrix.arch }}-${{ matrix.configuration }}.xml @parameters - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: @@ -240,9 +264,10 @@ jobs: - name: Run Pester v5 on packaged Rust library shell: pwsh run: | - if (-not (Get-Module -ListAvailable Pester | Where-Object Version -EQ '5.5.0')) { - Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -Force - } + $modules = Join-Path $env:RUNNER_TEMP 'powershell-modules' + New-Item -ItemType Directory -Force $modules > $null + Save-Module Pester -RequiredVersion 5.9.0 -Path $modules -Repository PSGallery -Force + $env:PSModulePath = $modules + [IO.Path]::PathSeparator + $env:PSModulePath ./tests/Invoke-Tests.ps1 -LibraryPath ../artifacts/rust/osx-${{ matrix.arch }}/libpsl-native.dylib -ResultPath ../artifacts/test-results/osx-${{ matrix.arch }}.xml - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml index 51b294c..12a8248 100644 --- a/rust/.cargo/config.toml +++ b/rust/.cargo/config.toml @@ -9,3 +9,9 @@ rustflags = ["-C", "link-arg=-Wl,-z,relro,-z,now", "-C", "link-arg=-Wl,-soname,l [target.aarch64-unknown-linux-gnu] rustflags = ["-C", "link-arg=-Wl,-z,relro,-z,now", "-C", "link-arg=-Wl,-soname,libpsl-native.so"] + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] + +[target.i686-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/rust/README.md b/rust/README.md index c9c3ff1..076516f 100644 --- a/rust/README.md +++ b/rust/README.md @@ -147,13 +147,18 @@ Windows checks both feature configurations and Linux checks both libc targets. Narrow, documented lint exceptions retain deliberate C ABI bit reinterpretations and narrowing conversions. Formatting and native Rust tests are also enforced. -CI installs Pester 5.5.0 and exercises the **packaged Rust binaries**, not only +CI saves Pester 5.9.0 to an isolated module directory rather than replacing or +downgrading runner-installed modules across signing-certificate changes. +The same module directory is mounted into the musl container. +Tests exercise the **packaged Rust binaries**, not only intermediate Cargo outputs. Unix tests check all 30 exported names, managed layouts, UTF-8/returned-string ownership, native booleans, errno, filesystem behavior, identities, safe failure paths, and logging. Windows tests check PE -architecture/resources, native help, missing-runtime errors, and Framework -startup/managed exit-code propagation. The runner fails on failed, empty, or -skipped test runs. +architecture/resources, native help, missing-runtime errors, successful CoreCLR +startup with matching legacy PowerShell 6.0.5 payloads, argument preservation, +signed managed exit codes, and Framework startup. Windows launchers statically +link the CRT, and CI rejects dynamic CRT imports in the packaged executables. +The runner fails on failed, empty, or skipped test runs. Pester runs on both glibc Linux architectures, both macOS architectures, every Windows configuration, and x64 Alpine/musl using a musl PowerShell host. @@ -167,15 +172,18 @@ Run Unix tests in a fresh PowerShell process matching the library's architecture and libc: ```powershell -Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery +Install-Module Pester -RequiredVersion 5.9.0 -Scope CurrentUser -Repository PSGallery ./tests/Invoke-Tests.ps1 -LibraryPath ./target/release/libpsl_native.dylib -ResultPath ./test-results/native.xml ``` For Windows: ```powershell -./tests/Invoke-Tests.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr -Architecture x64 -ResultPath ./test-results/windows.xml +./tests/Invoke-Tests.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr -Architecture x64 -CoreClrPayloadPath C:/payloads/powershell-6.0.5-win-x64 -ResultPath ./test-results/windows.xml ``` -Use `-Configuration framework` for a launcher built with `--no-default-features` -and `-Architecture x86` for an `i686-pc-windows-msvc` build. +Use `-Configuration framework` (without `-CoreClrPayloadPath`) for a launcher +built with `--no-default-features`, and `-Architecture x86` with matching x86 +payloads for an `i686-pc-windows-msvc` build. The legacy CoreCLR payload is an +end-of-life compatibility fixture, not a deployment recommendation; see +[`windows/README.md`](windows/README.md) for the entry-point compatibility limit. diff --git a/rust/libpsl-native/tests/native.rs b/rust/libpsl-native/tests/native.rs index de46167..d7f1fe1 100644 --- a/rust/libpsl-native/tests/native.rs +++ b/rust/libpsl-native/tests/native.rs @@ -215,15 +215,16 @@ fn canonicalization_includes_parent_symlinks_and_raw_fallback() { #[test] fn account_lookup_host_and_thread_identity() { + let fixture = Fixture::new(); + let owned_file = fixture.path("owner"); + fs::write(&owned_file, "").unwrap(); unsafe { let user = take_string(GetPwUid(libc::getuid())); assert_ne!(user, ""); assert_ne!(take_string(GetGrGid(libc::getgid())), ""); assert_eq!( - take_string(GetFileOwner( - path(&std::env::current_dir().unwrap()).as_ptr() - )), - take_string(GetPwUid(fs::metadata(".").unwrap().uid())) + take_string(GetFileOwner(path(&owned_file).as_ptr())), + take_string(GetPwUid(fs::metadata(&owned_file).unwrap().uid())) ); assert_eq!( take_string(GetUserFromPid(libc::getpid())), diff --git a/rust/tests/Invoke-Tests.ps1 b/rust/tests/Invoke-Tests.ps1 index d132fb9..7349d10 100644 --- a/rust/tests/Invoke-Tests.ps1 +++ b/rust/tests/Invoke-Tests.ps1 @@ -12,6 +12,8 @@ param( [Parameter(Mandatory, ParameterSetName = 'Windows')] [ValidateSet('x64', 'x86')] [string] $Architecture, + [Parameter(ParameterSetName = 'Windows')] + [string] $CoreClrPayloadPath, [Parameter(Mandatory)] [string] $ResultPath ) @@ -23,9 +25,23 @@ $data = if ($PSCmdlet.ParameterSetName -eq 'Unix') { @{ Path = "$PSScriptRoot/NativeLibrary.Tests.ps1"; Data = @{ LibraryPath = (Resolve-Path $LibraryPath).Path } } } else { if (-not $IsWindows) { throw 'Windows launcher tests require Windows.' } + if ($Configuration -eq 'coreclr') { + if (-not $CoreClrPayloadPath) { throw 'CoreCLR tests require -CoreClrPayloadPath with matching runtime and managed PowerShell assemblies.' } + $CoreClrPayloadPath = (Resolve-Path $CoreClrPayloadPath).Path + foreach ($file in 'coreclr.dll', 'Microsoft.PowerShell.ConsoleHost.dll') { + if (-not (Test-Path -LiteralPath (Join-Path $CoreClrPayloadPath $file) -PathType Leaf)) { + throw "CoreCLR test payload is missing $file." + } + } + } @{ Path = "$PSScriptRoot/WindowsLauncher.Tests.ps1" - Data = @{ ExecutablePath = (Resolve-Path $ExecutablePath).Path; Configuration = $Configuration; Architecture = $Architecture } + Data = @{ + ExecutablePath = (Resolve-Path $ExecutablePath).Path + Configuration = $Configuration + Architecture = $Architecture + CoreClrPayloadPath = $CoreClrPayloadPath + } } } $settings = New-PesterConfiguration diff --git a/rust/tests/WindowsLauncher.Tests.ps1 b/rust/tests/WindowsLauncher.Tests.ps1 index 5691faf..4f06000 100644 --- a/rust/tests/WindowsLauncher.Tests.ps1 +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -8,7 +8,8 @@ param( [string] $Configuration, [Parameter(Mandatory)] [ValidateSet('x64', 'x86')] - [string] $Architecture + [string] $Architecture, + [string] $CoreClrPayloadPath ) BeforeAll { @@ -45,6 +46,22 @@ BeforeAll { $process.Dispose() } } + if ($Configuration -eq 'coreclr') { + $coreClrHome = Join-Path $TestDrive 'coreclr' + New-Item -ItemType Directory $coreClrHome > $null + Copy-Item "$CoreClrPayloadPath/*" $coreClrHome -Recurse + $coreClrExecutable = Join-Path $coreClrHome 'powershell.exe' + Copy-Item $ExecutablePath $coreClrExecutable + $argumentScript = Join-Path $TestDrive 'arguments.ps1' + @' +param([string] $ResultPath, [int] $ExitCode) +[pscustomobject] @{ + Arguments = @($args) + Architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() +} | ConvertTo-Json | Set-Content -LiteralPath $ResultPath -Encoding utf8 +exit $ExitCode +'@ | Set-Content -LiteralPath $argumentScript -Encoding utf8 + } } Describe 'Packaged Rust Windows launcher' { @@ -91,6 +108,25 @@ Describe 'Packaged Rust Windows launcher' { $result.ExitCode | Should -Not -Be 0 $result.Error | Should -Match 'PowerShell native host error' } + + It 'runs managed CoreCLR PowerShell, preserves arguments, and propagates exit ' -ForEach @( + @{ ExitCode = 37 } + @{ ExitCode = -37 } + ) { + $resultFile = Join-Path $TestDrive "arguments-$ExitCode.json" + $expected = @('', 'two words', 'quote"value', 'trailing\', "$([char]0x03bb)$([char]::ConvertFromUtf32(0x1f600))") + $result = Invoke-Launcher -Path $coreClrExecutable -Arguments (@( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $argumentScript, $resultFile, "$ExitCode" + ) + $expected) + $result.ExitCode | Should -Be $ExitCode -Because "$($result.Output) $($result.Error)" + $resultFile | Should -Exist + $actual = Get-Content -LiteralPath $resultFile -Raw | ConvertFrom-Json + $actual.Architecture | Should -Be $Architecture + $actual.Arguments.Count | Should -Be $expected.Count + for ($index = 0; $index -lt $expected.Count; $index++) { + $actual.Arguments[$index] | Should -BeExactly $expected[$index] + } + } } else { It 'starts installed Windows PowerShell and propagates its managed exit code' { $result = Invoke-Launcher -Path $ExecutablePath -Arguments @( diff --git a/rust/windows/README.md b/rust/windows/README.md index d77bcfa..fec5312 100644 --- a/rust/windows/README.md +++ b/rust/windows/README.md @@ -13,15 +13,22 @@ linker/import libraries, and Windows SDK `mc.exe`/`rc.exe` on PATH. The SDK include directories must be set by the developer environment. ```powershell +# Run from rust/ so Cargo loads .cargo/config.toml, including static CRT linkage. +Set-Location rust + # CoreCLR launcher (default). -cargo build --manifest-path rust/Cargo.toml -p powershell-native-windows --release --target x86_64-pc-windows-msvc +cargo build -p powershell-native-windows --release --target x86_64-pc-windows-msvc # Legacy .NET Framework launcher. -cargo build --manifest-path rust/Cargo.toml -p powershell-native-windows --release --target x86_64-pc-windows-msvc --no-default-features +cargo build -p powershell-native-windows --release --target x86_64-pc-windows-msvc --no-default-features ``` Both configurations produce `powershell.exe`; build/deploy them separately. -MSVC x86 (`i686-pc-windows-msvc`) is also supported. ARM64 is not validated; +MSVC x86 (`i686-pc-windows-msvc`) is also supported. Both MSVC targets statically +link the CRT, so the launcher itself does not require the Visual C++ +redistributable. CI inspects the packaged executable's imports to enforce this; +managed payloads and their native dependencies retain their own prerequisites. +ARM64 is not validated; GNU/MinGW targets are rejected. `build.rs` embeds the original launcher messages, manifest, and icons from `src/powershell-native/nativemsh/pwrshexe`. Keep those resource inputs present. The version resource identifies this @@ -36,6 +43,9 @@ A successful cross-check does not establish Windows linker/runtime parity. their runtime dependencies. The host retains `coreclr_initialize`, `coreclr_create_delegate`, `coreclr_shutdown`, and the existing managed `Start` entry point; it does not switch existing payloads to hostfxr. +This legacy API resolves `Start` by name without an overload signature. +PowerShell payloads with overloaded `UnmanagedPSEntry.Start` methods, including +PowerShell 7, are not compatible with that preserved contract. `RemotePowerShellConfig.txt` can provide `PSHOMEDIR=` and `CORECLRDIR=`. Both paths must exist before environment expansion, as in the original host. Otherwise the executable directory is used. TPA enumeration retains the @@ -74,23 +84,31 @@ direct equivalent managed APIs. ## Validation ```powershell -cargo test --manifest-path rust/Cargo.toml -p powershell-native-windows -cargo test --manifest-path rust/Cargo.toml -p powershell-native-windows --no-default-features -cargo clippy --manifest-path rust/Cargo.toml -p powershell-native-windows --all-targets -- -D warnings -D clippy::pedantic -cargo clippy --manifest-path rust/Cargo.toml -p powershell-native-windows --all-targets --no-default-features -- -D warnings -D clippy::pedantic +# From rust/: +cargo test -p powershell-native-windows +cargo test -p powershell-native-windows --no-default-features +cargo clippy -p powershell-native-windows --all-targets -- -D warnings -D clippy::pedantic +cargo clippy -p powershell-native-windows --all-targets --no-default-features -- -D warnings -D clippy::pedantic ``` Portable tests cover version formats/overflow, registry-family mappings, parameter abbreviations/Unicode dashes, native TPA exclusions, and configuration -fallback. Windows-only tests cover Automation layout and launcher behavior. +fallback. Windows-only tests cover Automation layout, null dispatch/unknown +variants, and launcher behavior. The Windows CI jobs build/link both launcher configurations and architectures with SDK resources. Pester v5 checks the packaged executable's architecture, -version resources, native CoreCLR help and missing-runtime errors, and installed -Windows PowerShell startup/exit-code propagation through the Framework host: +version resources, native CoreCLR help and missing-runtime errors, successful +CoreCLR command execution, Unicode/empty/quoted/spaced arguments, positive and +negative managed exit codes, and installed Windows PowerShell startup through +the Framework host. The CoreCLR jobs use checksum-verified PowerShell 6.0.5 +runtime/managed payloads on x86 and x64 to exercise the original three-argument +entry point. That end-of-life payload is a compatibility test fixture, not a +deployment recommendation. Extract the matching architecture's official ZIP +before running CoreCLR tests locally: ```powershell -Install-Module Pester -RequiredVersion 5.5.0 -Scope CurrentUser -Repository PSGallery -./rust/tests/Invoke-Tests.ps1 -ExecutablePath ./rust/target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr -Architecture x64 -ResultPath ./artifacts/test-results/windows.xml +Install-Module Pester -RequiredVersion 5.9.0 -Scope CurrentUser -Repository PSGallery +./tests/Invoke-Tests.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr -Architecture x64 -CoreClrPayloadPath C:/payloads/powershell-6.0.5-win-x64 -ResultPath ../artifacts/test-results/windows.xml ``` CI packages these launchers under `artifacts/rust/win--/`, @@ -99,13 +117,12 @@ separate from the existing C++ CoreCLR binaries and WSMan plugin under The lightweight manual smoke script is also available: ```powershell -./rust/windows/tests/launcher-smoke.ps1 -ExecutablePath ./rust/target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr +./windows/tests/launcher-smoke.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr # After building the Framework configuration: -./rust/windows/tests/launcher-smoke.ps1 -ExecutablePath ./rust/target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration framework +./windows/tests/launcher-smoke.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration framework ``` -CoreCLR managed startup still requires Windows runtime verification with -matching managed payloads; Explorer integration requires a desktop session. +Explorer integration requires a desktop session and is not covered by headless CI. Explicit registry version requests are honored rather than being cleared before lookup, and Automation arrays carry their proper `VT_ARRAY | VT_BSTR` tag. diff --git a/rust/windows/src/hosting.rs b/rust/windows/src/hosting.rs index c5a302c..70ce174 100644 --- a/rust/windows/src/hosting.rs +++ b/rust/windows/src/hosting.rs @@ -271,10 +271,11 @@ impl AutomationVariant { if self.kind != 9 && self.kind != 13 { return Err(0x8002_0005); } - let object = ComObject(unsafe { self.value.pointer }); - if object.0.is_null() { + let pointer = unsafe { self.value.pointer }; + if pointer.is_null() { return Err(INIT_FAILURE); } + let object = ComObject(pointer); let result = object.query(&IID_DISPATCH); mem::forget(object); // The variant owns its original reference. result @@ -514,6 +515,26 @@ mod tests { assert_eq!(mem::offset_of!(AutomationVariant, value), 8); } + #[test] + fn automation_null_objects_are_rejected_without_releasing_null() { + for kind in [9, 13] { + let variant = AutomationVariant { + kind, + reserved: [0; 3], + value: VariantValue { + pointer: ptr::null_mut(), + }, + }; + assert!(matches!(variant.dispatch(), Err(INIT_FAILURE))); + } + } + + #[test] + fn automation_non_objects_report_type_mismatch() { + let variant = AutomationVariant::default(); + assert!(matches!(variant.dispatch(), Err(0x8002_0005))); + } + #[test] fn automation_string_array_preserves_utf16_and_bounds() { #[link(name = "oleaut32")] From a15f11632015190fab7a3c034bb415534c49f6f6 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 17:52:57 -0800 Subject: [PATCH 03/10] Fix Alpine build prerequisites and Framework object unwrapping Install make for the C++ musl baseline. Bind negative exit codes explicitly in the legacy managed test script and decode managed diagnostics as UTF-8. Unwrap Framework handles through the documented IObjectHandle interface and report failed COM interface IDs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust-native.yml | 2 +- rust/tests/WindowsLauncher.Tests.ps1 | 14 +++++++++----- rust/windows/src/hosting.rs | 16 ++++++++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rust-native.yml b/.github/workflows/rust-native.yml index 38a6caf..db4503d 100644 --- a/.github/workflows/rust-native.yml +++ b/.github/workflows/rust-native.yml @@ -72,7 +72,7 @@ jobs: - name: Test in a real musl process run: | docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src/rust alpine:3.22 sh -ec ' - apk add --no-cache g++ cmake cargo rust + apk add --no-cache g++ make cmake cargo rust CARGO_TARGET_DIR=target/alpine cargo test --locked -p psl-native cmake -S ../src/libpsl-native -B target/cpp-musl -DCMAKE_POLICY_VERSION_MINIMUM=3.5 cmake --build target/cpp-musl --target psl-native --parallel 2 diff --git a/rust/tests/WindowsLauncher.Tests.ps1 b/rust/tests/WindowsLauncher.Tests.ps1 index 4f06000..1a8850e 100644 --- a/rust/tests/WindowsLauncher.Tests.ps1 +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -14,7 +14,11 @@ param( BeforeAll { function Invoke-Launcher { - param([string] $Path, [string[]] $Arguments) + param( + [string] $Path, + [string[]] $Arguments, + [System.Text.Encoding] $Encoding = [System.Text.Encoding]::Unicode + ) $process = [System.Diagnostics.Process]::new() $started = $false try { @@ -23,8 +27,8 @@ BeforeAll { $process.StartInfo.CreateNoWindow = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true - $process.StartInfo.StandardOutputEncoding = [System.Text.Encoding]::Unicode - $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::Unicode + $process.StartInfo.StandardOutputEncoding = $Encoding + $process.StartInfo.StandardErrorEncoding = $Encoding foreach ($argument in $Arguments) { $process.StartInfo.ArgumentList.Add($argument) } $started = $process.Start() if (-not $started) { throw 'Launcher did not start.' } @@ -115,8 +119,8 @@ Describe 'Packaged Rust Windows launcher' { ) { $resultFile = Join-Path $TestDrive "arguments-$ExitCode.json" $expected = @('', 'two words', 'quote"value', 'trailing\', "$([char]0x03bb)$([char]::ConvertFromUtf32(0x1f600))") - $result = Invoke-Launcher -Path $coreClrExecutable -Arguments (@( - '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $argumentScript, $resultFile, "$ExitCode" + $result = Invoke-Launcher -Path $coreClrExecutable -Encoding ([System.Text.Encoding]::UTF8) -Arguments (@( + '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $argumentScript, $resultFile, "-ExitCode:$ExitCode" ) + $expected) $result.ExitCode | Should -Be $ExitCode -Because "$($result.Output) $($result.Error)" $resultFile | Should -Exist diff --git a/rust/windows/src/hosting.rs b/rust/windows/src/hosting.rs index 70ce174..0641012 100644 --- a/rust/windows/src/hosting.rs +++ b/rust/windows/src/hosting.rs @@ -314,6 +314,7 @@ impl Drop for ExceptionInfo { const IID_DISPATCH: GUID = GUID::from_u128(0x00020400_0000_0000_c000_000000000046); const IID_APPDOMAIN: GUID = GUID::from_u128(0x05f696dc_2b29_3663_ad8b_c4389cf2a713); +const IID_OBJECT_HANDLE: GUID = GUID::from_u128(0xc460e2b4_e199_412a_8456_84dc3e4838c3); const CLSID_COR_RUNTIME: GUID = GUID::from_u128(0xcb2f6723_ab3a_11d2_9c40_00c04fa30a3e); const IID_COR_RUNTIME: GUID = GUID::from_u128(0xcb2f6722_ab3a_11d2_9c40_00c04fa30a3e); const IID_NULL: GUID = GUID::zeroed(); @@ -331,6 +332,10 @@ impl ComObject { let mut result = ptr::null_mut(); let hr = unsafe { query(self.0, iid, &raw mut result) }; if hr < 0 { + output( + &wide(&format!("COM interface query failed: {iid:?}\r\n")), + false, + ); Err(status_code(hr)) } else if result.is_null() { Err(INIT_FAILURE) @@ -493,8 +498,15 @@ impl FrameworkClr { if handle.is_null() { return Err(INIT_FAILURE); } - let handle = ComObject(handle).query(&IID_DISPATCH)?; - let unwrapped = handle.invoke("Unwrap", &mut [])?; + let handle = ComObject(handle).query(&IID_OBJECT_HANDLE)?; + // IObjectHandle is an IUnknown interface with Unwrap at slot 3. + let unwrap: unsafe extern "system" fn(Ptr, *mut AutomationVariant) -> i32 = + unsafe { mem::transmute(handle.slot(3)) }; + let mut unwrapped = AutomationVariant::default(); + let hr = unsafe { unwrap(handle.0, &raw mut unwrapped) }; + if hr < 0 { + return Err(status_code(hr)); + } unwrapped.dispatch() } } From f346584fae4474e512b6f0667c07fbbdef192a10 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 17:58:01 -0800 Subject: [PATCH 04/10] Retain existing Automation dispatch interfaces without re-querying Match the original host's VT_DISPATCH ownership contract and cover AddRef/Release balancing with a regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/windows/src/hosting.rs | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/rust/windows/src/hosting.rs b/rust/windows/src/hosting.rs index 0641012..cc9d62c 100644 --- a/rust/windows/src/hosting.rs +++ b/rust/windows/src/hosting.rs @@ -276,6 +276,15 @@ impl AutomationVariant { return Err(INIT_FAILURE); } let object = ComObject(pointer); + if self.kind == 9 { + // VT_DISPATCH already contains IDispatch, as used by the C++ host. + let add_ref: unsafe extern "system" fn(Ptr) -> u32 = + unsafe { mem::transmute(object.slot(1)) }; + unsafe { + add_ref(pointer); + } + return Ok(object); + } let result = object.query(&IID_DISPATCH); mem::forget(object); // The variant owns its original reference. result @@ -541,6 +550,48 @@ mod tests { } } + #[test] + fn automation_dispatch_retains_the_existing_interface() { + #[repr(C)] + struct Dispatch { + vtable: *const Ptr, + references: std::cell::Cell, + } + unsafe extern "system" fn query(_: Ptr, _: *const GUID, _: *mut Ptr) -> i32 { + i32::from_ne_bytes(0x8000_4002_u32.to_ne_bytes()) + } + unsafe extern "system" fn add_ref(object: Ptr) -> u32 { + let object = unsafe { &*object.cast::() }; + let references = object.references.get() + 1; + object.references.set(references); + references + } + unsafe extern "system" fn release(object: Ptr) -> u32 { + let object = unsafe { &*object.cast::() }; + let references = object.references.get() - 1; + object.references.set(references); + references + } + let vtable = [query as Ptr, add_ref as Ptr, release as Ptr]; + let mut object = Dispatch { + vtable: vtable.as_ptr(), + references: std::cell::Cell::new(1), + }; + let variant = AutomationVariant { + kind: 9, + reserved: [0; 3], + value: VariantValue { + pointer: (&raw mut object).cast(), + }, + }; + let dispatch = variant.dispatch().unwrap(); + assert_eq!(object.references.get(), 2); + drop(dispatch); + assert_eq!(object.references.get(), 1); + drop(variant); + assert_eq!(object.references.get(), 0); + } + #[test] fn automation_non_objects_report_type_mismatch() { let variant = AutomationVariant::default(); From f7817151759d526c1c510c7e06b9f021d5499532 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 18:02:32 -0800 Subject: [PATCH 05/10] Diagnose installed Framework entry-point COM visibility in CI Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust-native.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/rust-native.yml b/.github/workflows/rust-native.yml index db4503d..95eca4d 100644 --- a/.github/workflows/rust-native.yml +++ b/.github/workflows/rust-native.yml @@ -214,6 +214,22 @@ jobs: } Expand-Archive $archive $payload $parameters.CoreClrPayloadPath = $payload + } else { + & "$env:WINDIR/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -NonInteractive -Command { + $type = [Microsoft.PowerShell.UnmanagedPSEntry] + $type.AssemblyQualifiedName + $type.GetCustomAttributes($false) | Format-List + $type.Assembly.GetCustomAttributes($false) | Where-Object { $_ -is [Runtime.InteropServices.ComVisibleAttribute] } | Format-List + $instance = [Activator]::CreateInstance($type) + try { + $dispatch = [Runtime.InteropServices.Marshal]::GetIDispatchForObject($instance) + 'Managed entry point exposes IDispatch.' + [void] [Runtime.InteropServices.Marshal]::Release($dispatch) + } catch { + Write-Error $_ + exit 1 + } + } } ./tests/Invoke-Tests.ps1 -ExecutablePath ../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}/powershell.exe -Configuration ${{ matrix.configuration }} -Architecture ${{ matrix.arch }} -ResultPath ../artifacts/test-results/win-${{ matrix.arch }}-${{ matrix.configuration }}.xml @parameters - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 From b7739d0bd7971c1e9da2e39f67872bbe8ace5821 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 19:00:36 -0800 Subject: [PATCH 06/10] Validate legacy Framework hosting with a COM-visible fixture Keep Windows PowerShell 5.1 unsupported as requested. Exercise real Framework activation, Automation argument marshaling, and signed exit codes without depending on its incompatible non-COM-visible entry point. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/rust-native.yml | 16 ------ rust/README.md | 5 +- rust/tests/WindowsLauncher.Tests.ps1 | 9 ++-- rust/windows/README.md | 12 ++++- rust/windows/tests/FrameworkEntry.cs | 32 ++++++++++++ rust/windows/tests/framework.rs | 74 ++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 rust/windows/tests/FrameworkEntry.cs create mode 100644 rust/windows/tests/framework.rs diff --git a/.github/workflows/rust-native.yml b/.github/workflows/rust-native.yml index 95eca4d..db4503d 100644 --- a/.github/workflows/rust-native.yml +++ b/.github/workflows/rust-native.yml @@ -214,22 +214,6 @@ jobs: } Expand-Archive $archive $payload $parameters.CoreClrPayloadPath = $payload - } else { - & "$env:WINDIR/System32/WindowsPowerShell/v1.0/powershell.exe" -NoProfile -NonInteractive -Command { - $type = [Microsoft.PowerShell.UnmanagedPSEntry] - $type.AssemblyQualifiedName - $type.GetCustomAttributes($false) | Format-List - $type.Assembly.GetCustomAttributes($false) | Where-Object { $_ -is [Runtime.InteropServices.ComVisibleAttribute] } | Format-List - $instance = [Activator]::CreateInstance($type) - try { - $dispatch = [Runtime.InteropServices.Marshal]::GetIDispatchForObject($instance) - 'Managed entry point exposes IDispatch.' - [void] [Runtime.InteropServices.Marshal]::Release($dispatch) - } catch { - Write-Error $_ - exit 1 - } - } } ./tests/Invoke-Tests.ps1 -ExecutablePath ../artifacts/rust/win-${{ matrix.arch }}-${{ matrix.configuration }}/powershell.exe -Configuration ${{ matrix.configuration }} -Architecture ${{ matrix.arch }} -ResultPath ../artifacts/test-results/win-${{ matrix.arch }}-${{ matrix.configuration }}.xml @parameters - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/rust/README.md b/rust/README.md index 076516f..8d4f5f9 100644 --- a/rust/README.md +++ b/rust/README.md @@ -156,7 +156,10 @@ layouts, UTF-8/returned-string ownership, native booleans, errno, filesystem behavior, identities, safe failure paths, and logging. Windows tests check PE architecture/resources, native help, missing-runtime errors, successful CoreCLR startup with matching legacy PowerShell 6.0.5 payloads, argument preservation, -signed managed exit codes, and Framework startup. Windows launchers statically +signed managed exit codes, and native Framework argument rejection. Framework +integration tests activate a COM-visible managed fixture and verify argument +marshaling and signed exit codes; Windows PowerShell 5.1 is not supported by the +preserved legacy COM hosting contract. Windows launchers statically link the CRT, and CI rejects dynamic CRT imports in the packaged executables. The runner fails on failed, empty, or skipped test runs. diff --git a/rust/tests/WindowsLauncher.Tests.ps1 b/rust/tests/WindowsLauncher.Tests.ps1 index 1a8850e..33a9c92 100644 --- a/rust/tests/WindowsLauncher.Tests.ps1 +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -132,11 +132,10 @@ Describe 'Packaged Rust Windows launcher' { } } } else { - It 'starts installed Windows PowerShell and propagates its managed exit code' { - $result = Invoke-Launcher -Path $ExecutablePath -Arguments @( - '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 37' - ) - $result.ExitCode | Should -Be 37 -Because "$($result.Output) $($result.Error)" + It 'rejects malformed Framework options without requiring a managed payload' { + $result = Invoke-Launcher -Path $ExecutablePath -Arguments @('-Version', 'invalid') + $result.ExitCode | Should -Not -Be 0 + $result.Error | Should -Match 'PowerShell native host error' } } } diff --git a/rust/windows/README.md b/rust/windows/README.md index fec5312..0c76781 100644 --- a/rust/windows/README.md +++ b/rust/windows/README.md @@ -56,6 +56,10 @@ be installed. The host retains `CorBindToRuntimeEx`, `ICorRuntimeHost`, default AppDomain activation, Automation invocation, legacy argument processing, console-file version selection, and Windows PE restrictions. Framework CLR destruction does not stop the CLR. +The managed entry point must be COM-visible and expose `Start` through +`IDispatch`, matching the original C++ host. Windows PowerShell 5.1 is not +supported: its console-host assembly disables COM visibility. This port does +not add reflection-based hosting to accommodate that incompatible payload. **Shell integration:** shortcut discovery/icons and taskbar administrator/ISE tasks are retained. Unavailable Explorer integration is nonfatal, including @@ -99,8 +103,12 @@ The Windows CI jobs build/link both launcher configurations and architectures with SDK resources. Pester v5 checks the packaged executable's architecture, version resources, native CoreCLR help and missing-runtime errors, successful CoreCLR command execution, Unicode/empty/quoted/spaced arguments, positive and -negative managed exit codes, and installed Windows PowerShell startup through -the Framework host. The CoreCLR jobs use checksum-verified PowerShell 6.0.5 +negative managed exit codes, and native Framework argument rejection. +Framework integration tests compile a COM-visible C# fixture with the installed +.NET Framework compiler and exercise actual CLR startup, AppDomain activation, +object unwrapping, argument marshaling, and signed managed exit codes on x86/x64. +They require .NET Framework 4 and its `csc.exe`, not Windows PowerShell 5.1. +The CoreCLR jobs use checksum-verified PowerShell 6.0.5 runtime/managed payloads on x86 and x64 to exercise the original three-argument entry point. That end-of-life payload is a compatibility test fixture, not a deployment recommendation. Extract the matching architecture's official ZIP diff --git a/rust/windows/tests/FrameworkEntry.cs b/rust/windows/tests/FrameworkEntry.cs new file mode 100644 index 0000000..38a6fec --- /dev/null +++ b/rust/windows/tests/FrameworkEntry.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.Runtime.InteropServices; + +[assembly: ComVisible(true)] + +namespace PowerShellNative.Tests +{ + [ClassInterface(ClassInterfaceType.AutoDual)] + public sealed class FrameworkEntry + { + public int Start(string consoleFile, string[] arguments) + { + string[] expected = { "", "two words", "quote\"value", "trailing\\", "\u03bb\ud83d\ude00" }; + if (arguments.Length != expected.Length) + { + throw new ArgumentException("Argument count was not preserved."); + } + for (int index = 0; index < expected.Length; index++) + { + if (!String.Equals(arguments[index], expected[index], StringComparison.Ordinal)) + { + throw new ArgumentException("Argument " + index + " was not preserved."); + } + } + return Int32.Parse(consoleFile, CultureInfo.InvariantCulture); + } + } +} diff --git a/rust/windows/tests/framework.rs b/rust/windows/tests/framework.rs new file mode 100644 index 0000000..17f0ca3 --- /dev/null +++ b/rust/windows/tests/framework.rs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(all(windows, not(feature = "coreclr")))] + +use powershell_native_host::hosting::{AutomationVariant, FrameworkClr}; +use std::{env, path::PathBuf, process::Command}; +use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED}; + +#[test] +fn framework_activates_a_com_visible_payload_and_preserves_arguments_and_exit_codes() { + struct Apartment; + impl Drop for Apartment { + fn drop(&mut self) { + unsafe { + CoUninitialize(); + } + } + } + + let compiler = PathBuf::from(env::var_os("WINDIR").expect("Windows directory is required")) + .join(r"Microsoft.NET\Framework\v4.0.30319\csc.exe"); + let executable = env::current_exe().unwrap(); + let assembly = executable + .parent() + .unwrap() + .join("PowerShellNativeFrameworkFixture.dll"); + let compilation = Command::new(compiler) + .args(["/nologo", "/target:library"]) + .arg(format!("/out:{}", assembly.display())) + .arg(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/FrameworkEntry.cs")) + .output() + .expect(".NET Framework C# compiler is required for the hosting fixture"); + assert!( + compilation.status.success(), + "Framework fixture compilation failed: {} {}", + String::from_utf8_lossy(&compilation.stdout), + String::from_utf8_lossy(&compilation.stderr) + ); + + unsafe { + CoInitializeEx(None, COINIT_MULTITHREADED).ok().unwrap(); + } + let _apartment = Apartment; + let host = FrameworkClr::launch("v4.0.30319").unwrap(); + let instance = host + .instance( + "PowerShellNativeFrameworkFixture", + "PowerShellNative.Tests.FrameworkEntry", + ) + .unwrap(); + let arguments: Vec> = [ + "", + "two words", + "quote\"value", + "trailing\\", + "\u{03bb}\u{1f600}", + ] + .iter() + .map(|argument| argument.encode_utf16().chain([0]).collect()) + .collect(); + for exit_code in [37_i32, -37] { + let console: Vec = exit_code.to_string().encode_utf16().collect(); + let mut parameters = [ + AutomationVariant::strings(&arguments).unwrap(), + AutomationVariant::bstr(Some(&console)).unwrap(), + ]; + let result = instance.invoke("Start", &mut parameters).unwrap(); + assert_eq!( + result.int_result().unwrap(), + u32::from_ne_bytes(exit_code.to_ne_bytes()) + ); + } +} From 799c7ad8dfa29484214ed0f3aa6edf0dc7bec780 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 19:04:47 -0800 Subject: [PATCH 07/10] Use native Windows separators for the Framework compiler source path Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/windows/tests/framework.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust/windows/tests/framework.rs b/rust/windows/tests/framework.rs index 17f0ca3..4b01d97 100644 --- a/rust/windows/tests/framework.rs +++ b/rust/windows/tests/framework.rs @@ -28,7 +28,11 @@ fn framework_activates_a_com_visible_payload_and_preserves_arguments_and_exit_co let compilation = Command::new(compiler) .args(["/nologo", "/target:library"]) .arg(format!("/out:{}", assembly.display())) - .arg(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/FrameworkEntry.cs")) + .arg( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("FrameworkEntry.cs"), + ) .output() .expect(".NET Framework C# compiler is required for the hosting fixture"); assert!( From 701c6a44be1426978dda7731574b3466dc645829 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 19:29:19 -0800 Subject: [PATCH 08/10] Handle BOM-prefixed configuration and keep CoreCLR stdout byte-oriented Preserve legacy UTF-16 resource diagnostics while writing native CoreCLR stdout as UTF-8. Add packaged-launcher regressions for external BOM-prefixed configuration and redirected help, debug prompts, and mixed native/managed verbose output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/tests/WindowsLauncher.Tests.ps1 | 57 +++++++++++++++++++++++++-- rust/windows/README.md | 6 +++ rust/windows/src/launcher.rs | 15 +++---- rust/windows/src/policy.rs | 20 ++++++++++ rust/windows/src/win.rs | 24 ++++++++++- rust/windows/tests/launcher-smoke.ps1 | 6 ++- 6 files changed, 112 insertions(+), 16 deletions(-) diff --git a/rust/tests/WindowsLauncher.Tests.ps1 b/rust/tests/WindowsLauncher.Tests.ps1 index 33a9c92..e11ed7a 100644 --- a/rust/tests/WindowsLauncher.Tests.ps1 +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -17,7 +17,9 @@ BeforeAll { param( [string] $Path, [string[]] $Arguments, - [System.Text.Encoding] $Encoding = [System.Text.Encoding]::Unicode + [System.Text.Encoding] $OutputEncoding = [System.Text.Encoding]::UTF8, + [System.Text.Encoding] $ErrorEncoding = [System.Text.Encoding]::Unicode, + [string] $InputText ) $process = [System.Diagnostics.Process]::new() $started = $false @@ -27,13 +29,18 @@ BeforeAll { $process.StartInfo.CreateNoWindow = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true - $process.StartInfo.StandardOutputEncoding = $Encoding - $process.StartInfo.StandardErrorEncoding = $Encoding + $process.StartInfo.RedirectStandardInput = $PSBoundParameters.ContainsKey('InputText') + $process.StartInfo.StandardOutputEncoding = $OutputEncoding + $process.StartInfo.StandardErrorEncoding = $ErrorEncoding foreach ($argument in $Arguments) { $process.StartInfo.ArgumentList.Add($argument) } $started = $process.Start() if (-not $started) { throw 'Launcher did not start.' } $stdout = $process.StandardOutput.ReadToEndAsync() $stderr = $process.StandardError.ReadToEndAsync() + if ($process.StartInfo.RedirectStandardInput) { + $process.StandardInput.Write($InputText) + $process.StandardInput.Close() + } if (-not $process.WaitForExit(30000)) { throw 'Launcher did not finish within 30 seconds.' } @@ -103,6 +110,15 @@ Describe 'Packaged Rust Windows launcher' { $result = Invoke-Launcher -Path $ExecutablePath -Arguments $Argument $result.ExitCode | Should -Be -1 -Because "$($result.Output) $($result.Error)" $result.Output | Should -Match 'USAGE: powershell' + $result.Output | Should -Not -Match '\x00' + } + + It 'keeps redirected debug prompts and help byte-oriented' { + $result = Invoke-Launcher -Path $ExecutablePath -Arguments @('-Debug', '-?') -InputText "`n" + $result.ExitCode | Should -Be -1 -Because "$($result.Output) $($result.Error)" + $result.Output | Should -Match 'Attach the debugger' + $result.Output | Should -Match 'USAGE: powershell' + $result.Output | Should -Not -Match '\x00' } It 'reports a missing runtime instead of succeeding' { @@ -119,7 +135,7 @@ Describe 'Packaged Rust Windows launcher' { ) { $resultFile = Join-Path $TestDrive "arguments-$ExitCode.json" $expected = @('', 'two words', 'quote"value', 'trailing\', "$([char]0x03bb)$([char]::ConvertFromUtf32(0x1f600))") - $result = Invoke-Launcher -Path $coreClrExecutable -Encoding ([System.Text.Encoding]::UTF8) -Arguments (@( + $result = Invoke-Launcher -Path $coreClrExecutable -ErrorEncoding ([System.Text.Encoding]::UTF8) -Arguments (@( '-NoLogo', '-NoProfile', '-NonInteractive', '-File', $argumentScript, $resultFile, "-ExitCode:$ExitCode" ) + $expected) $result.ExitCode | Should -Be $ExitCode -Because "$($result.Output) $($result.Error)" @@ -131,6 +147,39 @@ Describe 'Packaged Rust Windows launcher' { $actual.Arguments[$index] | Should -BeExactly $expected[$index] } } + + It 'keeps redirected verbose diagnostics and managed stdout in the same byte stream' { + $marker = "managed-$([char]0x03bb)$([char]::ConvertFromUtf32(0x1f600))" + $command = "[Console]::OutputEncoding = [Text.UTF8Encoding]::new(`$false); [Console]::WriteLine('$marker'); exit 37" + $result = Invoke-Launcher -Path $coreClrExecutable -ErrorEncoding ([System.Text.Encoding]::UTF8) -Arguments @( + '-Verbose', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $command + ) + $result.ExitCode | Should -Be 37 -Because "$($result.Output) $($result.Error)" + $result.Output | Should -BeExactly "$marker`r`nExecution succeeded`r`n" + $result.Output | Should -Not -Match '\x00' + } + + It 'loads an external payload from BOM-prefixed configuration starting with ' -ForEach @( + @{ FirstSetting = 'PSHOMEDIR' } + @{ FirstSetting = 'CORECLRDIR' } + ) { + $directory = Join-Path $TestDrive "configured-$FirstSetting" + New-Item -ItemType Directory $directory > $null + $configuredExecutable = Join-Path $directory 'powershell.exe' + Copy-Item $ExecutablePath $configuredExecutable + $settings = if ($FirstSetting -eq 'PSHOMEDIR') { + "PSHOMEDIR=$coreClrHome`r`nCORECLRDIR=$coreClrHome`r`n" + } else { + "CORECLRDIR=$coreClrHome`r`nPSHOMEDIR=$coreClrHome`r`n" + } + $config = Join-Path $directory 'RemotePowerShellConfig.txt' + [System.IO.File]::WriteAllText($config, $settings, [System.Text.UTF8Encoding]::new($true)) + [BitConverter]::ToString([System.IO.File]::ReadAllBytes($config), 0, 3) | Should -Be 'EF-BB-BF' + $result = Invoke-Launcher -Path $configuredExecutable -Arguments @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 37' + ) + $result.ExitCode | Should -Be 37 -Because "$($result.Output) $($result.Error)" + } } else { It 'rejects malformed Framework options without requiring a managed payload' { $result = Invoke-Launcher -Path $ExecutablePath -Arguments @('-Version', 'invalid') diff --git a/rust/windows/README.md b/rust/windows/README.md index 0c76781..fff75b1 100644 --- a/rust/windows/README.md +++ b/rust/windows/README.md @@ -47,9 +47,13 @@ This legacy API resolves `Start` by name without an overload signature. PowerShell payloads with overloaded `UnmanagedPSEntry.Start` methods, including PowerShell 7, are not compatible with that preserved contract. `RemotePowerShellConfig.txt` can provide `PSHOMEDIR=` and `CORECLRDIR=`. +UTF-8 configuration files are accepted with or without a leading BOM. Both paths must exist before environment expansion, as in the original host. Otherwise the executable directory is used. TPA enumeration retains the native-DLL exclusion list and system CoreClrPowerShellExt fallback. +Native CoreCLR stdout (help, debugger prompt, and verbose messages) is +byte-oriented UTF-8 when redirected, so it can share a stream with managed +command output. Legacy resource diagnostics on stderr retain UTF-16LE. **.NET Framework:** the selected CLR and PowerShell registry configuration must be installed. The host retains `CorBindToRuntimeEx`, `ICorRuntimeHost`, default @@ -104,6 +108,8 @@ with SDK resources. Pester v5 checks the packaged executable's architecture, version resources, native CoreCLR help and missing-runtime errors, successful CoreCLR command execution, Unicode/empty/quoted/spaced arguments, positive and negative managed exit codes, and native Framework argument rejection. +Regression coverage also checks BOM-prefixed external-payload configuration +in both setting orders and byte-oriented redirected help/debug/verbose output. Framework integration tests compile a COM-visible C# fixture with the installed .NET Framework compiler and exercise actual CLR startup, AppDomain activation, object unwrapping, argument marshaling, and signed managed exit codes on x86/x64. diff --git a/rust/windows/src/launcher.rs b/rust/windows/src/launcher.rs index 4a70dc2..2b6b15b 100644 --- a/rust/windows/src/launcher.rs +++ b/rust/windows/src/launcher.rs @@ -1,20 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::win::{message, output, wide, GetModuleHandleW, Result, SetThreadUILanguage}; +use crate::win::{message, output, GetModuleHandleW, Result, SetThreadUILanguage}; #[cfg(feature = "coreclr")] use crate::{ common::HostEnvironment, hosting::{CoreClr, CONSOLE_ASSEMBLY}, policy::INIT_FAILURE, - win::{status_code, Wide}, + win::{core_stdout, status_code, Wide}, }; #[cfg(not(feature = "coreclr"))] use crate::{ common::{console_version, Engine, RegistryKey}, hosting::{AutomationVariant, FrameworkClr}, policy::{parameter_matches, Version, BAD_COMMAND_LINE}, - win::ComApartment, + win::{wide, ComApartment}, }; #[cfg(feature = "coreclr")] use std::mem; @@ -84,17 +84,14 @@ fn core(arguments: &[Vec]) -> Result { } } if debug { - output( - &wide("Attach the debugger to powershell.exe and press Enter to continue\r\n"), - true, - ); + core_stdout("Attach the debugger to powershell.exe and press Enter to continue\r\n")?; let mut line = String::new(); std::io::stdin() .read_line(&mut line) .map_err(|_| INIT_FAILURE)?; } if help { - output(&wide("USAGE: powershell [-Verbose] [-Debug] [-File ] [-Command] \r\n\r\nCoreCLR is loaded beside powershell.exe or from RemotePowerShellConfig.txt.\r\n"), true); + core_stdout("USAGE: powershell [-Verbose] [-Debug] [-File ] [-Command] \r\n\r\nCoreCLR is loaded beside powershell.exe or from RemotePowerShellConfig.txt.\r\n")?; return Ok(u32::MAX); } let environment = HostEnvironment::discover()?; @@ -114,7 +111,7 @@ fn core(arguments: &[Vec]) -> Result { let code = status_code(unsafe { start(ptr::null(), managed.as_ptr(), count) }); host.shutdown()?; if verbose { - output(&wide("Execution succeeded\r\n"), true); + core_stdout("Execution succeeded\r\n")?; } Ok(code) } diff --git a/rust/windows/src/policy.rs b/rust/windows/src/policy.rs index 198191b..e45a4c8 100644 --- a/rust/windows/src/policy.rs +++ b/rust/windows/src/policy.rs @@ -115,6 +115,7 @@ pub fn exclude_tpa(name: &str) -> bool { /// Managed callers can use File.ReadLines and Directory.Exists for this legacy configuration. pub fn host_config(text: &str, mut exists: impl FnMut(&str) -> bool) -> Option<(String, String)> { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); let mut assemblies = None; let mut runtime = None; for line in text.lines().map(str::trim) { @@ -213,6 +214,25 @@ mod tests { ); } + #[test] + fn config_accepts_a_leading_utf8_bom_in_either_setting_order() { + for text in [ + "\u{feff}PSHOMEDIR=C:\\PowerShell\r\nCORECLRDIR=C:\\CLR\r\n", + "\u{feff}CORECLRDIR=C:\\CLR\r\nPSHOMEDIR=C:\\PowerShell\r\n", + ] { + assert_eq!( + host_config(text, |_| true), + Some(("C:\\PowerShell".into(), "C:\\CLR".into())) + ); + } + assert_eq!( + host_config("PSHOMEDIR=C:\\\u{feff}payload\nCORECLRDIR=C:\\CLR", |_| { + true + }), + Some(("C:\\\u{feff}payload".into(), "C:\\CLR".into())) + ); + } + #[test] fn config_checks_unexpanded_environment_variable_path() { let mut observed = Vec::new(); diff --git a/rust/windows/src/win.rs b/rust/windows/src/win.rs index 8d12af4..bf13d24 100644 --- a/rust/windows/src/win.rs +++ b/rust/windows/src/win.rs @@ -269,8 +269,8 @@ pub fn message(module: Ptr, code: u32, arguments: &[usize]) -> Vec { buffer } -/// Managed callers should use Console.Write/Console.Error.Write. This preserves legacy UTF-16 -/// redirected output rather than silently changing the existing native host's encoding. +/// Managed callers should use Console.Write/Console.Error.Write. Legacy resource diagnostics +/// remain UTF-16 when redirected; `CoreCLR` stdout uses `core_stdout` instead. pub fn output(text: &[u16], stdout: bool) { let count = text.iter().position(|c| *c == 0).unwrap_or(text.len()); unsafe { @@ -308,6 +308,26 @@ pub fn output(text: &[u16], stdout: bool) { } } +/// Managed callers should use Console.Write. Share byte-oriented UTF-8 output with the +/// managed `CoreCLR` payload when redirected; Rust handles Unicode console output directly. +/// +/// # Errors +/// Returns the native I/O error if stdout cannot be written or flushed. +#[cfg(feature = "coreclr")] +pub fn core_stdout(text: &str) -> Result<()> { + use std::io::Write; + + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(text.as_bytes()) + .and_then(|()| stdout.flush()) + .map_err(|error| { + error + .raw_os_error() + .map_or(crate::policy::INIT_FAILURE, status_code) + }) +} + // HRESULTs and process statuses are signed/unsigned views of the same 32 bits. pub fn status_code(value: i32) -> u32 { u32::from_ne_bytes(value.to_ne_bytes()) diff --git a/rust/windows/tests/launcher-smoke.ps1 b/rust/windows/tests/launcher-smoke.ps1 index 45ca144..c2e12ad 100644 --- a/rust/windows/tests/launcher-smoke.ps1 +++ b/rust/windows/tests/launcher-smoke.ps1 @@ -26,7 +26,11 @@ try { $process.StartInfo.CreateNoWindow = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true - $process.StartInfo.StandardOutputEncoding = [System.Text.Encoding]::Unicode + $process.StartInfo.StandardOutputEncoding = if ($Configuration -eq 'coreclr') { + [System.Text.Encoding]::UTF8 + } else { + [System.Text.Encoding]::Unicode + } $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::Unicode $arguments = if ($Configuration -eq 'coreclr') { @('-?') From 439f1bed26803787744c4e4529f00ddf7e1ff951 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 10 Nov 2026 19:51:20 -0800 Subject: [PATCH 09/10] Close safe COM boundaries and preserve symlinked assemblies Make Automation storage and arbitrary COM queries private, add compile-fail API regressions, follow DLL symlinks, and exercise payload-independent launcher smoke checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/tests/WindowsLauncher.Tests.ps1 | 27 +++++++++++++++-- rust/windows/README.md | 9 ++++-- rust/windows/src/common.rs | 2 +- rust/windows/src/hosting.rs | 42 ++++++++++++++++++++++----- rust/windows/tests/launcher-smoke.ps1 | 8 ++--- 5 files changed, 71 insertions(+), 17 deletions(-) diff --git a/rust/tests/WindowsLauncher.Tests.ps1 b/rust/tests/WindowsLauncher.Tests.ps1 index e11ed7a..778ef01 100644 --- a/rust/tests/WindowsLauncher.Tests.ps1 +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -102,6 +102,11 @@ Describe 'Packaged Rust Windows launcher' { Join-Path (Split-Path $ExecutablePath) 'pwrshplugin.dll' | Should -Not -Exist } + It 'passes the documented native smoke script without requiring a managed payload' { + & "$PSScriptRoot/../windows/tests/launcher-smoke.ps1" -ExecutablePath $ExecutablePath -Configuration $Configuration | + Should -Be 'Launcher resources and native smoke checks passed.' + } + if ($Configuration -eq 'coreclr') { It 'prints native help for without requiring CoreCLR' -ForEach @( @{ Argument = '-?' } @@ -159,6 +164,24 @@ Describe 'Packaged Rust Windows launcher' { $result.Output | Should -Not -Match '\x00' } + It 'starts CoreCLR when required managed assemblies are symbolic links' { + $linkedHome = Join-Path $TestDrive 'symlinked-coreclr' + New-Item -ItemType Directory $linkedHome > $null + Copy-Item "$coreClrHome/*" $linkedHome -Recurse + $targets = Join-Path $TestDrive 'assembly-targets' + New-Item -ItemType Directory $targets > $null + foreach ($name in @('System.Private.CoreLib.dll', 'Microsoft.PowerShell.ConsoleHost.dll')) { + $link = Join-Path $linkedHome $name + $target = Join-Path $targets $name + Move-Item -LiteralPath $link -Destination $target + New-Item -ItemType SymbolicLink -Path $link -Target $target > $null + } + $result = Invoke-Launcher -Path (Join-Path $linkedHome 'powershell.exe') -Arguments @( + '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 37' + ) + $result.ExitCode | Should -Be 37 -Because "$($result.Output) $($result.Error)" + } + It 'loads an external payload from BOM-prefixed configuration starting with ' -ForEach @( @{ FirstSetting = 'PSHOMEDIR' } @{ FirstSetting = 'CORECLRDIR' } @@ -183,8 +206,8 @@ Describe 'Packaged Rust Windows launcher' { } else { It 'rejects malformed Framework options without requiring a managed payload' { $result = Invoke-Launcher -Path $ExecutablePath -Arguments @('-Version', 'invalid') - $result.ExitCode | Should -Not -Be 0 - $result.Error | Should -Match 'PowerShell native host error' + $result.ExitCode | Should -Be -196608 + $result.Error | Should -Match 'PowerShell native host error 0xfffd0000' } } } diff --git a/rust/windows/README.md b/rust/windows/README.md index fff75b1..974f88f 100644 --- a/rust/windows/README.md +++ b/rust/windows/README.md @@ -102,7 +102,8 @@ cargo clippy -p powershell-native-windows --all-targets --no-default-features -- Portable tests cover version formats/overflow, registry-family mappings, parameter abbreviations/Unicode dashes, native TPA exclusions, and configuration fallback. Windows-only tests cover Automation layout, null dispatch/unknown -variants, and launcher behavior. +variants, and launcher behavior. Compile-fail documentation tests enforce private +variant storage and prevent arbitrary COM interface queries through the safe API. The Windows CI jobs build/link both launcher configurations and architectures with SDK resources. Pester v5 checks the packaged executable's architecture, version resources, native CoreCLR help and missing-runtime errors, successful @@ -110,6 +111,9 @@ CoreCLR command execution, Unicode/empty/quoted/spaced arguments, positive and negative managed exit codes, and native Framework argument rejection. Regression coverage also checks BOM-prefixed external-payload configuration in both setting orders and byte-oriented redirected help/debug/verbose output. +CoreCLR startup is also tested with symlinked core-library and console-host +assemblies; creating Windows symbolic links requires Developer Mode or the +symbolic-link privilege. Framework integration tests compile a COM-visible C# fixture with the installed .NET Framework compiler and exercise actual CLR startup, AppDomain activation, object unwrapping, argument marshaling, and signed managed exit codes on x86/x64. @@ -128,7 +132,8 @@ Install-Module Pester -RequiredVersion 5.9.0 -Scope CurrentUser -Repository PSGa CI packages these launchers under `artifacts/rust/win--/`, separate from the existing C++ CoreCLR binaries and WSMan plugin under `artifacts/cpp/win--coreclr/`. Pester NUnit XML is retained on failures. -The lightweight manual smoke script is also available: +The lightweight manual smoke script checks CoreCLR help or Framework native +argument rejection, without requiring an installed managed PowerShell payload: ```powershell ./windows/tests/launcher-smoke.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration coreclr diff --git a/rust/windows/src/common.rs b/rust/windows/src/common.rs index 2893f79..62f3887 100644 --- a/rust/windows/src/common.rs +++ b/rust/windows/src/common.rs @@ -247,7 +247,7 @@ impl HostEnvironment { let entry = entry.ok()?; let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); - if entry.file_type().ok()?.is_file() + if path.is_file() && name.to_ascii_lowercase().ends_with(".dll") && !exclude_tpa(&name) { diff --git a/rust/windows/src/hosting.rs b/rust/windows/src/hosting.rs index cc9d62c..a1fd96c 100644 --- a/rust/windows/src/hosting.rs +++ b/rust/windows/src/hosting.rs @@ -162,18 +162,31 @@ struct ArrayBound { } #[repr(C)] -pub union VariantValue { - pub pointer: Ptr, - pub signed: i64, - pub int: i32, - pub uint: u32, +union VariantValue { + pointer: Ptr, + // Preserve native VARIANT alignment on 32-bit Windows. + _signed: i64, + uint: u32, layout: [usize; 2], } +/// An owning Automation value whose tag and payload can only be set by this module. +/// +/// ```compile_fail,E0616 +/// use powershell_native_host::hosting::AutomationVariant; +/// let mut value = AutomationVariant::default(); +/// value.kind = 8; +/// ``` +/// +/// ```compile_fail,E0616 +/// use powershell_native_host::hosting::AutomationVariant; +/// let value = AutomationVariant::default(); +/// let _ = &value.value; +/// ``` #[repr(C)] pub struct AutomationVariant { - pub kind: u16, + kind: u16, reserved: [u16; 3], - pub value: VariantValue, + value: VariantValue, } impl Default for AutomationVariant { fn default() -> Self { @@ -328,6 +341,19 @@ const CLSID_COR_RUNTIME: GUID = GUID::from_u128(0xcb2f6723_ab3a_11d2_9c40_00c04f const IID_COR_RUNTIME: GUID = GUID::from_u128(0xcb2f6722_ab3a_11d2_9c40_00c04fa30a3e); const IID_NULL: GUID = GUID::zeroed(); +/// An owning COM reference. Public APIs only return dispatch interfaces; +/// arbitrary interface queries remain internal. +/// +/// ```compile_fail,E0624 +/// use powershell_native_host::hosting::ComObject; +/// use windows::core::GUID; +/// fn cannot_invoke_unknown(object: &ComObject) { +/// let unknown = object.query( +/// &GUID::from_u128(0x00000000_0000_0000_c000_000000000046) +/// ).unwrap(); +/// let _ = unknown.invoke("Start", &mut []); +/// } +/// ``` pub struct ComObject(Ptr); impl ComObject { unsafe fn slot(&self, index: usize) -> Ptr { @@ -335,7 +361,7 @@ impl ComObject { } /// # Errors /// Returns the HRESULT if the requested COM interface is unavailable. - pub fn query(&self, iid: &GUID) -> Result { + fn query(&self, iid: &GUID) -> Result { let query: unsafe extern "system" fn(Ptr, *const GUID, *mut Ptr) -> i32 = unsafe { mem::transmute(self.slot(0)) }; let mut result = ptr::null_mut(); diff --git a/rust/windows/tests/launcher-smoke.ps1 b/rust/windows/tests/launcher-smoke.ps1 index c2e12ad..2ece8da 100644 --- a/rust/windows/tests/launcher-smoke.ps1 +++ b/rust/windows/tests/launcher-smoke.ps1 @@ -35,7 +35,7 @@ try { $arguments = if ($Configuration -eq 'coreclr') { @('-?') } else { - @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 37') + @('-Version', 'invalid') } foreach ($argument in $arguments) { $process.StartInfo.ArgumentList.Add($argument) @@ -55,8 +55,8 @@ try { if ($process.ExitCode -ne -1 -or $output -notmatch 'USAGE: powershell') { throw "CoreCLR native help failed ($($process.ExitCode)): $output $errorText" } - } elseif ($process.ExitCode -ne 37) { - throw "Framework host did not execute managed PowerShell ($($process.ExitCode)): $output $errorText" + } elseif ($process.ExitCode -ne -196608 -or $errorText -notmatch 'PowerShell native host error 0xfffd0000') { + throw "Framework native argument rejection failed ($($process.ExitCode)): $output $errorText" } } finally { if ($started -and -not $process.HasExited) { @@ -65,4 +65,4 @@ try { } $process.Dispose() } -'Launcher resources and startup smoke checks passed.' +'Launcher resources and native smoke checks passed.' From fc065b929fb9c9383cc45a6ef9917e14ce216bb2 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Wed, 11 Nov 2026 15:45:19 -0800 Subject: [PATCH 10/10] Serialize macOS test forks to avoid libSystem initialization races Prevent concurrent native test forks from crashing in macOS child-side libSystem initialization before exec. Keep other tests parallel and report child wait status before asserting SIGINT output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/libpsl-native/tests/native.rs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/rust/libpsl-native/tests/native.rs b/rust/libpsl-native/tests/native.rs index d7f1fe1..17af8bd 100644 --- a/rust/libpsl-native/tests/native.rs +++ b/rust/libpsl-native/tests/native.rs @@ -13,6 +13,10 @@ use std::path::{Path, PathBuf}; use std::ptr; use std::sync::atomic::{AtomicU64, Ordering}; +// Concurrent first forks can crash macOS libSystem's child-side initialization. +#[cfg(target_os = "macos")] +static FORK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + struct Fixture(PathBuf); impl Fixture { @@ -263,6 +267,8 @@ fn error_category_mapping() { } unsafe fn spawn(args: &[&CStr], cwd: Option<&CStr>, flags: i32) -> (i32, i32, i32, i32) { + #[cfg(target_os = "macos")] + let _fork_guard = FORK_LOCK.lock().unwrap(); let mut pointers: Vec<_> = args.iter().map(|arg| arg.as_ptr()).collect(); pointers.push(ptr::null()); let env = [c"PSL_RUST_TEST=present".as_ptr(), ptr::null()]; @@ -336,9 +342,17 @@ fn child_only_sigint_suppression() { let (pid, input, output, error) = spawn(&[c"sh", c"-c", c"kill -INT $$; printf survived"], None, 1); libc::close(input); - assert_eq!(read_fd(output), "survived"); - assert_eq!(read_fd(error), ""); - assert_eq!(WaitPid(pid, false), pid); + let text = read_fd(output); + let diagnostic = read_fd(error); + let mut status = 0; + assert_eq!(libc::waitpid(pid, &raw mut status, 0), pid); + assert!( + libc::WIFEXITED(status), + "child terminated abnormally (wait status {status:#x}): {diagnostic}" + ); + assert_eq!(libc::WEXITSTATUS(status), 0, "{diagnostic}"); + assert_eq!(text, "survived"); + assert_eq!(diagnostic, ""); let mut after: libc::sigaction = std::mem::zeroed(); assert_eq!( libc::sigaction(libc::SIGINT, ptr::null(), &raw mut after), @@ -351,6 +365,8 @@ fn child_only_sigint_suppression() { #[test] fn failed_exec_exits_without_returning_into_the_host() { + #[cfg(target_os = "macos")] + let _fork_guard = FORK_LOCK.lock().unwrap(); let fixture = Fixture::new(); let invalid_program = fixture.path("not-an-executable-image"); fs::write(&invalid_program, b"not an executable").unwrap(); @@ -384,7 +400,10 @@ fn failed_exec_exits_without_returning_into_the_host() { assert_eq!((input, output, error), (-1, -1, -1)); let mut status = 0; assert_eq!(libc::waitpid(pid, &raw mut status, 0), pid); - assert!(libc::WIFEXITED(status)); + assert!( + libc::WIFEXITED(status), + "child terminated abnormally (wait status {status:#x})" + ); assert_eq!(libc::WEXITSTATUS(status), libc::ENOEXEC); } }