diff --git a/.github/workflows/rust-native.yml b/.github/workflows/rust-native.yml new file mode 100644 index 0000000..db4503d --- /dev/null +++ b/.github/workflows/rust-native.yml @@ -0,0 +1,276 @@ +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++ 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 + 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: | + $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' + 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 "$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 \ + -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 + $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 + $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: | + $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 @parameters + - 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: | + $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() + 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..12a8248 --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,17 @@ +[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"] + +[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/.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..8d4f5f9 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,192 @@ +# 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 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, successful CoreCLR +startup with matching legacy PowerShell 6.0.5 payloads, argument preservation, +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. + +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.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 -CoreClrPayloadPath C:/payloads/powershell-6.0.5-win-x64 -ResultPath ./test-results/windows.xml +``` + +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/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..17af8bd --- /dev/null +++ b/rust/libpsl-native/tests/native.rs @@ -0,0 +1,460 @@ +// 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}; + +// 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 { + 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() { + 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(&owned_file).as_ptr())), + take_string(GetPwUid(fs::metadata(&owned_file).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) { + #[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()]; + 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); + 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), + 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() { + #[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(); + 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), + "child terminated abnormally (wait status {status:#x})" + ); + 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..7349d10 --- /dev/null +++ b/rust/tests/Invoke-Tests.ps1 @@ -0,0 +1,59 @@ +# 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(ParameterSetName = 'Windows')] + [string] $CoreClrPayloadPath, + [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.' } + 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 + CoreClrPayloadPath = $CoreClrPayloadPath + } + } +} +$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..778ef01 --- /dev/null +++ b/rust/tests/WindowsLauncher.Tests.ps1 @@ -0,0 +1,213 @@ +# 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, + [string] $CoreClrPayloadPath +) + +BeforeAll { + function Invoke-Launcher { + param( + [string] $Path, + [string[]] $Arguments, + [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 + try { + $process.StartInfo.FileName = $Path + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $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.' + } + @{ + 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() + } + } + 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' { + 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 + } + + 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 = '-?' } + @{ Argument = '/?' } + ) { + $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' { + $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' + } + + 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 -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)" + $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] + } + } + + 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 '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' } + ) { + $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') + $result.ExitCode | Should -Be -196608 + $result.Error | Should -Match 'PowerShell native host error 0xfffd0000' + } + } +} 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..974f88f --- /dev/null +++ b/rust/windows/README.md @@ -0,0 +1,149 @@ +# 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 +# Run from rust/ so Cargo loads .cargo/config.toml, including static CRT linkage. +Set-Location rust + +# CoreCLR launcher (default). +cargo build -p powershell-native-windows --release --target x86_64-pc-windows-msvc + +# Legacy .NET Framework launcher. +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. 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 +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. +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 +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 +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 +# 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, null dispatch/unknown +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 +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. +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 +before running CoreCLR tests locally: + +```powershell +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--/`, +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 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 +# After building the Framework configuration: +./windows/tests/launcher-smoke.ps1 -ExecutablePath ./target/x86_64-pc-windows-msvc/release/powershell.exe -Configuration framework +``` + +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. +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..62f3887 --- /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 path.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..a1fd96c --- /dev/null +++ b/rust/windows/src/hosting.rs @@ -0,0 +1,663 @@ +// 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)] +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 { + kind: u16, + reserved: [u16; 3], + 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 pointer = unsafe { self.value.pointer }; + if pointer.is_null() { + 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 + } +} + +#[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 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(); + +/// 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 { + *(*(self.0 as *const *const Ptr)).add(index) + } + /// # Errors + /// Returns the HRESULT if the requested COM interface is unavailable. + 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 { + output( + &wide(&format!("COM interface query failed: {iid:?}\r\n")), + false, + ); + 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_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() + } +} + +#[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_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_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(); + assert!(matches!(variant.dispatch(), Err(0x8002_0005))); + } + + #[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..2b6b15b --- /dev/null +++ b/rust/windows/src/launcher.rs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::win::{message, output, GetModuleHandleW, Result, SetThreadUILanguage}; +#[cfg(feature = "coreclr")] +use crate::{ + common::HostEnvironment, + hosting::{CoreClr, CONSOLE_ASSEMBLY}, + policy::INIT_FAILURE, + 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::{wide, 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 { + 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 { + 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()?; + 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 { + core_stdout("Execution succeeded\r\n")?; + } + 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..e45a4c8 --- /dev/null +++ b/rust/windows/src/policy.rs @@ -0,0 +1,248 @@ +// 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 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) { + 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_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(); + 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..bf13d24 --- /dev/null +++ b/rust/windows/src/win.rs @@ -0,0 +1,334 @@ +// 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. 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 { + 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; + } + } +} + +/// 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/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..4b01d97 --- /dev/null +++ b/rust/windows/tests/framework.rs @@ -0,0 +1,78 @@ +// 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") + .join("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()) + ); + } +} diff --git a/rust/windows/tests/launcher-smoke.ps1 b/rust/windows/tests/launcher-smoke.ps1 new file mode 100644 index 0000000..2ece8da --- /dev/null +++ b/rust/windows/tests/launcher-smoke.ps1 @@ -0,0 +1,68 @@ +# 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 = 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') { + @('-?') + } else { + @('-Version', 'invalid') + } + 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 -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) { + $process.Kill($true) + $process.WaitForExit() + } + $process.Dispose() +} +'Launcher resources and native smoke checks passed.'