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
5 changes: 4 additions & 1 deletion New-LogEntry/Private/Get-NewLogEntryMutexName.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ function Get-NewLogEntryMutexName
{
param([string]$Path)

$normalizedPath = if ($IsWindows) { $Path.ToUpperInvariant() } else { $Path }
# Conservatively share locks for case aliases on macOS, including its
# common case-insensitive volumes. Case-sensitive macOS volumes may serialize
# distinct case-only filenames; that is safer than missing a shared file.
$normalizedPath = if ($IsWindows -or $IsMacOS) { $Path.ToUpperInvariant() } else { $Path }
$bytes = [System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($normalizedPath))
$hash = [Convert]::ToHexString($bytes).Substring(0, 32)

Expand Down
18 changes: 16 additions & 2 deletions New-LogEntry/Private/Resolve-NewLogEntryPath.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ function Resolve-NewLogEntryPath

if (-not [string]::IsNullOrWhiteSpace($Path))
{
return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
$provider = $null
$drive = $null
$resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath(
$Path, [ref]$provider, [ref]$drive
)
if ($provider.Name -ne 'FileSystem')
{
throw [ArgumentException]::new('LogFilePath must use the FileSystem provider.')
}
return $resolved
}

$basePath = if (-not [string]::IsNullOrWhiteSpace($CallerScriptPath))
Expand All @@ -13,7 +22,12 @@ function Resolve-NewLogEntryPath
}
else
{
Join-Path -Path (Get-Location).ProviderPath -ChildPath 'PowerShell'
$location = Get-Location
if ($location.Provider.Name -ne 'FileSystem')
{
throw [ArgumentException]::new('A filesystem LogFilePath is required when the current location uses another provider.')
}
Join-Path -Path $location.ProviderPath -ChildPath 'PowerShell'
}

$directory = Split-Path -Path $basePath -Parent
Expand Down
2 changes: 1 addition & 1 deletion New-LogEntry/Private/Write-NewLogEntryLines.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function Write-NewLogEntryLines
if (-not $hasLock)
{
throw [System.TimeoutException]::new(
'Timed out waiting {0} seconds for the log file lock: {1}' -f $LockTimeoutSeconds, $resolvedPath
('Timed out waiting {0} seconds for the log file lock: {1}' -f $LockTimeoutSeconds, $resolvedPath)
)
}

Expand Down
15 changes: 15 additions & 0 deletions New-LogEntry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,3 +239,18 @@ Released under the [MIT License](./LICENSE).
The New-LogEntry workflow runs the Pester suite on Windows, Linux, and macOS using each GitHub-hosted runner's installed PowerShell. It also checks PowerShell syntax and performs a separate loader smoke test. Pester is pinned to version 5.7.1.

PowerShell 7.1 is the API minimum because the mutex helper uses .NET 5 APIs. CI tests the runner versions, not every historical PowerShell release.

### Logger path and lock boundaries

Explicit log destinations must use the FileSystem provider. Non-filesystem paths
such as `Env:NAME` are rejected before file operations. Interactive calls from a
non-filesystem location require an explicit filesystem log destination; a calling
script can still supply the default directory through its own filesystem path.
A rejected flush preserves the buffer.

