diff --git a/New-LogEntry/Private/Get-NewLogEntryMutexName.ps1 b/New-LogEntry/Private/Get-NewLogEntryMutexName.ps1 index 620049c..79c10e6 100644 --- a/New-LogEntry/Private/Get-NewLogEntryMutexName.ps1 +++ b/New-LogEntry/Private/Get-NewLogEntryMutexName.ps1 @@ -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) diff --git a/New-LogEntry/Private/Resolve-NewLogEntryPath.ps1 b/New-LogEntry/Private/Resolve-NewLogEntryPath.ps1 index df27c19..7114c59 100644 --- a/New-LogEntry/Private/Resolve-NewLogEntryPath.ps1 +++ b/New-LogEntry/Private/Resolve-NewLogEntryPath.ps1 @@ -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)) @@ -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 diff --git a/New-LogEntry/Private/Write-NewLogEntryLines.ps1 b/New-LogEntry/Private/Write-NewLogEntryLines.ps1 index 21e060b..255b64f 100644 --- a/New-LogEntry/Private/Write-NewLogEntryLines.ps1 +++ b/New-LogEntry/Private/Write-NewLogEntryLines.ps1 @@ -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) ) } diff --git a/New-LogEntry/README.md b/New-LogEntry/README.md index f496cf7..12ec52d 100644 --- a/New-LogEntry/README.md +++ b/New-LogEntry/README.md @@ -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. diff --git a/New-LogEntry/Tests/LoggerPath.Tests.ps1 b/New-LogEntry/Tests/LoggerPath.Tests.ps1 new file mode 100644 index 0000000..b1db985 --- /dev/null +++ b/New-LogEntry/Tests/LoggerPath.Tests.ps1 @@ -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: ' -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() + } + } +}