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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
## Logging and timer audit fixes (unreleased)

- Treat redaction replacement text literally, preventing regex substitutions from reinserting secrets.
- Serialize buffer snapshot/write/removal; retain entries after failed writes and preserve appended entries.
- Resolve default log paths from the calling script, with current-directory fallback for interactive calls.
- Reject conflicting buffered severity switches while retaining individual compatibility switches.
- Require a non-null stopwatch in every Get-ElapsedTime parameter set.
- Add regression tests and concurrent-process file-write coverage.

## String utilities restoration (unreleased)

- Restore character conversion, string checksums and SHA-256 hashing.
Expand Down
3 changes: 2 additions & 1 deletion Private/ConvertTo-NewLogEntryRedactedMessage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ function ConvertTo-NewLogEntryRedactedMessage
continue
}

$redactedMessage = [regex]::Replace($redactedMessage, $item, $Replacement)
# Escape dollar signs so replacement syntax cannot reinsert matched secrets.
$redactedMessage = [regex]::Replace($redactedMessage, $item, $Replacement.Replace('$', '$$'))
}

return $redactedMessage
Expand Down
38 changes: 38 additions & 0 deletions Private/Flush-NewLogEntryBuffer.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
function Flush-NewLogEntryBuffer
{
param(
[string]$Path,
[ValidateRange(1, 86400)]
[int]$LockTimeoutSeconds = 30
)

Initialize-NewLogEntryState

# Serialize snapshot, write, and removal with other operations on this buffer.
# A failed write leaves entries available for a later retry.
[System.Threading.Monitor]::Enter($script:NewLogEntryBufferLock)
try
{
$lines = $script:NewLogEntryBuffer.ToArray()
if ($lines.Count -eq 0)
{
return
}

Write-NewLogEntryLines -Lines $lines -Path $Path -LockTimeoutSeconds $LockTimeoutSeconds

# Remove only the written prefix, preserving any reentrant append during writing.
$script:NewLogEntryBuffer.RemoveRange(0, $lines.Count)
$script:messageBuffer = $script:NewLogEntryBuffer -join [Environment]::NewLine
if ($script:messageBuffer.Length -gt 0)
{
$script:messageBuffer += [Environment]::NewLine
}
}
finally
{
[System.Threading.Monitor]::Exit($script:NewLogEntryBufferLock)
}

return $lines
}
10 changes: 3 additions & 7 deletions Private/Resolve-NewLogEntryPath.ps1
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
function Resolve-NewLogEntryPath
{
param([string]$Path)
param([string]$Path, [string]$CallerScriptPath)

if (-not [string]::IsNullOrWhiteSpace($Path))
{
return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}

$basePath = if (-not [string]::IsNullOrWhiteSpace($script:PSCommandPath))
$basePath = if (-not [string]::IsNullOrWhiteSpace($CallerScriptPath))
{
$script:PSCommandPath
}
elseif (-not [string]::IsNullOrWhiteSpace($PSCommandPath))
{
$PSCommandPath
$CallerScriptPath
}
else
{
Expand Down
13 changes: 2 additions & 11 deletions Public/Get-ElapsedTime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,8 @@ function Get-ElapsedTime
[OutputType([timespan])]
param
(
[Parameter(ParameterSetName = 'FullOutput',
Mandatory = $true)]
[Parameter(ParameterSetName = 'Days')]
[Parameter(ParameterSetName = 'Hours')]
[Parameter(ParameterSetName = 'Minutes')]
[Parameter(ParameterSetName = 'Seconds')]
[Parameter(ParameterSetName = 'TotalDays')]
[Parameter(ParameterSetName = 'TotalHours')]
[Parameter(ParameterSetName = 'TotalMilliseconds')]
[Parameter(ParameterSetName = 'TotalMinutes')]
[Parameter(ParameterSetName = 'TotalSeconds')]
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[System.Diagnostics.Stopwatch]
$ElapsedTime,
[Parameter(ParameterSetName = 'Days')]
Expand Down
23 changes: 15 additions & 8 deletions Public/New-LogEntry.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ function New-LogEntry
Returns buffered log entries without clearing them.

.PARAMETER FlushBuffer
Writes buffered entries to the log file and clears the buffer after a successful write.
Writes buffered entries to the log file and removes the written entries after a successful write.
A failed file write retains the buffer for retry. A partial filesystem write can leave
content on disk, so a retry after an I/O failure can duplicate that content.

.PARAMETER ClearBuffer
Clears buffered entries without writing them.
Expand All @@ -72,7 +74,7 @@ function New-LogEntry
Additional regular expression patterns to redact before formatting or writing the message.

.PARAMETER RedactionText
Replacement text used for redacted content. Defaults to [REDACTED].
Literal replacement text used for redacted content (regex substitutions are not expanded). Defaults to [REDACTED].

.PARAMETER NoTag
Omits the severity tag from the formatted entry.
Expand Down Expand Up @@ -170,6 +172,7 @@ function New-LogEntry

begin
{
$callerScriptPath = $MyInvocation.ScriptName
$pendingEntries = [System.Collections.Generic.List[string]]::new()
$activeRedactPatterns = [System.Collections.Generic.List[string]]::new()

Expand All @@ -178,6 +181,12 @@ function New-LogEntry
throw 'Use either -IsWarningMessage or -IsErrorMessage, not both.'
}

$bufferSeverityCount = [int]$BufferOnlyInfo.IsPresent + [int]$BufferOnlyWarning.IsPresent + [int]$BufferOnlyError.IsPresent
if ($bufferSeverityCount -gt 1)
{
throw 'Use only one of -BufferOnlyInfo, -BufferOnlyWarning, or -BufferOnlyError.'
}

if ($PSBoundParameters.ContainsKey('Level') -and ($IsWarningMessage -or $IsErrorMessage -or $BufferOnlyWarning -or $BufferOnlyError -or $BufferOnlyInfo))
{
throw 'Use either -Level or a compatibility severity switch, not both.'
Expand Down Expand Up @@ -234,15 +243,14 @@ function New-LogEntry

'FlushBuffer'
{
$bufferedLines = Get-NewLogEntryBuffer
$resolvedLogPath = Resolve-NewLogEntryPath -Path $LogFilePath -CallerScriptPath $callerScriptPath
$bufferedLines = @(Flush-NewLogEntryBuffer -Path $resolvedLogPath -LockTimeoutSeconds $LockTimeoutSeconds)

if ($bufferedLines.Count -eq 0)
{
return
}

Write-NewLogEntryLines -Lines $bufferedLines -Path $LogFilePath -LockTimeoutSeconds $LockTimeoutSeconds

if (-not $NoConsole)
{
foreach ($line in $bufferedLines)
Expand All @@ -251,8 +259,6 @@ function New-LogEntry
}
}

Clear-NewLogEntryBuffer

if ($PassThru)
{
$bufferedLines
Expand Down Expand Up @@ -294,7 +300,8 @@ function New-LogEntry
return
}

Write-NewLogEntryLines -Lines $entries -Path $LogFilePath -LockTimeoutSeconds $LockTimeoutSeconds
$resolvedLogPath = Resolve-NewLogEntryPath -Path $LogFilePath -CallerScriptPath $callerScriptPath
Write-NewLogEntryLines -Lines $entries -Path $resolvedLogPath -LockTimeoutSeconds $LockTimeoutSeconds

if (-not $NoConsole)
{
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,16 @@ Stop-Timer -Timer $timer

Logging output only enters the success pipeline when requested with `-PassThru`.
Use `New-LogEntry -GetBuffer`, `-FlushBuffer` and `-ClearBuffer` rather than accessing
module variables. Redaction is opt-in and does not guarantee detection of every secret.
module variables. A successful flush removes only the written entries; a failed file write
retains the buffer for retry. Buffer operations are serialized during a flush.
A partially completed filesystem write may still leave content on disk, so retrying after
an I/O failure does not provide an exactly-once delivery guarantee.

Without `-LogFilePath`, logs are created beside the calling script, or in the current
directory for interactive calls. Specify a path to select a stable log filename.
`-RedactionText` is literal text, including dollar signs. Specify only one buffered
severity switch, or use `-BufferOnly -Level WARNING` / `ERROR`.
Redaction is opt-in and does not guarantee detection of every secret.

## Migration from v2

Expand All @@ -66,11 +75,12 @@ Invoke-Pester ./Tests

CI runs syntax validation, isolated import and Pester on Windows, Linux and macOS
using each hosted runner's installed PowerShell. It does not test every PowerShell
release. The inherited ten logger tests now exercise the command through module import.
Concurrency stress tests and Windows/AD integration tests are future work.
release. Logger tests exercise module import, redaction, failed-write retention, default paths,
and simultaneous direct/buffered file writes from three processes. Windows/AD
integration tests are future work.

The logger is adopted from `PowerShell-Functions/New-LogEntry` at commit `d5a9edd`.
The source and helper implementations are unchanged; integration tests import this module.
The integrated logger includes the maintenance fixes described in CHANGELOG.md.

See [CHANGELOG.md](./CHANGELOG.md) for history. Released under the [MIT License](./LICENSE).

Expand Down
21 changes: 21 additions & 0 deletions Tests/Module.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,24 @@ Describe 'Timers' {
Get-TimerStatus -Timer $timer | Should -BeFalse
}
}


Describe 'Elapsed-time input validation' {
It 'requires a non-null stopwatch in every parameter set' {
$command = Get-Command Get-ElapsedTime -Module IT-ToolBox
foreach ($set in $command.ParameterSets) {
($set.Parameters | Where-Object Name -eq ElapsedTime).IsMandatory | Should -BeTrue
}
{ Get-ElapsedTime -ElapsedTime $null -Seconds } | Should -Throw
}

It 'returns the requested elapsed-time component: <Component>' -ForEach @(
@{ Component = 'Days' }; @{ Component = 'Hours' }; @{ Component = 'Minutes' }
@{ Component = 'Seconds' }; @{ Component = 'TotalDays' }; @{ Component = 'TotalHours' }
@{ Component = 'TotalMinutes' }; @{ Component = 'TotalSeconds' }; @{ Component = 'TotalMilliseconds' }
) {
$timer = [System.Diagnostics.Stopwatch]::new()
$selector = @{ $Component = $true }
Get-ElapsedTime -ElapsedTime $timer @selector | Should -Be $timer.Elapsed.$Component
}
}
Loading
Loading