diff --git a/filer-manager.ps1 b/filer-manager.ps1 index cb94741..05e5e20 100644 --- a/filer-manager.ps1 +++ b/filer-manager.ps1 @@ -83,6 +83,13 @@ .PARAMETER NoGui Forcer le mode sans interface (nécessite -Path et -Output). +.PARAMETER RevertPendingGrants + Annuler les modifications d'accès laissées par une exécution précédente qui + s'est arrêtée avant de les restaurer (interface fermée pendant l'analyse, + plantage, fermeture de session). Chaque octroi est journalisé sous + %LOCALAPPDATA%\FilerManager avant d'être appliqué ; ce commutateur rejoue les + journaux orphelins puis se termine. Ne demande aucun autre paramètre. + .PARAMETER Category Catégories de rapport à inclure dans le HTML. Par défaut 'All'. Passez toute combinaison de : Tree (tailles des dossiers), LongPaths, Permissions, Grants @@ -121,6 +128,7 @@ param( [switch]$GrantAccess, [switch]$KeepGrants, [switch]$NoGui, + [switch]$RevertPendingGrants, [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')] [string[]]$Category = @('All') ) @@ -154,6 +162,14 @@ function Test-IsAccessDenied { $ex = $ErrorRecord.Exception if ($ex -is [System.UnauthorizedAccessException]) { return $true } if ($ErrorRecord.CategoryInfo -and $ErrorRecord.CategoryInfo.Category -eq 'PermissionDenied') { return $true } + # A failure raised inside a .NET call reaches us wrapped in a + # MethodInvocationException, so the real cause is only visible on the base + # exception. Windows PowerShell 5.1 also reports some denials as a bare + # Win32Exception carrying ERROR_ACCESS_DENIED. + $base = $null + try { $base = $ex.GetBaseException() } catch { } + if ($base -is [System.UnauthorizedAccessException]) { return $true } + if ($base -is [System.ComponentModel.Win32Exception] -and $base.NativeErrorCode -eq 5) { return $true } return $false } @@ -171,6 +187,29 @@ function Resolve-PrincipalSid { } } +function Resolve-SidName { + # Friendly account name for a SID, falling back to the raw SID string when it + # cannot be translated (deleted account, broken trust, offline domain). + # Results are cached: a large scan meets the same handful of principals + # thousands of times and every miss costs an LSA / directory round trip. + param($Sid) + if (-not $Sid) { return '' } + if ($null -eq $script:FilerSidNameCache) { $script:FilerSidNameCache = @{} } + $key = [string]$Sid + if ($script:FilerSidNameCache.ContainsKey($key)) { return $script:FilerSidNameCache[$key] } + $name = $key + try { + if ($Sid -is [System.Security.Principal.SecurityIdentifier]) { + $name = $Sid.Translate([System.Security.Principal.NTAccount]).Value + } + else { + $name = (New-Object System.Security.Principal.SecurityIdentifier $key).Translate([System.Security.Principal.NTAccount]).Value + } + } catch { } + $script:FilerSidNameCache[$key] = $name + return $name +} + function Test-IsSystemPrincipal { # True for well-known built-in / system accounts and groups, so the audit can # focus on real business identities. Detection is by SID prefix (locale- @@ -267,6 +306,10 @@ function Grant-AdminAccess { RevertError = $null }) + # Persist before returning: from here on the change is live on disk, so it + # has to be recoverable even if this process never reaches Restore-Grants. + if ($success) { Save-FilerGrantJournal -Grants $Grants } + if (-not $success -and $errText) { [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = "Octroi automatique échoué : $errText" }) } @@ -314,6 +357,113 @@ function Restore-Grants { $g.RevertError = $err if ($err) { [void]$ScanErrors.Add([pscustomobject]@{ Path = $g.Path; Error = "Annulation : $err" }) } } + Save-FilerGrantJournal -Grants $Grants +} + +function Get-FilerGrantJournalDir { + # Where the pending-grant journals live: one file per process id. + $dir = Join-Path ([string]$env:LOCALAPPDATA) 'FilerManager' + if (-not (Test-Path -LiteralPath $dir)) { + try { $null = New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop } catch { return $null } + } + return $dir +} + +function Save-FilerGrantJournal { + <# Mirrors the not-yet-reverted grants to disk. Without it, losing the process + between a grant and Restore-Grants - the GUI closed mid-scan, a crash, a + logoff - would leave Administrators as owner with FullControl on the + scanned folders and no record that Filer Manager put it there. Best effort + by design: a journal failure must never abort a scan. The file is removed + as soon as nothing is pending. #> + param([System.Collections.ArrayList]$Grants) + if (-not $script:FilerGrantJournalPath) { return } + try { + $pending = @($Grants | Where-Object { $_.Success -and -not $_.Reverted } | ForEach-Object { + [pscustomobject]@{ + Path = [string]$_.Path + IsDir = [bool]$_.IsDir + OriginalOwner = [string]$_.OriginalOwner + OriginalSddl = [string]$_.OriginalSddl + } + }) + if ($pending.Count -eq 0) { + Remove-Item -LiteralPath $script:FilerGrantJournalPath -Force -ErrorAction SilentlyContinue + return + } + # Wrapped in an object: ConvertTo-Json collapses a one-element array. + $doc = [pscustomobject]@{ + Computer = [string]$env:COMPUTERNAME + Pid = $PID + Written = (Get-Date).ToString('o') + Grants = $pending + } + (ConvertTo-Json -InputObject $doc -Depth 4) | + Set-Content -LiteralPath $script:FilerGrantJournalPath -Encoding UTF8 -ErrorAction Stop + } catch { } +} + +function Get-FilerPendingGrantJournal { + <# Journals left behind by a run that never reverted. A journal whose owning + process is still alive is skipped - that scan is running and will clean up + after itself. A recycled process id can therefore delay a recovery, never + break one: the file simply stays until a later run finds the id free. #> + $dir = Get-FilerGrantJournalDir + if (-not $dir) { return @() } + $out = New-Object System.Collections.ArrayList + foreach ($f in @(Get-ChildItem -LiteralPath $dir -Filter 'grants-*.json' -File -ErrorAction SilentlyContinue)) { + $doc = $null + try { $doc = (Get-Content -LiteralPath $f.FullName -Raw -ErrorAction Stop) | ConvertFrom-Json } catch { continue } + $rows = @($doc.Grants | Where-Object { $_ -and $_.Path }) + if ($rows.Count -eq 0) { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue; continue } + $owner = 0 + try { $owner = [int]$doc.Pid } catch { } + if ($owner -eq $PID) { continue } + if ($owner -gt 0) { + $alive = $null + try { $alive = Get-Process -Id $owner -ErrorAction Stop } catch { } + if ($alive) { continue } + } + [void]$out.Add([pscustomobject]@{ File = $f.FullName; Grants = $rows; Written = [string]$doc.Written }) + } + return $out.ToArray() +} + +function Restore-FilerPendingGrants { + <# Replays one orphaned journal through Restore-Grants, deleting it only once + every entry came back. Returns a small report. #> + param([Parameter(Mandatory)] $Journal) + $grants = New-Object System.Collections.ArrayList + foreach ($g in @($Journal.Grants)) { + [void]$grants.Add([pscustomobject]@{ + Path = [string]$g.Path + IsDir = [bool]$g.IsDir + Reason = 'journal' + Changes = '' + OriginalOwner = [string]$g.OriginalOwner + OriginalSddl = [string]$g.OriginalSddl + Success = $true + Error = $null + Reverted = $false + RevertError = $null + }) + } + $errs = New-Object System.Collections.ArrayList + # Blank the journal path while replaying: Restore-Grants must not rewrite the + # very file we are consuming. + $saved = $script:FilerGrantJournalPath + $script:FilerGrantJournalPath = $null + try { Restore-Grants -Grants $grants -ScanErrors $errs -Progress $null } + finally { $script:FilerGrantJournalPath = $saved } + + $failed = @($grants | Where-Object { -not $_.Reverted }) + if ($failed.Count -eq 0) { Remove-Item -LiteralPath $Journal.File -Force -ErrorAction SilentlyContinue } + return [pscustomobject]@{ + Total = $grants.Count + Reverted = @($grants | Where-Object { $_.Reverted }).Count + Failed = $failed.Count + Errors = @($errs) + } } function Test-IsExcludedFolder { @@ -337,6 +487,71 @@ function Test-IsExcludedFolder { return $false } +function Test-IsReparsePoint { + # True for a junction, a directory symlink or any other reparse point. Those + # must never be descended into: a link that points at one of its own + # ancestors makes the walk recurse until the stack (or the path length) gives + # out, and a lateral one counts the very same bytes twice. They are still + # reported as an entry, just never followed. + param($Entry) + try { return (($Entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) } catch { return $false } +} + +function Expand-FilerShortPath { + <# Expands 8.3 short components of a path to their long form. Resolve-Path + keeps them as-is and [IO.Path]::GetFullPath expands them, so neither is a + reliable answer on its own; GetLongPathNameW is authoritative and also + accepts an extended-length path. #> + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $Path } + if (-not ('FilerManager.LongPath' -as [type])) { try { Initialize-FilerNetApi } catch { } } + if ('FilerManager.LongPath' -as [type]) { + try { return [FilerManager.LongPath]::ExpandShort($Path) } catch { } + } + return $Path +} + +function Get-FilerChildEntry { + <# Directory listing that is not bounded by MAX_PATH, used by every part of + the scan that walks a tree. Returns Entries / ErrorCode / ErrorMessage + instead of throwing, so the caller can tell "access denied" (code 5) from + "not there" without unwrapping a MethodInvocationException. + + Entries carry Name / FullPath / Attributes / IsDirectory / IsReparsePoint / + Length, which is the subset of FileSystemInfo the scan actually used, so + Test-IsExcludedFolder and Test-IsReparsePoint work on them unchanged. + Get-ChildItem remains as a fallback in case the P/Invoke shim cannot be + compiled. #> + param([string]$Path) + + if (-not ('FilerManager.LongPath' -as [type])) { try { Initialize-FilerNetApi } catch { } } + if ('FilerManager.LongPath' -as [type]) { + try { return [FilerManager.LongPath]::GetEntries($Path) } catch { } + } + + try { + $out = New-Object System.Collections.ArrayList + foreach ($i in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + $isDir = [bool]$i.PSIsContainer + $len = [long]0 + if (-not $isDir) { try { $len = [long]$i.Length } catch { $len = [long]0 } } + [void]$out.Add([pscustomobject]@{ + Name = [string]$i.Name + FullPath = [string]$i.FullName + Attributes = $i.Attributes + IsDirectory = $isDir + IsReparsePoint = (($i.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) + Length = $len + }) + } + return [pscustomobject]@{ Entries = $out.ToArray(); ErrorCode = 0; ErrorMessage = $null } + } + catch { + $code = if (Test-IsAccessDenied $_) { 5 } else { -1 } + return [pscustomobject]@{ Entries = @(); ErrorCode = $code; ErrorMessage = $_.Exception.GetBaseException().Message } + } +} + function Get-FolderNode { <# Recursively builds a size tree. Accumulates long paths and errors via synchronized collections passed by the caller. #> @@ -371,27 +586,37 @@ function Get-FolderNode { $entries = $null $granted = $false while ($true) { - try { - $entries = Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop - break - } - catch { - if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) { - $granted = $true - if ($Progress) { $Progress.Status = "Octroi de l'accès : $Path" } - if (Grant-AdminAccess -Path $Path -Reason 'énumérer le dossier' -Grants $GrantList -ScanErrors $ScanErrors) { - continue # retry once, now that access has been granted - } + $listing = Get-FilerChildEntry -Path $Path + if ($listing.ErrorCode -eq 0) { $entries = $listing.Entries; break } + if ($GrantAccessFlag -and -not $granted -and $listing.ErrorCode -eq 5) { + $granted = $true + if ($Progress) { $Progress.Status = "Octroi de l'accès : $Path" } + if (Grant-AdminAccess -Path $Path -Reason 'énumérer le dossier' -Grants $GrantList -ScanErrors $ScanErrors) { + continue # retry once, now that access has been granted } - [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = $_.Exception.Message }) - return [pscustomobject]$node } + [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = [string]$listing.ErrorMessage }) + return [pscustomobject]$node } foreach ($entry in $entries) { - if ($entry.PSIsContainer) { + if ($entry.IsDirectory) { if (Test-IsExcludedFolder -Entry $entry -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue } - $child = Get-FolderNode -Path $entry.FullName -MaxLen $MaxLen ` + if (Test-IsReparsePoint -Entry $entry) { + # Junction / directory symlink: recorded as a leaf, never walked, + # and contributing no size (the bytes belong to the link target). + $node.FolderCount += 1 + [void]$node.Children.Add([pscustomobject]@{ + Name = $entry.Name; FullPath = $entry.FullPath; Size = [long]0 + FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsLink = $true + Children = (New-Object System.Collections.ArrayList) + }) + if ($entry.FullPath.Length -ge $MaxLen) { + [void]$LongPaths.Add([pscustomobject]@{ Type = 'Lien'; Length = $entry.FullPath.Length; Path = $entry.FullPath }) + } + continue + } + $child = Get-FolderNode -Path $entry.FullPath -MaxLen $MaxLen ` -LongPaths $LongPaths -ScanErrors $ScanErrors ` -Progress $Progress -Depth ($Depth + 1) $node.Size += $child.Size @@ -400,16 +625,16 @@ function Get-FolderNode { [void]$node.Children.Add($child) } else { - $len = 0 - try { $len = [long]$entry.Length } catch { $len = 0 } + $len = [long]0 + try { $len = [long]$entry.Length } catch { $len = [long]0 } $node.Size += $len $node.FileCount += 1 - if ($entry.FullName.Length -ge $MaxLen) { - [void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullName.Length; Path = $entry.FullName }) + if ($entry.FullPath.Length -ge $MaxLen) { + [void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullPath.Length; Path = $entry.FullPath }) } if ($IncludeFilesInTreeFlag) { [void]$node.Children.Add([pscustomobject]@{ - Name = $entry.Name; FullPath = $entry.FullName; Size = $len + Name = $entry.Name; FullPath = $entry.FullPath; Size = $len FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsFile = $true Children = (New-Object System.Collections.ArrayList) }) @@ -432,12 +657,19 @@ function Get-FolderPermissions { while ($true) { try { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop - foreach ($ace in $acl.Access) { + # Read the owner and the ACEs as raw SIDs, then translate each name on its + # own. The NTAccount form of these APIs ($acl.Owner / $acl.Access) throws + # IdentityNotMappedException as soon as ONE principal is unresolvable - a + # single orphaned SID would otherwise cost us the whole folder's ACL. + $ownerSid = $null + try { $ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) } catch { } + $owner = Resolve-SidName $ownerSid + foreach ($ace in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { [void]$results.Add([pscustomobject]@{ Folder = $Path - Owner = $acl.Owner - Identity = [string]$ace.IdentityReference - Sid = Resolve-PrincipalSid $ace.IdentityReference + Owner = $owner + Identity = (Resolve-SidName $ace.IdentityReference) + Sid = [string]$ace.IdentityReference Rights = [string]$ace.FileSystemRights Type = [string]$ace.AccessControlType Inherited = $ace.IsInherited @@ -458,11 +690,13 @@ function Get-FolderPermissions { } if ($Current -lt $Depth) { - $subDirs = $null - try { $subDirs = Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction Stop } catch { $subDirs = @() } + $subDirs = @() + $listing = Get-FilerChildEntry -Path $Path + if ($listing.ErrorCode -eq 0) { $subDirs = @($listing.Entries | Where-Object { $_.IsDirectory }) } foreach ($d in $subDirs) { if (Test-IsExcludedFolder -Entry $d -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue } - $child = Get-FolderPermissions -Path $d.FullName -Depth $Depth -ScanErrors $ScanErrors -Current ($Current + 1) + if (Test-IsReparsePoint -Entry $d) { continue } # never follow a junction / symlink + $child = Get-FolderPermissions -Path $d.FullPath -Depth $Depth -ScanErrors $ScanErrors -Current ($Current + 1) foreach ($r in $child) { [void]$results.Add($r) } } } @@ -488,6 +722,19 @@ function Invoke-FilerScan { $script:GrantAccessFlag = $GrantAccess $script:GrantList = New-Object System.Collections.ArrayList + # Arm the on-disk journal only when we actually intend to revert: with + # -KeepGrants the changes are meant to stay, so there is nothing to recover. + $script:FilerGrantJournalPath = $null + if ($GrantAccess -and $RevertGrants) { + $jdir = Get-FilerGrantJournalDir + if ($jdir) { $script:FilerGrantJournalPath = Join-Path $jdir ("grants-{0}.json" -f $PID) } + } + + # Compile the P/Invoke shim up front: Get-FilerChildEntry needs + # FilerManager.LongPath to get past MAX_PATH, and doing it once here keeps the + # cost off the recursion. + try { Initialize-FilerNetApi } catch { } + $longPaths = New-Object System.Collections.ArrayList $scanErrors = New-Object System.Collections.ArrayList $permissions = New-Object System.Collections.ArrayList @@ -532,8 +779,12 @@ function Invoke-FilerScan { [void]$scanErrors.Add([pscustomobject]@{ Path = $rawPath; Error = $msg }) continue } + # Canonicalise the root once. Every child path is derived from this + # string, and every length compared against -MaxPathLength is measured on + # it, so an 8.3 alias here would understate how long the real paths are. $fullPath = $p try { $fullPath = (Resolve-Path -LiteralPath $p).Path } catch { } + $fullPath = Expand-FilerShortPath -Path $fullPath $node = Get-FolderNode -Path $fullPath -MaxLen $MaxPathLength ` -LongPaths $longPaths -ScanErrors $scanErrors -Progress $Progress [void]$roots.Add($node) @@ -640,8 +891,11 @@ function ConvertTo-FilerHtmlReport { $pct = if ($ParentSize -gt 0) { [math]::Round(($Node.Size / $ParentSize) * 100, 1) } else { 100 } $sizeStr = Format-Bytes $Node.Size $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile) - $icon = if ($isFile) { '📄' } else { '📁' } - $meta = if ($isFile) { '' } else { " $($Node.FolderCount) dossiers, $($Node.FileCount) fichiers" } + $isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink) + $icon = if ($isLink) { '🔗' } elseif ($isFile) { '📄' } else { '📁' } + $meta = if ($isLink) { " lien (non parcouru)" } + elseif ($isFile) { '' } + else { " $($Node.FolderCount) dossiers, $($Node.FileCount) fichiers" } $kids = @($Node.Children | Where-Object { $_ } ) $summary = "$icon $(_enc $Node.Name) $sizeStr" + @@ -967,6 +1221,12 @@ function ConvertTo-FilerCsvReport { $base = [System.IO.Path]::GetFileNameWithoutExtension($Path) if ([string]::IsNullOrEmpty($base)) { $base = 'filer-report' } + # 'UTF8' means "with BOM" on Windows PowerShell 5.1 but "without BOM" on + # PowerShell 7. Excel reads a BOM-less file as the ANSI code page, which turns + # every accent into mojibake (Proprietaire -> "Propriétaire"), so ask for the + # BOM explicitly on the hosts that know the name. + $csvEncoding = if ($PSVersionTable.PSVersion.Major -ge 6) { 'utf8BOM' } else { 'UTF8' } + $written = New-Object System.Collections.ArrayList function _writeCsv { param($Rows, [string]$Suffix) @@ -974,7 +1234,7 @@ function ConvertTo-FilerCsvReport { if ($rows.Count -eq 0) { return } # don't emit empty files $name = "$base-$Suffix.csv" $p = if ([string]::IsNullOrEmpty($dir)) { $name } else { Join-Path $dir $name } - $rows | Export-Csv -LiteralPath $p -NoTypeInformation -Encoding UTF8 -Delimiter $delim + $rows | Export-Csv -LiteralPath $p -NoTypeInformation -Encoding $csvEncoding -Delimiter $delim [void]$written.Add($p) } @@ -984,11 +1244,12 @@ function ConvertTo-FilerCsvReport { function _flattenNode { param($Node, [string]$Root, [System.Collections.ArrayList]$Acc) $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile) + $isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink) [void]$Acc.Add([pscustomobject]@{ Racine = $Root Chemin = $Node.FullPath Nom = $Node.Name - Type = if ($isFile) { 'Fichier' } else { 'Dossier' } + Type = if ($isLink) { 'Lien' } elseif ($isFile) { 'Fichier' } else { 'Dossier' } Profondeur = $Node.Depth TailleOctets = [long]$Node.Size Taille = Format-Bytes $Node.Size @@ -1087,6 +1348,157 @@ namespace FilerManager { public bool IsDisk; } + // One directory entry, shaped so the PowerShell side can treat it like the + // FileSystemInfo it replaces (same Name / Attributes contract). + public class DirEntry { + public string Name; + public string FullPath; + public System.IO.FileAttributes Attributes; + public bool IsDirectory; + public bool IsReparsePoint; + public long Length; + } + + // Result of one listing. Errors are returned, not thrown: the caller has to + // tell "access denied" from "gone" and PowerShell wraps every exception + // raised by a .NET call in a MethodInvocationException. + public class DirListing { + public DirEntry[] Entries; + public int ErrorCode; + public string ErrorMessage; + } + + // Directory enumeration that is not bounded by MAX_PATH. + // + // This has to go straight to the Win32 API. Windows PowerShell 5.1 runs .NET + // with legacy path handling, which rejects the \\?\ prefix outright, and + // Get-ChildItem there stops at 260 characters - exactly the paths this tool + // exists to report. FindFirstFileW has no such limit. + public static class LongPath { + + // Must match WIN32_FIND_DATAW byte for byte (592 bytes). Each FILETIME is + // two DWORDs and therefore 4-byte aligned: declaring one as a managed + // long would make the runtime align it on 8 bytes, pad after + // dwFileAttributes and shift every field that follows. + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct FindData { + public uint dwFileAttributes; + public uint ftCreationTimeLow; + public uint ftCreationTimeHigh; + public uint ftLastAccessTimeLow; + public uint ftLastAccessTimeHigh; + public uint ftLastWriteTimeLow; + public uint ftLastWriteTimeHigh; + public uint nFileSizeHigh; + public uint nFileSizeLow; + public uint dwReserved0; + public uint dwReserved1; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] public string cAlternateFileName; + } + + // Guards the layout above against a silent regression. + public static int StructSize() { return Marshal.SizeOf(typeof(FindData)); } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr FindFirstFileW(string lpFileName, out FindData lpFindFileData); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool FindNextFileW(IntPtr hFindFile, out FindData lpFindFileData); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FindClose(IntPtr hFindFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetLongPathNameW(string lpszShortPath, StringBuilder lpszLongPath, uint cchBuffer); + + private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010; + private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400; + private const int ERROR_NO_MORE_FILES = 18; + private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1); + + // Extended-length form of a path, which lifts MAX_PATH on the Win32 call. + public static string Extended(string path) { + if (string.IsNullOrEmpty(path)) return path; + if (path.StartsWith(@"\\?\")) return path; + if (path.StartsWith(@"\\")) return @"\\?\UNC\" + path.Substring(2); + return @"\\?\" + path; + } + + // Inverse of Extended, so reports show the path the user typed. + public static string Trim(string path) { + if (string.IsNullOrEmpty(path)) return path; + if (path.StartsWith(@"\\?\UNC\")) return @"\\" + path.Substring(8); + if (path.StartsWith(@"\\?\")) return path.Substring(4); + return path; + } + + // Expands 8.3 short components ("SEBAST~1") to their real names, keeping + // the caller's choice about the \\?\ prefix. Returns the input + // unchanged when the path cannot be resolved. + public static string ExpandShort(string path) { + if (string.IsNullOrEmpty(path)) return path; + bool wasExtended = path.StartsWith(@"\\?\"); + StringBuilder sb = new StringBuilder(32768); + uint n = GetLongPathNameW(Extended(path), sb, (uint)sb.Capacity); + if (n == 0 || n > sb.Capacity) return path; + string expanded = sb.ToString(); + return wasExtended ? expanded : Trim(expanded); + } + + public static DirListing GetEntries(string directory) { + DirListing result = new DirListing(); + result.Entries = new DirEntry[0]; + + if (string.IsNullOrEmpty(directory)) { + result.ErrorCode = 3; // ERROR_PATH_NOT_FOUND + result.ErrorMessage = "Chemin vide."; + return result; + } + + // Children are reported under the caller's own spelling of the + // directory, never under the \\?\ form, so the prefix stays an + // implementation detail. + string basePath = Trim(directory).TrimEnd('\\'); + string pattern = Extended(directory).TrimEnd('\\') + @"\*"; + + FindData fd; + IntPtr h = FindFirstFileW(pattern, out fd); + if (h == INVALID_HANDLE_VALUE) { + int rc = Marshal.GetLastWin32Error(); + if (rc == ERROR_NO_MORE_FILES) return result; // empty directory + result.ErrorCode = rc; + result.ErrorMessage = new System.ComponentModel.Win32Exception(rc).Message; + return result; + } + + List list = new List(); + try { + do { + if (fd.cFileName == "." || fd.cFileName == "..") continue; + DirEntry e = new DirEntry(); + e.Name = fd.cFileName; + e.FullPath = basePath + @"\" + fd.cFileName; + e.Attributes = (System.IO.FileAttributes)fd.dwFileAttributes; + e.IsDirectory = (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + e.IsReparsePoint = (fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0; + e.Length = e.IsDirectory ? 0L : (((long)fd.nFileSizeHigh) << 32) | (long)fd.nFileSizeLow; + list.Add(e); + } while (FindNextFileW(h, out fd)); + + int last = Marshal.GetLastWin32Error(); + if (last != 0 && last != ERROR_NO_MORE_FILES) { + result.ErrorCode = last; + result.ErrorMessage = new System.ComponentModel.Win32Exception(last).Message; + } + } + finally { FindClose(h); } + + result.Entries = list.ToArray(); + return result; + } + } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct ShareInfo1 { [MarshalAs(UnmanagedType.LPWStr)] public string NetName; @@ -1553,6 +1965,25 @@ function Connect-FilerShare { # Dot-source core functions into the current scope (for headless + GUI export). . ([scriptblock]::Create($CoreFunctions)) +# ============================================================================ +# RECOVERY MODE (-RevertPendingGrants) +# ============================================================================ +if ($RevertPendingGrants) { + $journals = @(Get-FilerPendingGrantJournal) + if ($journals.Count -eq 0) { + Write-Host "Aucune modification d'accès en attente." -ForegroundColor Green + return + } + foreach ($j in $journals) { + Write-Host ("Journal {0} - {1} élément(s) ..." -f $j.File, @($j.Grants).Count) -ForegroundColor Cyan + $r = Restore-FilerPendingGrants -Journal $j + $col = if ($r.Failed -gt 0) { 'Yellow' } else { 'Green' } + Write-Host (" {0} restauré(s), {1} échec(s)" -f $r.Reverted, $r.Failed) -ForegroundColor $col + foreach ($e in $r.Errors) { Write-Host " ! $($e.Path) : $($e.Error)" -ForegroundColor Yellow } + } + return +} + # ============================================================================ # HEADLESS MODE # ============================================================================ @@ -1565,6 +1996,12 @@ if ($runHeadless) { Write-Warning "-GrantAccess nécessite des droits Administrateur ; ce processus n'est pas élevé. Les éléments refusés peuvent ne pas être corrigés." } + $stale = @(Get-FilerPendingGrantJournal) + if ($stale.Count -gt 0) { + $nStale = (@($stale | ForEach-Object { @($_.Grants).Count }) | Measure-Object -Sum).Sum + Write-Warning ("Une exécution précédente n'a pas annulé ses modifications d'accès ({0} élément(s)). Lancez : .\filer-manager.ps1 -RevertPendingGrants" -f $nStale) + } + Write-Host "Analyse de $($Path -join ', ') en cours ..." -ForegroundColor Cyan $progress = [hashtable]::Synchronized(@{ Status = '' }) $scan = Invoke-FilerScan -Paths $Path -MaxPathLength $MaxPathLength ` @@ -1983,6 +2420,9 @@ $script:Runspace = $null $script:PowerShell = $null $script:Handle = $null $script:Shared = $null +# True while a running scan may already have granted access that still needs +# reverting - checked by the FormClosing guard below. +$script:ScanGrantsPending = $false # Background export state (report generation runs off the UI thread too). $script:ExportRunspace = $null @@ -2018,7 +2458,10 @@ function New-FilerTreeNode { param($Node) $sizeStr = Format-Bytes $Node.Size $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile) - if ($isFile) { + $isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink) + if ($isLink) { + $text = "$($Node.Name) - lien (non parcouru)" + } elseif ($isFile) { $text = "$($Node.Name) - $sizeStr" } else { $text = "$($Node.Name) - $sizeStr ($($Node.FolderCount) dossiers, $($Node.FileCount) fichiers)" @@ -2413,7 +2856,11 @@ function Invoke-RenameListing { Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1) }) } - if ($recurse) { $stack.Push([pscustomobject]@{ Path = $c.FullName; Depth = ($cur.Depth + 1) }) } + # A junction / symlink can be renamed, but descending into one + # risks an endless walk when it points back up the tree. + if ($recurse -and -not (Test-IsReparsePoint -Entry $c)) { + $stack.Push([pscustomobject]@{ Path = $c.FullName; Depth = ($cur.Depth + 1) }) + } } elseif ($wantFile) { [void]$items.Add([pscustomobject]@{ @@ -2585,6 +3032,7 @@ $timer.Add_Tick({ if ($script:PowerShell) { $script:PowerShell.Dispose() } if ($script:Runspace) { $script:Runspace.Close(); $script:Runspace.Dispose() } $script:PowerShell = $null; $script:Runspace = $null; $script:Handle = $null + $script:ScanGrantsPending = $false $progressBar.Visible = $false $btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true } @@ -3204,6 +3652,7 @@ $btnScan.Add_Click({ $progressBar.Visible = $true $statusLbl.Text = 'Analyse en cours...' + $script:ScanGrantsPending = ($grant -and $revert) $script:Shared = [hashtable]::Synchronized(@{ Status = 'Démarrage...'; Result = $null }) $script:Runspace = [runspacefactory]::CreateRunspace() $script:Runspace.ApartmentState = 'STA' @@ -3292,6 +3741,50 @@ catch { $Shared.Error = $_.Exception.Message } $exportTimer.Start() }) +# Closing the window kills the background runspace, so a scan that granted +# access would never reach Restore-Grants. The journal written by +# Save-FilerGrantJournal makes that recoverable, but warning first is cheaper +# than recovering afterwards. +$form.Add_FormClosing({ + param($s, $e) + if (-not $script:Handle -or $script:Handle.IsCompleted) { return } + $msg = if ($script:ScanGrantsPending) { + "Une analyse est en cours et elle a peut-être déjà accordé l'accès Administrators à des éléments refusés.`n`n" + + "Fermer maintenant l'interrompt AVANT l'annulation de ces modifications : les droits resteraient en place sur le disque. " + + "Ils sont journalisés et pourront être restaurés au prochain démarrage, ou avec -RevertPendingGrants.`n`n" + + "Fermer quand même ?" + } else { + "Une analyse est en cours. Fermer maintenant l'interrompt.`n`nFermer quand même ?" + } + if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -ne 'Yes') { $e.Cancel = $true } +}) + +# Offer to undo grants left behind by a run that never got to revert them. +$pendingJournals = @(Get-FilerPendingGrantJournal) +if ($pendingJournals.Count -gt 0) { + $nPending = (@($pendingJournals | ForEach-Object { @($_.Grants).Count }) | Measure-Object -Sum).Sum + $msg = "Une exécution précédente s'est arrêtée sans annuler ses modifications d'accès.`n`n" + + "$nPending élément(s) portent encore le propriétaire et/ou le FullControl Administrators accordés par Filer Manager.`n`n" + + 'Les restaurer maintenant ?' + if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -eq 'Yes') { + $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $done = 0; $failed = 0 + try { + foreach ($j in $pendingJournals) { + $r = Restore-FilerPendingGrants -Journal $j + $done += $r.Reverted; $failed += $r.Failed + } + } + finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default } + $statusLbl.Text = "Restauration : $done annulé(s), $failed échec(s)." + if ($failed -gt 0) { + [System.Windows.Forms.MessageBox]::Show( + "$done élément(s) restauré(s), $failed échec(s). Les échecs restent journalisés et seront réessayés au prochain démarrage.", + 'Filer Manager', 'OK', 'Warning') | Out-Null + } + } +} + # Pre-fill folders if -Path was supplied without -Output if ($Path) { foreach ($p in $Path) { [void]$lstFolders.Items.Add($p) } }