Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion chef-powershell/lib/chef-powershell/powershell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@
require_relative "unicode"

class ChefPowerShell
module Kernel32
extend FFI::Library
ffi_lib "kernel32"
attach_function :SetDllDirectoryA, %i{string}, :int
attach_function :SetDefaultDllDirectories, %i{uint32}, :int
attach_function :AddDllDirectory, %i{pointer}, :pointer

# https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-setdefaultdlldirectories
LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000

# AddDllDirectory requires a wide (UTF-16LE), null-terminated string (LPCWSTR).
# Returns true if the directory was registered successfully.
def self.register_search_directory(path)
wide_path = (path + "\0").encode("UTF-16LE")
ptr = FFI::MemoryPointer.new(:uint8, wide_path.bytesize)
ptr.put_bytes(0, wide_path)
!(AddDllDirectory(ptr).address == 0)
end
end

def self.bin_dir
if ENV["CHEF_POWERSHELL_BIN"] && !ENV["CHEF_POWERSHELL_BIN"].empty?
return File.expand_path(ENV["CHEF_POWERSHELL_BIN"])
Expand Down Expand Up @@ -59,7 +79,27 @@ def initialize(script, timeout: -1)
# Every merge into that repo triggers a Habitat build and verification process.
# There is no mechanism to build a Windows gem file. It has to be done manually running manual_gem_release.ps1
# Bundle install ensures that the correct architecture binaries are installed into the path.
@powershell_dll = File.join(ChefPowerShell.bin_dir, "Chef.PowerShell.Wrapper.dll")
bin_dir = ChefPowerShell.bin_dir
@powershell_dll = File.join(bin_dir, "Chef.PowerShell.Wrapper.dll")

# Windows' default LoadLibrary(Ex) search order used by FFI does NOT
# include the directory of the DLL being loaded -- FFI loads on Windows
# with LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, which only searches the hosting
# EXE's directory, System32, and directories registered via
# SetDllDirectory/AddDllDirectory (notably *not* PATH or the current
# directory). Chef.PowerShell.Wrapper.dll depends on native VC++ runtime
# DLLs (vcruntime140.dll, msvcp140.dll, etc.) that live alongside it in
# bin_dir, so that directory must be registered explicitly or those
# dependent DLLs will fail to resolve (error 126) even though the
# wrapper DLL itself loads fine via its absolute path.
Kernel32.SetDefaultDllDirectories(Kernel32::LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)
raise LoadError, "Failed to register DLL search directory: #{bin_dir}" unless Kernel32.register_search_directory(bin_dir)

# Retained alongside AddDllDirectory for defense in depth / older Windows
# compatibility (AddDllDirectory requires Windows 8+/Server 2012+, or
# Windows 7 SP1/Server 2008 R2 SP1 with KB2533623).
Kernel32.SetDllDirectoryA(bin_dir)

exec(script, timeout: timeout)
end

Expand Down
20 changes: 0 additions & 20 deletions chef-powershell/lib/chef-powershell/pwsh.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,6 @@

class ChefPowerShell
class Pwsh < ChefPowerShell::PowerShell
module Kernel32
extend FFI::Library
ffi_lib "kernel32"
attach_function :SetDllDirectoryA, %i{string}, :int
attach_function :SetDefaultDllDirectories, %i{uint32}, :int
attach_function :AddDllDirectory, %i{pointer}, :pointer

# https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-setdefaultdlldirectories
LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000

# AddDllDirectory requires a wide (UTF-16LE), null-terminated string (LPCWSTR).
# Returns true if the directory was registered successfully.
def self.register_search_directory(path)
wide_path = (path + "\0").encode("UTF-16LE")
ptr = FFI::MemoryPointer.new(:uint8, wide_path.bytesize)
ptr.put_bytes(0, wide_path)
!(AddDllDirectory(ptr).address == 0)
end
end