On Windows and macOS, case-only path aliases share a mutex. On case-sensitive Mac
volumes, distinct case-only filenames may therefore serialize unnecessarily.
Locks remain path-based: symlink/hard-link aliases are not resolved to file identity.
Upgrade all writers together: older macOS implementations can use different lock
names for lowercase paths. Keep one consistent path spelling during mixed-version
operation.
100 changes: 100 additions & 0 deletions New-LogEntry/Tests/LoggerPath.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
BeforeAll {
$loggerLoader = (Resolve-Path (Join-Path $PSScriptRoot '../New-LogEntry.ps1')).Path
$loggerModule = New-Module -Name LoggerPathStandalone -ArgumentList $loggerLoader -ScriptBlock {
param($Loader)
. $Loader
} | Import-Module -Force -PassThru
$loggerIsModule = $false
}
AfterAll { Remove-Module LoggerPathStandalone }
Describe 'Logger destination boundaries' {
BeforeEach { New-LogEntry -ClearBuffer }
It 'rejects non-filesystem destinations before creating a file: <Provider>' -ForEach @(
@{Provider='Env'}; @{Provider='Variable'}; @{Provider='Function'}
) {
$name = 'LoggerProbe' + [guid]::NewGuid().ToString('N')
$destination = '{0}:{1}' -f $Provider,$name
$originalDirectory = [Environment]::CurrentDirectory
try {
[Environment]::CurrentDirectory = $TestDrive
{ New-LogEntry -LogMessage 'must not be written' -LogFilePath $destination -NoConsole } | Should -Throw '*FileSystem*'
@(Get-ChildItem -LiteralPath $TestDrive -Filter $name -Force).Count | Should -Be 0
New-LogEntry -LogMessage 'keep buffered' -BufferOnly
{ New-LogEntry -FlushBuffer -LogFilePath $destination -NoConsole } | Should -Throw '*FileSystem*'
@(New-LogEntry -GetBuffer).Count | Should -Be 1
(New-LogEntry -GetBuffer) | Should -Match 'keep buffered$'
}
finally { [Environment]::CurrentDirectory = $originalDirectory }
}
It 'rejects an interactive default path from a non-filesystem location' {
Push-Location Env:
try {
{ & $loggerModule { Resolve-NewLogEntryPath } } | Should -Throw '*filesystem LogFilePath*'
}
finally { Pop-Location }
}
It 'still resolves an explicit filesystem destination from another provider' {
$path = Join-Path $TestDrive 'explicit.log'
Push-Location Env:
try { New-LogEntry -LogMessage 'explicit destination' -LogFilePath $path -NoConsole }
finally { Pop-Location }
Get-Content -LiteralPath $path | Should -Match 'explicit destination$'
}
}

Describe 'Logger case-alias locking' {
It 'blocks a different process using the same file or an available case alias' {
$path = Join-Path $TestDrive 'case-probe.log'
$aliasPath = Join-Path $TestDrive 'CASE-PROBE.log'
Set-Content -LiteralPath $path -Value 'seed'
if (-not (Test-Path -LiteralPath $aliasPath)) {
$aliasPath = $path

}
$name = & $loggerModule { param($p) Get-NewLogEntryMutexName -Path $p } $path
$aliasName = & $loggerModule { param($p) Get-NewLogEntryMutexName -Path $p } $aliasPath
$aliasName | Should -BeExactly $name
$mutex = [Threading.Mutex]::new($false,$name)
$locked = $false
$process = $null
try {
$locked = $mutex.WaitOne(1000)
$locked | Should -BeTrue
$escapedLoader = $loggerLoader.Replace("'","''")
$escapedPath = $aliasPath.Replace("'","''")
$load = if ($loggerIsModule) { "Import-Module '$escapedLoader' -Force" } else { ". '$escapedLoader'" }
$code = @"
`$ErrorActionPreference='Stop'
$load
try {
New-LogEntry -LogMessage 'must time out' -LogFilePath '$escapedPath' -LockTimeoutSeconds 1 -NoConsole
Write-Output 'UNEXPECTED_WRITE'
exit 2
} catch {
if (`$_.Exception.Message -like '*Timed out waiting*') { Write-Output 'EXPECTED_TIMEOUT'; exit 0 }
Write-Output `$_.Exception.Message
exit 3
}
"@
$start = [Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path)
$start.UseShellExecute = $false
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
foreach ($argument in @('-NoProfile','-EncodedCommand',[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($code)))) {
$start.ArgumentList.Add($argument)
}
$process = [Diagnostics.Process]::Start($start)
if (-not $process.WaitForExit(15000)) { $process.Kill($true); throw 'Logger child process exceeded test timeout.' }
$output = $process.StandardOutput.ReadToEnd()
$errorOutput = $process.StandardError.ReadToEnd()
$process.ExitCode | Should -Be 0 -Because ($output + $errorOutput)
$output | Should -Match 'EXPECTED_TIMEOUT'
Get-Content -LiteralPath $path | Should -Be 'seed'
}
finally {
if ($null -ne $process) { $process.Dispose() }
if ($locked) { $mutex.ReleaseMutex() }
$mutex.Dispose()
}
}
}
Loading