Private/ODBLogProcessor.ps1
|
class ODBLogProcessor { # ── Properties ────────────────────────────────────────────────────────────── [System.IO.FileInfo] $SourceFile # validated FileInfo for the input .cab/.zip [string] $DateFilter # e.g. "2023-10-31" [string] $Platform # "Windows" | "Mac" [string] $WorkingDir # parent directory of the source file [string] $ExtractedDir # full path to the extracted subfolder # ── Constructor ────────────────────────────────────────────────────────────── ODBLogProcessor([string]$Path, [string]$Date, [string]$Platform) { try { $this.SourceFile = Get-Item -LiteralPath $Path -ErrorAction Stop } catch { throw "Source file not found at '$Path': $_" } $this.DateFilter = $Date $this.Platform = $Platform $this.WorkingDir = $this.SourceFile.DirectoryName $this.ExtractedDir = Join-Path $this.WorkingDir $this.SourceFile.BaseName } # ── Methods ────────────────────────────────────────────────────────────────── # Step 1 — Extract the source file. # Windows .cab → extrac32.exe /E /L (built-in Cabinet tool, window hidden) # Mac .zip → ZipFile API (entry-by-entry, skips MAX_PATH violations) [void] ExtractSource() { if ($this.Platform -eq 'Windows') { # extrac32.exe /E expands all files preserving the full folder structure within the CAB. # expand.exe was considered but rejected: it does not auto-create subdirectories, causing # multi-level CABs to fail. Full path avoids PATH ambiguity. # The extrac32.exe window is intentionally left visible — it serves as the extraction # progress indicator since no in-terminal progress bar is available. $proc = Start-Process -FilePath "$env:SystemRoot\System32\extrac32.exe" ` -ArgumentList "`"$($this.SourceFile.FullName)`"", '/E', '/L', "`"$($this.ExtractedDir)`"" ` -Wait ` -PassThru if ($proc.ExitCode -ne 0) { throw "extrac32.exe exited with code $($proc.ExitCode). Extraction may have failed." } } else { # Pre-clear the destination to avoid stale files from a previous interrupted run. if (Test-Path -LiteralPath $this.ExtractedDir) { Remove-Item -LiteralPath $this.ExtractedDir -Recurse -Force -ErrorAction SilentlyContinue } # Use ZipFile API directly instead of Expand-Archive. # Expand-Archive has no per-entry error handling: if any single entry fails # (e.g. a .temp file whose full extracted path exceeds Windows MAX_PATH of 260 chars), # it triggers a destructive rollback that removes all previously extracted items, # producing thousands of "Cannot find path" errors. # Extracting entry-by-entry lets us silently skip oversized paths and continue. # (System.IO.Compression.FileSystem is loaded by the module before this class is parsed.) $zip = [System.IO.Compression.ZipFile]::OpenRead($this.SourceFile.FullName) try { foreach ($entry in $zip.Entries) { # Skip directory entries (no filename after the last slash). if ($entry.FullName.EndsWith('/') -or $entry.FullName.EndsWith('\')) { continue } $destPath = [System.IO.Path]::Combine($this.ExtractedDir, $entry.FullName.Replace('/', '\')) # Skip entries whose full Windows path would exceed MAX_PATH (260 chars). if ($destPath.Length -ge 260) { continue } $destDir = [System.IO.Path]::GetDirectoryName($destPath) if (-not [System.IO.Directory]::Exists($destDir)) { [void][System.IO.Directory]::CreateDirectory($destDir) } [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true) } } finally { $zip.Dispose() } } } # Step 2 — Collect ODL log files (files to KEEP). # Windows adds *.odlsent; Mac does not (Mac ODB does not generate them). [System.IO.FileInfo[]] CollectOdlFiles() { $extensions = @('*.odl', '*.odlgz', '*.etlgz') if ($this.Platform -eq 'Windows') { $extensions += '*.odlsent' } $files = [System.Collections.Generic.List[System.IO.FileInfo]]::new() foreach ($ext in $extensions) { foreach ($f in (Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter $ext)) { $files.Add($f) } } # Deduplicate: on Windows, Get-ChildItem -Filter '*.odl' also matches '*.odlgz' files # via legacy 8.3 short-name wildcard matching (FindFirstFile API), causing each .odlgz # to appear twice — once via *.odl and once via *.odlgz. return ($files.ToArray() | Sort-Object FullName -Unique) } # Step 3 — Collect the four excluded file categories. # Returns a hashtable keyed by the zip-suffix label used in CompressExcluded. # Where-Object extension check guards against Windows 8.3 short-name false positives: # e.g. Get-ChildItem -Filter '*.log' also matches '*.loggz' (8.3 extension = .LOG). [hashtable] CollectExcludedFiles() { return @{ 'EventLogsFiles' = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.evtx' | Where-Object { $_.Extension -eq '.evtx' }) 'DbFiles' = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.db' | Where-Object { $_.Extension -eq '.db' }) 'DbWalFiles' = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.db-wal' | Where-Object { $_.Extension -eq '.db-wal' }) 'SetupLogFiles' = @(Get-ChildItem -Path $this.ExtractedDir -Recurse -Filter '*.log' | Where-Object { $_.Extension -eq '.log' }) } } # Step 4 — Rename each excluded file with its immediate parent folder as a prefix # to avoid collisions when files from different subdirs are compressed together. # Returns a hashtable of the same keys, now holding [string[]] of new absolute paths. [hashtable] RenameExcludedFiles([hashtable]$ExcludedGroups) { $renamed = @{} foreach ($key in $ExcludedGroups.Keys) { $group = $ExcludedGroups[$key] if ($group.Count -gt 0) { $group | Rename-Item -NewName { $parentLeaf = Split-Path $_.DirectoryName -Leaf "${parentLeaf}_$($_.Name)" } $renamed[$key] = $group | ForEach-Object { $parentLeaf = Split-Path $_.DirectoryName -Leaf Join-Path $_.DirectoryName "${parentLeaf}_$($_.Name)" } } else { $renamed[$key] = @() } } return $renamed } # Step 5 — Compress each excluded category into its own .zip side archive. [void] CompressExcluded([hashtable]$RenamedGroups) { foreach ($key in $RenamedGroups.Keys) { $paths = $RenamedGroups[$key] if ($paths.Count -gt 0) { $destination = Join-Path $this.WorkingDir "$($this.SourceFile.BaseName)-${key}.zip" $paths | Compress-Archive -DestinationPath $destination -CompressionLevel Optimal -Force } } } # Step 6a — Delete excluded files after they have been archived. # Both platforms suppress deletion errors; explicit -ErrorAction is used because # class methods cannot assign to scope-based preference variables. [void] DeleteExcluded([hashtable]$RenamedGroups) { $errorAction = 'SilentlyContinue' foreach ($paths in $RenamedGroups.Values) { if ($paths.Count -gt 0) { $paths | Remove-Item -Force -ErrorAction $errorAction } } } # Step 6b — Delete ODL files whose BaseName does not match the date filter. # Windows suppresses deletion errors; [void] DeleteNonMatchingOdl([System.IO.FileInfo[]]$OdlFiles) { $toDelete = $OdlFiles | Where-Object { $_.BaseName -notmatch $this.DateFilter } if ($toDelete) { $toDelete | Remove-Item -Force -ErrorAction "SilentlyContinue" } } # Step 8 — Remove the extracted working directory and its contents. [void] Cleanup() { if (Test-Path -LiteralPath $this.ExtractedDir) { Remove-Item -LiteralPath $this.ExtractedDir -Recurse -Force -ErrorAction SilentlyContinue } } # Step 7 — Compress remaining logs into the final Parsed archive. # Windows → {basename}-Parsed.cab via makecab.exe (preserves folder structure via DDF). # Mac → {basename}-Parsed.zip via Compress-Archive. [string] CompressParsed() { if ($this.Platform -eq 'Windows') { $destination = Join-Path $this.WorkingDir "$($this.SourceFile.BaseName)-Parsed.cab" return New-CabinetFile -SourceDirectory $this.ExtractedDir -Destination $destination } else { $destination = Join-Path $this.WorkingDir "$($this.SourceFile.BaseName)-Parsed.zip" Compress-Archive -Path "$($this.ExtractedDir)\*" -DestinationPath $destination -CompressionLevel Optimal -Force return $destination } } } |