# Run a command under pwsh (powershell core) via FFI
# This implementation requires the managed dll, native wrapper and a
# published, self contained dotnet core directory tree to exist in the
Expand Down
102 changes: 99 additions & 3 deletions chef-powershell/spec/integration/dll_integration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
# If CHEF_POWERSHELL_BIN is not set, the spec falls back to the gem's own bin/ruby_bin_folder
# (useful when running from a fully-installed gem).

require "bundler"
require "open3"
require "tempfile"

# Snapshot CHEF_POWERSHELL_BIN *before* require "chef-powershell", because
# powershell_exec.rb unconditionally overwrites it with the gem's own bin path
# at module load time.
Expand Down Expand Up @@ -77,9 +81,9 @@ def raw_pwsh(script, timeout: -1)
# nested runtime dir alongside the DLL -- register both explicitly. Do not
# rely on System32 already having the VC++ redistributable.
core_dir = File.dirname(NET10_DLL)
ChefPowerShell::Pwsh::Kernel32.SetDefaultDllDirectories(ChefPowerShell::Pwsh::Kernel32::LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)
[DLL_BIN_DIR, core_dir].each { |dir| ChefPowerShell::Pwsh::Kernel32.register_search_directory(dir) }
ChefPowerShell::Pwsh::Kernel32.SetDllDirectoryA(core_dir)
ChefPowerShell::Kernel32.SetDefaultDllDirectories(ChefPowerShell::Kernel32::LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)
[DLL_BIN_DIR, core_dir].each { |dir| ChefPowerShell::Kernel32.register_search_directory(dir) }
ChefPowerShell::Kernel32.SetDllDirectoryA(core_dir)

ps = ChefPowerShell::Pwsh.allocate
ps.instance_variable_set(:@powershell_dll, NET10_DLL)
Expand Down Expand Up @@ -361,3 +365,95 @@ def raw_pwsh(script, timeout: -1)
expect(ENV["DOTNET_ROOT"]).to eq(original)
end
end

# ---------------------------------------------------------------------------
# Regression: PowerShell#initialize must register its own DLL search directory
# ---------------------------------------------------------------------------
#
# Background: AddDllDirectory/SetDefaultDllDirectories mutate GLOBAL,
# PROCESS-WIDE Windows search-path state that is never unregistered. Because
# this entire spec file runs in a single rspec process, a missing directory
# registration in one interpreter's init path can be silently masked by
# another interpreter's init path having already registered the very same
# directory earlier in the run (raw_powershell/raw_pwsh above share DLL_BIN_DIR).
#
# This previously hid a real bug: PowerShell#initialize (the Windows
# PowerShell / .NET Framework 4.8.1 path) never registered `bin_dir` as a DLL
# search directory -- only Pwsh#exec (.NET 10 / PowerShell Core path) did.
# Production usage (e.g. Chef-18) only ever constructs a `:powershell`
# interpreter in a single-purpose process, so no such masking occurs there --
# it failed with error 126 because the native CRT dependencies
# (vcruntime140.dll, msvcp140.dll, etc.) could not be resolved.
#
# NOTE: on a dev/CI machine that already has the VC++ redistributable
# installed under System32 (part of the classic DLL search order regardless
# of AddDllDirectory registration), removing the registration call will NOT
# reproduce the failure -- System32 quietly satisfies the dependency. The
# behavioral spec below ("registers bin_dir...") is what actually catches a
# regression deterministically, on any machine. This subprocess test instead
# guards against the *masking* mechanism itself: it proves the real
# constructor (not `.allocate`) succeeds end-to-end in a fresh, single-purpose
# process with no possibility of state bleed from other examples in this
# suite -- the same condition production code runs under.
RSpec.describe "PowerShell#initialize DLL directory registration (process isolation)", :windows_only do
before(:all) do
skip "NET481 DLL not found at #{NET481_DLL}" unless File.exist?(NET481_DLL)
end

it "constructs ChefPowerShell::PowerShell and resolves native DLL dependencies in an otherwise-empty process" do
gem_root = File.expand_path("../..", __dir__)

