Netscoot.Shared/Common/Journal.ps1
|
# Retroactive-undo journal. Records one line per completed move so it can be reversed later - an hour # later, or in a fresh session - with Undo-Netscoot. The move family is symmetric: each entry's # inverse is the same mover run with source/destination swapped, re-reconciling from the CURRENT # state (more robust than restoring a stale snapshot). # # Storage lives in the per-user application-data directory (not the working tree, not a volatile temp # dir, not inside .git/ where prune/clean and repository deletion churn it). One folder, "netscoot", # holds one <leaf>-<hash>.jsonl per repository (the hash of the repository root keeps repositories separate in # the shared store). Resolves per OS: # Windows: %LOCALAPPDATA% (e.g. C:\Users\<u>\AppData\Local\netscoot\) # macOS: ~/Library/Application Support (Apple's LocalAppData equivalent) # Linux: $XDG_DATA_HOME or ~/.local/share (the XDG persistent-data location) # Normal backup (Time Machine, roaming profiles, JAMF/Intune) covers these by default. # $env:NETSCOOT_JOURNAL_HOME overrides the base dir (relocate the store, or isolate it in tests). # # Enabled resolution, first match wins: # 1. an explicit suppression (NETSCOOT_JOURNAL_SUPPRESS, set by Undo around its reverse move) # 2. $env:NETSCOOT_JOURNAL (trumps git config, so an admin can force it on/off fleet-wide) # 3. git config netscoot.journal (local config wins over global - the persistent "git thing") # 4. default: ON # # Pruning keeps the journal small: on each write it drops entries older than the age cap and, oldest # first, anything beyond the size cap - in a single pass (read once, filter, write once). $script:JournalMaxAgeDays = 180 $script:JournalMaxBytes = 1MB function ConvertTo-JournalBool { # Parse a git-config / env truthy string to $true/$false, or $null when empty/unrecognized. param([string]$Value) switch -regex (("$Value").Trim().ToLowerInvariant()) { '^(1|true|on|yes|enabled)$' { $true; break } '^(0|false|off|no|disabled)$' { $false; break } default { $null } } } function Test-MoveJournalEnabled { [CmdletBinding()] [OutputType([bool])] param([string]$RepositoryRoot) if ((ConvertTo-JournalBool $env:NETSCOOT_JOURNAL_SUPPRESS) -eq $true) { return $false } # The env var trumps git config: an admin can force journaling on/off fleet-wide (GPO/Intune/ # profile) regardless of any repository's git setting. $envBool = ConvertTo-JournalBool $env:NETSCOOT_JOURNAL if ($null -ne $envBool) { return $envBool } if ($RepositoryRoot) { try { $cfg = "$(& git -C $RepositoryRoot config --get netscoot.journal 2>$null)" $b = ConvertTo-JournalBool $cfg if ($null -ne $b) { return $b } } catch { Write-Verbose "git config probe failed: $_" } } return $true } function Get-MoveJournalAppDataRoot { # The per-user application-data base, per OS. macOS is special-cased to Application Support # because .NET's LocalApplicationData maps to ~/.local/share on Unix (including macOS); on # Windows and Linux that mapping is already correct (%LOCALAPPDATA% / $XDG_DATA_HOME). [CmdletBinding()] [OutputType([string])] param() # Explicit override (relocation / tests): use it verbatim as the base. if ($env:NETSCOOT_JOURNAL_HOME) { return $env:NETSCOOT_JOURNAL_HOME } $isMac = (Test-Path Variable:\IsMacOS) -and (Get-Variable -Name IsMacOS -ValueOnly) if ($isMac) { return (Join-Path $HOME 'Library/Application Support') } $base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($base)) { $base = Join-Path $HOME '.local/share' } # defensive fallback return $base } function Get-MoveJournalPath { # Resolve the journal file for a repository in the per-user store: <appdata>/netscoot/<leaf>-<hash>.jsonl. # The hash of the (lowercased) repository root keeps different repositories separate in the shared store; # the readable leaf name makes the file identifiable. [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][string]$RepositoryRoot) $dir = Join-Path (Get-MoveJournalAppDataRoot) 'netscoot' $sha = [System.Security.Cryptography.SHA1]::Create() try { $hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($RepositoryRoot.ToLowerInvariant())) } finally { $sha.Dispose() } $key = -join ($hash[0..3] | ForEach-Object { $_.ToString('x2') }) $leaf = (Split-Path -Leaf $RepositoryRoot) -replace '[^A-Za-z0-9._-]', '_' if (-not $leaf) { $leaf = 'repo' } return (Join-Path $dir "$leaf-$key.jsonl") } function Select-RecentJournalLine { # Single pass: keep the newest lines that are within the age cap and whose cumulative size stays # under the byte cap. Input is oldest-first; output preserves that order. The newest line is # always kept (so a move is never silently dropped). [CmdletBinding()] param([string[]]$Lines) $cutoff = (Get-Date).ToUniversalTime().AddDays(-$script:JournalMaxAgeDays) $keep = [System.Collections.Generic.List[string]]::new() $bytes = 0 for ($i = $Lines.Count - 1; $i -ge 0; $i--) { $line = $Lines[$i] $ts = $null # ConvertFrom-Json may return the timestamp as a string OR an already-parsed [datetime]; # normalize either to a UTC instant (stored values are UTC) for the age comparison. try { $t = ($line | ConvertFrom-Json).timestamp if ($t -is [datetime]) { $ts = if ($t.Kind -eq 'Local') { $t.ToUniversalTime() } else { [datetime]::SpecifyKind($t, [System.DateTimeKind]::Utc) } } elseif ($t -is [datetimeoffset]) { $ts = $t.UtcDateTime } elseif ($t) { $ts = ([datetimeoffset]::Parse([string]$t)).UtcDateTime } } catch { $ts = $null } if ($ts -and $ts -lt $cutoff) { break } # older than the age cap: this and all earlier drop $sz = [System.Text.Encoding]::UTF8.GetByteCount($line) + 1 if ($keep.Count -gt 0 -and ($bytes + $sz) -gt $script:JournalMaxBytes) { break } $bytes += $sz $keep.Insert(0, $line) } , $keep.ToArray() } function Add-MoveJournalEntry { # Append one completed move (then prune). $Undo is @{ Command = '<mover>'; Params = @{ <splat> } }. [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepositoryRoot, [Parameter(Mandatory)][string]$Command, [Parameter(Mandatory)][string]$Engine, [Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string]$Destination, [Parameter(Mandatory)][hashtable]$Undo ) $path = Get-MoveJournalPath -RepositoryRoot $RepositoryRoot $dir = Split-Path -Parent $path if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $entry = [ordered]@{ id = [guid]::NewGuid().ToString('N').Substring(0, 8) timestamp = (Get-Date).ToUniversalTime().ToString('o') command = $Command engine = $Engine source = $Source destination = $Destination undo = $Undo } $existing = if (Test-Path -LiteralPath $path) { @(Get-Content -LiteralPath $path | Where-Object { $_.Trim() }) } else { @() } $kept = Select-RecentJournalLine -Lines (@($existing) + (ConvertTo-Json $entry -Depth 6 -Compress)) Set-Content -LiteralPath $path -Value $kept -Encoding utf8 } function Get-MoveJournalEntries { # All journal entries, oldest first. [CmdletBinding()] param([Parameter(Mandatory)][string]$RepositoryRoot) $path = Get-MoveJournalPath -RepositoryRoot $RepositoryRoot if (-not (Test-Path -LiteralPath $path)) { return @() } @(Get-Content -LiteralPath $path | Where-Object { $_.Trim() } | ForEach-Object { $entry = $_ | ConvertFrom-Json # Tag for the default table view (Netscoot.Format.ps1xml) so Undo-Netscoot -List prints a # clean table; the properties are unchanged, so callers reading .id/.undo still work. $entry.PSObject.TypeNames.Insert(0, 'Netscoot.JournalEntry') $entry }) } function Remove-MoveJournalEntry { # Drop one entry by id (called after a successful undo). [CmdletBinding()] param([Parameter(Mandatory)][string]$RepositoryRoot, [Parameter(Mandatory)][string]$Id) $path = Get-MoveJournalPath -RepositoryRoot $RepositoryRoot if (-not (Test-Path -LiteralPath $path)) { return } $kept = @(Get-Content -LiteralPath $path | Where-Object { $_.Trim() } | Where-Object { ($_ | ConvertFrom-Json).id -ne $Id }) if ($kept.Count) { Set-Content -LiteralPath $path -Value $kept -Encoding utf8 } else { Remove-Item -LiteralPath $path -Force } } function Register-MoveUndo { # Called by each mover after a successful move: emits a one-line undo hint and, when journaling is # enabled, records the reversing invocation. $UndoParams is the splat that reverses the move (the # same mover with source/destination swapped). [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepositoryRoot, [Parameter(Mandatory)][string]$Command, [Parameter(Mandatory)][string]$Engine, [Parameter(Mandatory)][string]$Source, [Parameter(Mandatory)][string]$Destination, [Parameter(Mandatory)][hashtable]$UndoParams, # Per-call opt-out: skip journaling this one move (the caller's -NoJournal). Still prints the # one-line undo hint so the inverse invocation is visible, just without recording it. [switch]$NoJournal ) $inv = "$Command " + (($UndoParams.GetEnumerator() | Sort-Object Name | ForEach-Object { if ($_.Value -is [bool]) { if ($_.Value) { "-$($_.Key)" } } else { "-$($_.Key) '$($_.Value)'" } }) -join ' ') if (-not $NoJournal -and (Test-MoveJournalEnabled -RepositoryRoot $RepositoryRoot)) { Add-MoveJournalEntry -RepositoryRoot $RepositoryRoot -Command $Command -Engine $Engine -Source $Source ` -Destination $Destination -Undo @{ Command = $Command; Params = $UndoParams } Write-Host "Undo with: Undo-Netscoot (replays: $inv)" -ForegroundColor DarkGray } else { Write-Host "Undo (journaling off): $inv" -ForegroundColor DarkGray } } |