script = <<~'RUBY'
require "chef-powershell"
result = ChefPowerShell::PowerShell.new("$PSVersionTable", timeout: -1)
raise "errors: #{result.errors}" unless result.errors.empty?
unless result.result["PSEdition"] == "Desktop"
raise "unexpected PSEdition: #{result.result["PSEdition"].inspect}"
end

puts "OK"
RUBY

env = { "CHEF_POWERSHELL_BIN" => DLL_BIN_DIR }

# Write the script to a real file rather than passing it via `ruby -e`.
# On Windows, `bundle` resolves to a .bat shim, so the OS re-invokes the
# child through cmd.exe -- which treats embedded newlines in a command-line
# argument as command separators and silently truncates a multi-line -e
# script at the first newline.
stdout, stderr, status = Tempfile.create(["dll_isolation_check", ".rb"]) do |file|
file.write(script)
file.close

Bundler.with_unbundled_env do
Open3.capture3(env, "bundle", "exec", "ruby", file.path, chdir: gem_root)
end
end

expect(status.success?).to be(true),
"subprocess failed (exit #{status.exitstatus}):\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}"
expect(stdout).to include("OK")
end

# Deterministic regression coverage: assert the actual Kernel32 calls happen,
# rather than relying on native DLL resolution failing -- which depends on
# whether the VC++ redistributable happens to already be installed on the
# machine running the suite (see NOTE above).
it "registers bin_dir as a DLL search directory before executing" do
orig_bin = ENV["CHEF_POWERSHELL_BIN"]
ENV["CHEF_POWERSHELL_BIN"] = DLL_BIN_DIR

allow(ChefPowerShell::Kernel32).to receive(:SetDefaultDllDirectories).and_call_original
allow(ChefPowerShell::Kernel32).to receive(:register_search_directory).and_call_original
allow(ChefPowerShell::Kernel32).to receive(:SetDllDirectoryA).and_call_original

ChefPowerShell::PowerShell.new("$PSVersionTable", timeout: -1)

expect(ChefPowerShell::Kernel32).to have_received(:SetDefaultDllDirectories)
.with(ChefPowerShell::Kernel32::LOAD_LIBRARY_SEARCH_DEFAULT_DIRS)
expect(ChefPowerShell::Kernel32).to have_received(:register_search_directory).with(DLL_BIN_DIR)
expect(ChefPowerShell::Kernel32).to have_received(:SetDllDirectoryA).with(DLL_BIN_DIR)
ensure
ENV["CHEF_POWERSHELL_BIN"] = orig_bin
end
end
28 changes: 13 additions & 15 deletions habitat/plan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,29 @@ function Invoke-SetupEnvironment {
}

function Invoke-Build {
Copy-Item $PLAN_CONTEXT/../* $HAB_CACHE_SRC_PATH/$pkg_dirname -recurse -force -Exclude ".vs"
nuget restore $HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell/packages.config -PackagesDirectory $HAB_CACHE_SRC_PATH/$pkg_dirname/packages -Source "https://www.nuget.org/api/v2"
MSBuild $HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell.Wrapper/Chef.Powershell.Wrapper.vcxproj /t:Build /p:Configuration=Release /p:Platform=x64
Copy-Item $PLAN_CONTEXT/../* $HAB_CACHE_SRC_PATH/$pkg_dirname -Recurse -Force -Exclude ".vs"

nuget restore `
"$HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell/packages.config" `
-PackagesDirectory "$HAB_CACHE_SRC_PATH/$pkg_dirname/packages" `
-Source "https://www.nuget.org/api/v2"

$vsBuildToolsPath = "$(Get-HabPackagePath visual-build-tools-2026)\Contents"
$vcTargetsPath = "$vsBuildToolsPath\MSBuild\Microsoft\VC\v180\"
$msbuildExe = "$vsBuildToolsPath\MSBuild\Current\Bin\amd64\MSBuild.exe"

& $msbuildExe $HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell.Wrapper/Chef.Powershell.Wrapper.vcxproj /t:Build /p:Configuration=Release /p:Platform=x64
if($LASTEXITCODE -ne 0) {
Write-Error "dotnet build failed!"
}

$env:DOTNET_ROOT = "$(Get-HabPackagePath dotnet-10-sdk)\bin"

# Step 1: Build the managed .NET 10 core library (CoreCLR). Must use dotnet — VC++ MSBuild
# tasks are .NET Framework-only and cannot build .csproj in the same invocation as .vcxproj.
& "$env:DOTNET_ROOT\dotnet.exe" build $HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell.Core/Chef.Powershell.Core.csproj --configuration Release /p:Platform=x64
if($LASTEXITCODE -ne 0) {
Write-Error "dotnet core build failed!"
}

# Step 2: Build the C++/CLI wrapper with the 64-bit MSBuild.exe (.NET Framework).
# Must use amd64\MSBuild.exe — hostfxr.dll is 64-bit and cannot be loaded by a 32-bit process.
$vsBuildToolsPath = "$(Get-HabPackagePath visual-build-tools-2026)\Contents"
$vcTargetsPath = "$vsBuildToolsPath\MSBuild\Microsoft\VC\v180\"
$msbuildExe = "$vsBuildToolsPath\MSBuild\Current\Bin\amd64\MSBuild.exe"

# Locate the .NET 10 reference pack and host pack from the Hab dotnet-10-sdk package.
# These are passed to MSBuild so cl.exe and the linker can find the right binaries without
# relying on NuGet restore or workload resolution (both disabled via DisableImplicitFrameworkReferences).
$refPackRoot = "$env:DOTNET_ROOT\packs\Microsoft.NETCore.App.Ref"
$refVersion = (Get-ChildItem $refPackRoot | Sort-Object Name -Descending | Select-Object -First 1).Name
$refPackPath = "$refPackRoot\$refVersion\ref\net10.0"
Expand All @@ -54,7 +52,7 @@ function Invoke-Build {
$hostPackRoot = "$env:DOTNET_ROOT\packs\Microsoft.NETCore.App.Host.win-x64"
$hostPackVersion = (Get-ChildItem $hostPackRoot | Sort-Object Name -Descending | Select-Object -First 1).Name
$ijwHostSourcePath = "$hostPackRoot\$hostPackVersion\runtimes\win-x64\native\ijwhost.dll"
& $msbuildExe $HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell.Wrapper.Core/Chef.Powershell.Wrapper.Core.vcxproj /t:Build /p:Configuration=Release /p:Platform=x64 /p:BuildProjectReferences=false /p:VCTargetsPath="$vcTargetsPath" /p:DotNetSdkRoot="$env:DOTNET_ROOT" /p:DotNetCoreRefPackPath="$refPackPath" /p:IjwHostSourcePath="$ijwHostSourcePath" /p:DisableImplicitFrameworkReferences=true /p:GenerateRuntimeConfigurationFiles=false /nodeReuse:false
& $msbuildExe $HAB_CACHE_SRC_PATH/$pkg_dirname/Chef.Powershell.Wrapper.Core/Chef.Powershell.Wrapper.Core.vcxproj /t:Build /p:Configuration=Release /p:Platform=x64 /p:BuildProjectReferences=false /p:VCTargetsPath="$vcTargetsPath" /p:DotNetSdkRoot="$env:DOTNET_ROOT" /p:DotNetCoreRefPackPath="$refPackPath" /p:IjwHostSourcePath="$ijwHostSourcePath" /p:DisableImplicitFrameworkReferences=true /p:GenerateRuntimeConfigurationFiles=false /p:MSBuildEnableWorkloadResolver=false /nodeReuse:false
if($LASTEXITCODE -ne 0) {
Write-Error "dotnet core build failed!"
}
Expand Down
Loading