#Requires -Version 5.1 <# .SYNOPSIS Filer Manager - analyse des dossiers Windows et rapporte les tailles des dossiers, les chemins trop longs et les permissions NTFS, avec export HTML et CSV. .DESCRIPTION S'exécute avec une interface graphique WinForms par défaut. Peut aussi s'exécuter sans interface (mode headless) pour la planification : .\filer-manager.ps1 -Path "D:\Shares\Public" -Output report.html -NoGui Fonctionne sur Windows PowerShell 5.1 et PowerShell 7+. .PARAMETER Path Un ou plusieurs dossiers racines à analyser. S'il est fourni avec -Output (ou avec -NoGui), le script s'exécute sans interface et écrit un rapport HTML. Accepte un chemin local, un chemin UNC (\\serveur\partage\dossier) ou un lecteur réseau mappé (Z:\dossier). Un lecteur mappé est automatiquement réécrit vers sa cible UNC lorsque la lettre n'est pas montée dans le jeton courant : les mappages appartiennent à la session qui les a créés, donc ceux de l'utilisateur interactif sont absents d'une exécution en mode élevé (et inversement). En tâche planifiée sous un autre compte, indiquez l'UNC. .PARAMETER Output Chemin du rapport HTML à écrire en mode headless. .PARAMETER CsvOutput Chemin de base du/des rapport(s) CSV à écrire en mode headless. Un fichier CSV distinct est produit par catégorie ayant des données (p. ex. base.csv -> base-tree.csv, base-permissions.csv, ...). Peut être combiné avec -Output pour écrire HTML et CSV en même temps, ou utilisé seul pour un export CSV uniquement. .PARAMETER MaxPathLength Longueur de chemin (caractères) à partir de laquelle un élément est signalé comme « trop long ». Par défaut 260 (Windows MAX_PATH). .PARAMETER PermissionDepth Nombre de niveaux de dossiers sous chaque racine pour lesquels collecter les permissions NTFS. 0 = racines uniquement, 1 = racines + enfants immédiats (par défaut), etc. .PARAMETER IncludeFilesInTree Inclure les fichiers individuels (pas seulement les dossiers) dans l'arborescence des tailles. .PARAMETER ExcludeFolder Noms de dossiers à exclure de l'analyse (tailles, chemins trop longs et permissions). Les jokers sont acceptés et la comparaison est insensible à la casse, p. ex. '#recycle', '@eaDir', 'Thumbs*'. Par défaut, les corbeilles et dossiers système courants sont exclus : #recycle et @eaDir (NAS Synology) et $RECYCLE.BIN (corbeille Windows). Passez une liste vide (-ExcludeFolder @()) pour ne rien exclure par nom. .PARAMETER ExcludeHidden Exclure de l'analyse les dossiers portant l'attribut « masqué ». .PARAMETER HideInheritedChildPerms Omettre des rapports les permissions héritées portées par les dossiers enfants (les entrées héritées qui ne font que recopier celles du parent). Les permissions héritées des dossiers racines sont conservées, car le parent d'une racine est hors analyse et ces entrées sont la seule trace des droits en vigueur. .PARAMETER HideSystemPrincipals Omettre des rapports les permissions portées par des comptes/groupes système et intégrés bien connus (p. ex. NT AUTHORITY\SYSTEM, BUILTIN\Administrators, CREATOR OWNER, NT SERVICE\*). La détection se fait par SID (indépendante de la langue), avec un repli sur le nom. Utile pour ne garder que les identités métier dans l'audit des permissions. .PARAMETER GrantAccess Lorsqu'un dossier/fichier ne peut pas être lu (accès refusé), s'approprie automatiquement la propriété pour Administrators et accorde le FullControl à BUILTIN\Administrators, puis réessaye. Nécessite une exécution en mode élevé. Chaque modification est consignée dans le rapport. .PARAMETER KeepGrants Conserver l'accès accordé par -GrantAccess après l'analyse. Par défaut, l'outil annule chaque modification une fois l'analyse terminée (en restaurant le propriétaire et l'ACL d'origine là où ils ont pu être lus au préalable). .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 (journal des accès accordés), Errors. À utiliser pour garder de gros rapports petits, p. ex. -Category Permissions ou -Category Tree,LongPaths. .EXAMPLE .\filer-manager.ps1 Lance l'interface graphique. .EXAMPLE .\filer-manager.ps1 -Path "\\FILER01\Data","D:\Profiles" -Output C:\Reports\filer.html -NoGui .EXAMPLE .\filer-manager.ps1 -Path "D:\Shares" -Output filer.html -NoGui -GrantAccess Analyse D:\Shares en accordant l'accès Administrators aux éléments refusés afin que l'analyse puisse se terminer, puis annule ces modifications ensuite. .EXAMPLE .\filer-manager.ps1 -Path "\\NAS\Public" -Output filer.html -NoGui -ExcludeFolder '#recycle','@eaDir','Thumbs*' -ExcludeHidden Exclut les dossiers système Synology (#recycle, @eaDir), tout dossier « Thumbs… » et les dossiers masqués de l'analyse. #> [CmdletBinding()] param( [string[]]$Path, [string]$Output, [string]$CsvOutput, [int]$MaxPathLength = 260, [int]$PermissionDepth = 1, [switch]$IncludeFilesInTree, [string[]]$ExcludeFolder = @('#recycle', '@eaDir', '$RECYCLE.BIN'), [switch]$ExcludeHidden, [switch]$HideInheritedChildPerms, [switch]$HideSystemPrincipals, [switch]$GrantAccess, [switch]$KeepGrants, [switch]$NoGui, [switch]$RevertPendingGrants, [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')] [string[]]$Category = @('All') ) # ============================================================================ # CORE (GUI-independent). Kept as a string so it can be dot-sourced both here # and inside a background runspace used by the GUI to stay responsive. # ============================================================================ $CoreFunctions = @' function Format-Bytes { param([long]$Bytes) if ($Bytes -ge 1TB) { '{0:N2} TB' -f ($Bytes / 1TB) } elseif ($Bytes -ge 1GB) { '{0:N2} GB' -f ($Bytes / 1GB) } elseif ($Bytes -ge 1MB) { '{0:N2} MB' -f ($Bytes / 1MB) } elseif ($Bytes -ge 1KB) { '{0:N2} KB' -f ($Bytes / 1KB) } else { "$Bytes B" } } function Test-IsElevated { # True when the current process is running with Administrator rights. try { $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() $pr = New-Object System.Security.Principal.WindowsPrincipal($id) return $pr.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Test-IsAccessDenied { # Recognise an "access denied" failure across locales and PowerShell hosts. param($ErrorRecord) $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 } function Resolve-PrincipalSid { # Best-effort translation of an ACE IdentityReference (an NTAccount or a # SecurityIdentifier) to its SID string. Returns $null when unresolvable # (e.g. an orphaned account from a deleted domain user). param($IdentityReference) try { if ($IdentityReference -is [System.Security.Principal.SecurityIdentifier]) { return $IdentityReference.Value } return $IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { # The reference may already be a raw SID string (unresolved account). try { return (New-Object System.Security.Principal.SecurityIdentifier ([string]$IdentityReference)).Value } catch { return $null } } } 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- # independent), with a name-based fallback for the few cases where the SID # could not be resolved. Examples: NT AUTHORITY\SYSTEM (S-1-5-18), # BUILTIN\Administrators (S-1-5-32-544), CREATOR OWNER (S-1-3-*), # NT SERVICE\TrustedInstaller (S-1-5-80-*). param([string]$Identity, [string]$Sid) if ($Sid) { switch -Regex ($Sid) { '^S-1-5-(18|19|20)$' { return $true } # SYSTEM / LOCAL SERVICE / NETWORK SERVICE '^S-1-5-(6|9|17)$' { return $true } # SERVICE / Enterprise DCs / IUSR '^S-1-5-32-' { return $true } # BUILTIN\* (Administrators, Users, ...) '^S-1-5-(80|83|90|96)-' { return $true } # NT SERVICE / VM / Window Manager / Font driver '^S-1-3-' { return $true } # CREATOR OWNER / CREATOR GROUP } } # Name fallback: only when the SID could not be resolved (offline domain, # broken trust). When a SID is known it is authoritative, so groups like # Authenticated Users (S-1-5-11) are intentionally not treated as system even # though their name carries the NT AUTHORITY prefix. Covers common # English/French/German forms of the built-in domains and standalone principals. if (-not $Sid -and $Identity) { $id = $Identity.Trim() foreach ($p in @('NT AUTHORITY\', 'AUTORITE NT\', "AUTORIT$([char]0xC9) NT\", 'NT-AUTORIT', 'BUILTIN\', 'NT SERVICE\', 'AUTORITE DE SECURITE')) { if ($id.StartsWith($p, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } foreach ($e in @('SYSTEM', 'CREATOR OWNER', 'CREATOR GROUP', 'TrustedInstaller')) { if ($id.Equals($e, [System.StringComparison]::OrdinalIgnoreCase)) { return $true } } } return $false } function Grant-AdminAccess { <# Seizes ownership for Administrators and grants BUILTIN\Administrators FullControl on a single item (no recursion), so a denied path can be scanned. Captures the original owner/ACL first (when readable) so the change can be reverted later. Records every attempt in $Grants. Returns $true when something was changed (so the caller can retry). #> param( [string]$Path, [string]$Reason, [System.Collections.ArrayList]$Grants, [System.Collections.ArrayList]$ScanErrors ) # BUILTIN\Administrators well-known SID - locale-independent (the group is # named differently on non-English systems). $adminSid = 'S-1-5-32-544' # Don't act twice on the same path. foreach ($g in $Grants) { if ($g.Path -eq $Path) { return $g.Success } } # Capture the original state for a possible revert (may be unreadable). $origSddl = $null; $origOwner = $null try { $a = Get-Acl -LiteralPath $Path -ErrorAction Stop $origSddl = $a.Sddl; $origOwner = [string]$a.Owner } catch { } $isDir = $true try { $isDir = [bool]((Get-Item -LiteralPath $Path -Force -ErrorAction Stop).PSIsContainer) } catch { } $changes = New-Object System.Collections.ArrayList $errText = $null # 1) Seize ownership for Administrators (icacls enables the needed privilege # itself and, unlike takeown, has no locale-specific confirmation prompt). $null = & icacls.exe $Path /setowner "*$adminSid" /C /Q 2>&1 if ($LASTEXITCODE -eq 0) { [void]$changes.Add('Propriétaire défini -> Administrators') } else { $errText = "setowner a échoué (code $LASTEXITCODE)" } # 2) Grant Administrators FullControl (inheritable on directories). $perm = if ($isDir) { "*${adminSid}:(OI)(CI)F" } else { "*${adminSid}:F" } $null = & icacls.exe $Path /grant $perm /C /Q 2>&1 if ($LASTEXITCODE -eq 0) { [void]$changes.Add('Administrators -> FullControl accordé') } else { if ($errText) { $errText += '; ' }; $errText += "grant a échoué (code $LASTEXITCODE)" } $success = ($changes.Count -gt 0) [void]$Grants.Add([pscustomobject]@{ Path = $Path IsDir = $isDir Reason = $Reason Changes = ($changes -join '; ') OriginalOwner = $origOwner OriginalSddl = $origSddl Success = $success Error = $errText Reverted = $false 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" }) } return $success } function Restore-Grants { <# Reverts the changes made by Grant-AdminAccess, children before parents. Restores the full original security descriptor (owner + ACL) when it was captured; otherwise removes only the Administrators ACE we added. #> param( [System.Collections.ArrayList]$Grants, [System.Collections.ArrayList]$ScanErrors, [hashtable]$Progress ) $adminSid = 'S-1-5-32-544' for ($i = $Grants.Count - 1; $i -ge 0; $i--) { $g = $Grants[$i] if (-not $g.Success) { continue } if ($Progress) { $Progress.Status = "Annulation de l'accès : $($g.Path)" } $done = $false; $err = $null if ($g.OriginalSddl) { try { if ($g.IsDir) { $sec = New-Object System.Security.AccessControl.DirectorySecurity } else { $sec = New-Object System.Security.AccessControl.FileSecurity } $sec.SetSecurityDescriptorSddlForm($g.OriginalSddl) Set-Acl -LiteralPath $g.Path -AclObject $sec -ErrorAction Stop $done = $true } catch { $err = "Échec de la restauration Set-Acl : $($_.Exception.Message)" } } if (-not $done) { # Best-effort fallback: drop the ACE we added and put the owner back. try { $null = & icacls.exe $g.Path /remove:g "*$adminSid" /C /Q 2>&1 if ($g.OriginalOwner) { $null = & icacls.exe $g.Path /setowner $g.OriginalOwner /C /Q 2>&1 } if (-not $g.OriginalSddl) { $done = $true $err = "l'ACL d'origine était illisible ; seul l'ACE Administrators ajouté a été supprimé" } } catch { if ($err) { $err += '; ' }; $err += "Repli icacls échoué : $($_.Exception.Message)" } } $g.Reverted = $done $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 { # True when a directory entry should be skipped from the analysis. A folder is # excluded when its name matches one of the (wildcard, case-insensitive) # patterns - e.g. '#recycle', '@eaDir', 'Thumbs*' - or when -ExcludeHidden is # set and the directory carries the Hidden attribute. Exclusions apply to # descendants only; a scan root is always analysed even if it would match. param($Entry, [string[]]$Patterns, [bool]$ExcludeHidden) if ($ExcludeHidden) { try { if (($Entry.Attributes -band [System.IO.FileAttributes]::Hidden) -ne 0) { return $true } } catch { } } if ($Patterns) { $name = [string]$Entry.Name foreach ($pat in $Patterns) { if ([string]::IsNullOrWhiteSpace($pat)) { continue } if ($name -like $pat) { return $true } } } 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. #> param( [string]$Path, [int]$MaxLen, [System.Collections.ArrayList]$LongPaths, [System.Collections.ArrayList]$ScanErrors, [hashtable]$Progress, [int]$Depth = 0 ) $name = [System.IO.Path]::GetFileName($Path.TrimEnd('\')) if ([string]::IsNullOrEmpty($name)) { $name = $Path } # e.g. a drive root $node = [ordered]@{ Name = $name FullPath = $Path Size = [long]0 FileCount = 0 FolderCount = 0 Depth = $Depth Children = New-Object System.Collections.ArrayList } if ($Progress) { $Progress.Status = "Analyse : $Path" } if ($Path.Length -ge $MaxLen) { [void]$LongPaths.Add([pscustomobject]@{ Type = 'Dossier'; Length = $Path.Length; Path = $Path }) } $entries = $null $granted = $false while ($true) { $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 = [string]$listing.ErrorMessage }) return [pscustomobject]$node } foreach ($entry in $entries) { if ($entry.IsDirectory) { if (Test-IsExcludedFolder -Entry $entry -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue } 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 $node.FileCount += $child.FileCount $node.FolderCount += 1 + $child.FolderCount [void]$node.Children.Add($child) } else { $len = [long]0 try { $len = [long]$entry.Length } catch { $len = [long]0 } $node.Size += $len $node.FileCount += 1 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.FullPath; Size = $len FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsFile = $true Children = (New-Object System.Collections.ArrayList) }) } } } return [pscustomobject]$node } function Get-FolderPermissions { param( [string]$Path, [int]$Depth, [System.Collections.ArrayList]$ScanErrors, [int]$Current = 0 ) $results = New-Object System.Collections.ArrayList $granted = $false while ($true) { try { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop # 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 = $owner Identity = (Resolve-SidName $ace.IdentityReference) Sid = [string]$ace.IdentityReference Rights = [string]$ace.FileSystemRights Type = [string]$ace.AccessControlType Inherited = $ace.IsInherited }) } break } catch { if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) { $granted = $true if (Grant-AdminAccess -Path $Path -Reason 'lire les permissions' -Grants $GrantList -ScanErrors $ScanErrors) { continue # retry once, now that the ACL is readable } } [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = "ACL : $($_.Exception.Message)" }) break } } if ($Current -lt $Depth) { $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 } 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) } } } return $results } function Invoke-FilerScan { param( [string[]]$Paths, [int]$MaxPathLength = 260, [int]$PermissionDepth = 1, [bool]$IncludeFilesInTree = $false, [string[]]$ExcludeFolder = @(), [bool]$ExcludeHidden = $false, [bool]$GrantAccess = $false, [bool]$RevertGrants = $true, [hashtable]$Progress ) $script:IncludeFilesInTreeFlag = $IncludeFilesInTree $script:ExcludeFolderFlag = $ExcludeFolder $script:ExcludeHiddenFlag = $ExcludeHidden $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 $roots = New-Object System.Collections.ArrayList # Drive inventory is only needed when a root is given as a drive letter; it is # resolved once and shared, since a network path can be slow to interrogate. $inventory = $null if (@($Paths | Where-Object { $_ -match '^[A-Za-z]:' }).Count -gt 0) { $inventory = Get-FilerDriveInventory } foreach ($rawPath in $Paths) { # A mapped drive letter is rewritten to its UNC target when the mapping is # missing from this process token (typical for an elevated run: UAC gives # each token its own set of network drives). $resolved = Resolve-FilerScanPath -Path $rawPath -Inventory $inventory $p = $resolved.Path if (-not $p) { [void]$scanErrors.Add([pscustomobject]@{ Path = $rawPath; Error = 'Chemin vide' }) continue } if ($Progress -and $resolved.Rewritten) { $Progress.Status = "Chemin réseau : $rawPath -> $p" } $reach = Test-FilerPathReachable -Path $p -TimeoutSeconds 20 if (-not $reach.Exists) { $msg = 'Chemin introuvable' if ($reach.TimedOut) { $msg = 'Serveur injoignable (délai dépassé)' } elseif ($resolved.Letter -and -not $resolved.Mounted) { $msg = if ($resolved.Unc) { "Lecteur $($resolved.Letter) non monté dans cette session ; cible $($resolved.Unc) inaccessible" } else { "Lecteur $($resolved.Letter) non monté dans cette session et cible UNC inconnue (mappez-le ou saisissez le chemin \\serveur\partage)" } } elseif ($resolved.IsNetwork) { $msg = 'Chemin réseau inaccessible (hors ligne ou identifiants manquants)' } if ($reach.Error) { $msg += " : $($reach.Error)" } [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. # ProviderPath, not Path: on a UNC root the latter comes back in # provider-qualified form ("Microsoft.PowerShell.Core\FileSystem::\\srv\ # share"), which no Win32 call can open - the scan then found nothing. $fullPath = $p try { $rp = Resolve-Path -LiteralPath $p -ErrorAction Stop $cand = [string]$rp.ProviderPath if ([string]::IsNullOrWhiteSpace($cand)) { $cand = [string]$rp.Path } if ($cand) { $fullPath = $cand } } catch { } $fullPath = Expand-FilerShortPath -Path $fullPath $node = Get-FolderNode -Path $fullPath -MaxLen $MaxPathLength ` -LongPaths $longPaths -ScanErrors $scanErrors -Progress $Progress [void]$roots.Add($node) if ($Progress) { $Progress.Status = "Lecture des permissions : $fullPath" } $perms = Get-FolderPermissions -Path $fullPath -Depth $PermissionDepth -ScanErrors $scanErrors foreach ($r in $perms) { [void]$permissions.Add($r) } } # Revert the access we granted, once the scan (and ACL reads) are done. if ($GrantAccess -and $RevertGrants -and $script:GrantList.Count -gt 0) { if ($Progress) { $Progress.Status = 'Annulation des accès accordés...' } Restore-Grants -Grants $script:GrantList -ScanErrors $scanErrors -Progress $Progress } $totalSize = ($roots | Measure-Object -Property Size -Sum).Sum if (-not $totalSize) { $totalSize = 0 } $grantsMade = @($script:GrantList | Where-Object { $_.Success }) return [pscustomobject]@{ Roots = $roots LongPaths = ($longPaths | Sort-Object Length -Descending) Permissions = $permissions Grants = $script:GrantList Errors = $scanErrors Settings = [pscustomobject]@{ MaxPathLength = $MaxPathLength PermissionDepth = $PermissionDepth IncludeFilesInTree = $IncludeFilesInTree ExcludeFolder = @($ExcludeFolder | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) ExcludeHidden = $ExcludeHidden GrantAccess = $GrantAccess RevertGrants = $RevertGrants } Stats = [pscustomobject]@{ TotalSize = [long]$totalSize TotalFiles = ($roots | Measure-Object -Property FileCount -Sum).Sum TotalFolders = ($roots | Measure-Object -Property FolderCount -Sum).Sum LongPaths = $longPaths.Count Errors = $scanErrors.Count Grants = $grantsMade.Count } Computer = $env:COMPUTERNAME GeneratedAt = (Get-Date) } } function Get-FilerRootPathSet { # Case-insensitive set of the scan-root full paths. Used to decide which # permission rows sit on a scan root (vs. a child folder). param($Scan) $set = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) foreach ($r in $Scan.Roots) { [void]$set.Add([string]$r.FullPath) } return $set } function Select-FilerPerms { # Returns the scan's permission ACEs, optionally dropping inherited ACEs that # sit on a child folder and/or ACEs held by well-known system principals. # Inherited ACEs on the scan roots are always kept: a root's parent is outside # the scan, so those entries are the only record of the permissions in effect there. param($Scan, [bool]$HideInheritedChildPerms, [bool]$HideSystemPrincipals) $rows = @($Scan.Permissions) if ($HideInheritedChildPerms) { $roots = Get-FilerRootPathSet -Scan $Scan $rows = @($rows | Where-Object { (-not $_.Inherited) -or $roots.Contains([string]$_.Folder) }) } if ($HideSystemPrincipals) { $rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) }) } return $rows } function ConvertTo-FilerHtmlReport { param( [Parameter(Mandatory)] $Scan, [Parameter(Mandatory)] [string]$Path, # Which report categories to include. 'All' (default) emits everything; # otherwise pass any combination of Tree, LongPaths, Permissions, Grants, Errors. [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')] [string[]]$Categories = @('All'), # When set, inherited permissions on child folders are omitted (roots keep theirs). [bool]$HideInheritedChildPerms = $false, # When set, ACEs held by well-known system/built-in principals are omitted. [bool]$HideSystemPrincipals = $false ) function _enc([string]$s) { [System.Net.WebUtility]::HtmlEncode($s) } # Resolve the requested categories into per-section switches. $all = ($Categories -contains 'All') -or ($Categories.Count -eq 0) $wantTree = $all -or ($Categories -contains 'Tree') $wantLong = $all -or ($Categories -contains 'LongPaths') $wantPerm = $all -or ($Categories -contains 'Permissions') $wantGrants = $all -or ($Categories -contains 'Grants') $wantErrors = $all -or ($Categories -contains 'Errors') $sb = New-Object System.Text.StringBuilder # ---- recursive tree renderer (folders sorted largest-first) ---- function _renderNode { param($Node, [long]$ParentSize, [System.Text.StringBuilder]$Out) $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) $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" + "$pct%$meta" if ($kids.Count -gt 0) { $open = if ($Node.Depth -eq 0) { ' open' } else { '' } [void]$Out.Append("$summary
") foreach ($c in ($kids | Sort-Object @{E={$_.Size}} -Descending)) { _renderNode -Node $c -ParentSize $Node.Size -Out $Out } [void]$Out.Append("
") } else { [void]$Out.Append("
$summary
") } } $treeSb = New-Object System.Text.StringBuilder if ($wantTree) { foreach ($root in $Scan.Roots) { _renderNode -Node $root -ParentSize $root.Size -Out $treeSb } } # ---- long paths ---- $longRows = New-Object System.Text.StringBuilder if ($wantLong) { foreach ($lp in $Scan.LongPaths) { [void]$longRows.Append("$($lp.Length)$(_enc $lp.Type)$(_enc $lp.Path)") } if ($Scan.LongPaths.Count -eq 0) { [void]$longRows.Append("Aucun chemin égal ou supérieur à $($Scan.Settings.MaxPathLength) caractères. ✅") } } # ---- permissions (nested folder tree; every level is collapsible) ---- $permSb = New-Object System.Text.StringBuilder if ($wantPerm) { # Canonicalise so 8.3 / trailing-slash / casing differences between a folder # and its parent (from Get-ChildItem) still line up when building the tree. function _canon([string]$p) { try { ([System.IO.Path]::GetFullPath($p)).TrimEnd('\') } catch { $p.TrimEnd('\') } } $byFolder = (Select-FilerPerms -Scan $Scan -HideInheritedChildPerms $HideInheritedChildPerms -HideSystemPrincipals $HideSystemPrincipals) | Group-Object Folder # One node per folder, keyed by canonical path. $nodes = @{} foreach ($grp in $byFolder) { $canon = _canon ([string]$grp.Name) $nodes[$canon] = [pscustomobject]@{ Canon = $canon Display = [string]$grp.Name Owner = ($grp.Group | Select-Object -First 1).Owner Aces = $grp.Group Children = New-Object System.Collections.ArrayList } } # Attach each folder to its nearest ancestor that also has permissions; the # ones with no such ancestor are the tree's top-level (scan-root) nodes. $tops = New-Object System.Collections.ArrayList foreach ($node in $nodes.Values) { $parent = [System.IO.Path]::GetDirectoryName($node.Canon) $attached = $false while ($parent) { if ($nodes.ContainsKey($parent)) { [void]$nodes[$parent].Children.Add($node); $attached = $true; break } $up = [System.IO.Path]::GetDirectoryName($parent) if ($up -eq $parent) { break } $parent = $up } if (-not $attached) { [void]$tops.Add($node) } } function _renderPermNode { param($Node, [bool]$IsTop, [System.Text.StringBuilder]$Out) # Top-level nodes show their full path; nested ones just the leaf name. $label = if ($IsTop) { $Node.Display } else { Split-Path -Leaf $Node.Display } $topCls = if ($IsTop) { ' top' } else { '' } [void]$Out.Append("
$(_enc $label) Propriétaire : $(_enc $Node.Owner)") [void]$Out.Append("") foreach ($ace in $Node.Aces) { $cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' } $sys = Test-IsSystemPrincipal -Identity $ace.Identity -Sid $ace.Sid [void]$Out.Append("") } [void]$Out.Append("
IdentitéDroitsTypeHérité
$(_enc $ace.Identity)$(_enc $ace.Rights)$(_enc $ace.Type)$($ace.Inherited)
") if ($Node.Children.Count -gt 0) { [void]$Out.Append("
") foreach ($c in ($Node.Children | Sort-Object Display)) { _renderPermNode -Node $c -IsTop $false -Out $Out } [void]$Out.Append("
") } [void]$Out.Append("
") } foreach ($t in ($tops | Sort-Object Display)) { _renderPermNode -Node $t -IsTop $true -Out $permSb } if ($byFolder.Count -eq 0) { [void]$permSb.Append("

Aucune permission collectée.

") } } # ---- granted access (auto-remediation log) ---- $grantSb = New-Object System.Text.StringBuilder $grantRows = @($Scan.Grants | Where-Object { $_.Success }) if ($wantGrants) { foreach ($g in $grantRows) { if ($g.Reverted) { $state = "annulé" } elseif ($Scan.Settings.RevertGrants) { $state = "NON annulé$(if($g.RevertError){' - ' + (_enc $g.RevertError)})" } else { $state = "conservé" } [void]$grantSb.Append("$(_enc $g.Path)$(_enc $g.Reason)$(_enc $g.Changes)$(_enc $g.OriginalOwner)$state") } } # ---- errors ---- $errSb = New-Object System.Text.StringBuilder if ($wantErrors) { foreach ($e in $Scan.Errors) { [void]$errSb.Append("$(_enc $e.Path)$(_enc $e.Error)") } } $rootsList = ($Scan.Roots | ForEach-Object { _enc $_.FullPath }) -join '
' $gen = $Scan.GeneratedAt.ToString('yyyy-MM-dd HH:mm:ss') # Note which folders were excluded from the scan, when any exclusion is active. $exclParts = @() if ($Scan.Settings.ExcludeFolder -and $Scan.Settings.ExcludeFolder.Count -gt 0) { $exclParts += ($Scan.Settings.ExcludeFolder | ForEach-Object { _enc $_ }) -join ', ' } if ($Scan.Settings.ExcludeHidden) { $exclParts += 'dossiers masqués' } $exclNote = if ($exclParts.Count -gt 0) { "
Dossiers exclus : $($exclParts -join ' · ')
" } else { '' } # When only some categories are exported, name them in the header. $catLabels = [ordered]@{ Tree = 'Tailles des dossiers'; LongPaths = 'Chemins trop longs'; Permissions = 'Permissions' Grants = 'Accès accordé'; Errors = 'Erreurs' } $included = @() if ($wantTree) { $included += $catLabels.Tree } if ($wantLong) { $included += $catLabels.LongPaths } if ($wantPerm) { $included += $catLabels.Permissions } if ($wantGrants -and $Scan.Settings.GrantAccess) { $included += $catLabels.Grants } if ($wantErrors -and $Scan.Errors.Count -gt 0) { $included += $catLabels.Errors } $catNote = if ($all) { '' } else { "
Catégories : $(_enc ($included -join ', '))
" } $html = @" Rapport Filer Manager - $gen

📁 Rapport Filer Manager

Généré le $gen · Hôte $(_enc $Scan.Computer) · Seuil chemin trop long $($Scan.Settings.MaxPathLength) · Profondeur des permissions $($Scan.Settings.PermissionDepth)
Analysé : $rootsList
$exclNote $catNote
$(Format-Bytes $Scan.Stats.TotalSize)
Taille totale
$('{0:N0}' -f $Scan.Stats.TotalFolders)
Dossiers
$('{0:N0}' -f $Scan.Stats.TotalFiles)
Fichiers
$($Scan.Stats.LongPaths)
Chemins trop longs
$($Scan.Stats.Errors)
Erreurs / refusés
$(if ($Scan.Settings.GrantAccess) { "
$($Scan.Stats.Grants)
Accès accordé
" })
$(if ($wantTree) { @"

📊 Tailles des dossiers

$($treeSb.ToString())
"@ }) $(if ($wantLong) { @"

⚠️ Noms de fichiers / chemins trop longs (≥ $($Scan.Settings.MaxPathLength) caractères)

$($longRows.ToString())
LongueurTypeChemin
"@ }) $(if ($wantPerm) { @"

🔐 Permissions

$($permSb.ToString())
"@ }) $(if ($wantGrants -and $Scan.Settings.GrantAccess) { @"

🔨 Accès accordé pour terminer l'analyse

Éléments qui étaient refusés, sur lesquels la propriété/le FullControl Administrators a été appliqué afin de pouvoir les analyser. Mode : $(if ($Scan.Settings.RevertGrants) { "annuler après l'analyse" } else { 'conserver les modifications' }).

$(if ($grantRows.Count -gt 0) { @"
$($grantSb.ToString())
CheminRaisonModificationPropriétaire d'origineÉtat
"@ } else { "

Aucune modification d'accès n'était nécessaire. ✅

" })
"@ }) $(if ($wantErrors -and $Scan.Errors.Count -gt 0) { @"

❌ Erreurs et accès refusé

$($errSb.ToString())
CheminErreur
"@ })
"@ Set-Content -LiteralPath $Path -Value $html -Encoding UTF8 return $Path } function ConvertTo-FilerCsvReport { <# Writes the scan results to CSV. Because the report categories have very different shapes, each selected category with data is written to its own file, derived from the base $Path (e.g. report.csv -> report-tree.csv, report-permissions.csv, ...). Returns the list of files written. The list separator follows the current culture so the files open cleanly in the local Excel (';' on French systems, ',' elsewhere). #> param( [Parameter(Mandatory)] $Scan, [Parameter(Mandatory)] [string]$Path, [ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')] [string[]]$Categories = @('All'), # When set, inherited permissions on child folders are omitted (roots keep theirs). [bool]$HideInheritedChildPerms = $false, # When set, ACEs held by well-known system/built-in principals are omitted. [bool]$HideSystemPrincipals = $false ) $all = ($Categories -contains 'All') -or ($Categories.Count -eq 0) $wantTree = $all -or ($Categories -contains 'Tree') $wantLong = $all -or ($Categories -contains 'LongPaths') $wantPerm = $all -or ($Categories -contains 'Permissions') $wantGrants = $all -or ($Categories -contains 'Grants') $wantErrors = $all -or ($Categories -contains 'Errors') $delim = (Get-Culture).TextInfo.ListSeparator if ([string]::IsNullOrEmpty($delim)) { $delim = ',' } # Derive a per-category file path from the base path. $dir = Split-Path -Path $Path -Parent $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) $rows = @($Rows | Where-Object { $null -ne $_ }) 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 $csvEncoding -Delimiter $delim [void]$written.Add($p) } # ---- tree (flattened depth-first, folders largest-first like the HTML) ---- if ($wantTree) { $treeRows = New-Object System.Collections.ArrayList 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 ($isLink) { 'Lien' } elseif ($isFile) { 'Fichier' } else { 'Dossier' } Profondeur = $Node.Depth TailleOctets = [long]$Node.Size Taille = Format-Bytes $Node.Size Fichiers = $Node.FileCount Dossiers = $Node.FolderCount }) foreach ($c in @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending)) { _flattenNode -Node $c -Root $Root -Acc $Acc } } foreach ($root in $Scan.Roots) { _flattenNode -Node $root -Root $root.FullPath -Acc $treeRows } _writeCsv -Rows $treeRows -Suffix 'tree' } # ---- long paths ---- if ($wantLong) { $longRows = foreach ($lp in $Scan.LongPaths) { [pscustomobject]@{ Longueur = $lp.Length; Type = $lp.Type; Chemin = $lp.Path } } _writeCsv -Rows $longRows -Suffix 'longpaths' } # ---- permissions ---- if ($wantPerm) { $permRows = foreach ($ace in (Select-FilerPerms -Scan $Scan -HideInheritedChildPerms $HideInheritedChildPerms -HideSystemPrincipals $HideSystemPrincipals)) { [pscustomobject]@{ Dossier = $ace.Folder Proprietaire = $ace.Owner Identite = $ace.Identity Droits = $ace.Rights Type = $ace.Type Herite = $ace.Inherited } } _writeCsv -Rows $permRows -Suffix 'permissions' } # ---- granted access (successful remediations only, like the HTML) ---- if ($wantGrants -and $Scan.Settings.GrantAccess) { $grantRows = foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) { if ($g.Reverted) { $state = 'annulé' } elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé' } else { $state = 'conservé' } [pscustomobject]@{ Chemin = $g.Path Raison = $g.Reason Modification = $g.Changes ProprietaireOrigine = $g.OriginalOwner Etat = $state ErreurAnnulation = $g.RevertError } } _writeCsv -Rows $grantRows -Suffix 'grants' } # ---- errors ---- if ($wantErrors) { $errRows = foreach ($e in $Scan.Errors) { [pscustomobject]@{ Chemin = $e.Path; Erreur = $e.Error } } _writeCsv -Rows $errRows -Suffix 'errors' } return $written.ToArray() } # ---------------------------------------------------------------------------- # NETWORK / DRIVE SUPPORT # # Scanning a "mounted network drive" cannot rely on the drive letter alone: # a mapping belongs to the logon token that created it, so the letters mapped # by the interactive user do not exist inside an elevated process (and the # reverse is also true). Filer Manager needs elevation for -GrantAccess, so # every network path is resolved to its UNC target, which is token-agnostic. # Mappings made with New-PSDrive are worse still: they exist only inside the # PowerShell session and are invisible to Win32/Explorer. # ---------------------------------------------------------------------------- function Initialize-FilerNetApi { # Loads the P/Invoke shims used to enumerate shares, read the UNC target of a # drive letter and open an authenticated connection. Idempotent: the type is # added to the AppDomain once and stays visible to background runspaces. if ('FilerManager.NetApi' -as [type]) { return } Add-Type -TypeDefinition @" using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; namespace FilerManager { public class ShareEntry { public string Name; public string Remark; public bool IsSpecial; 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; public uint ShareType; [MarshalAs(UnmanagedType.LPWStr)] public string Remark; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal class NetResource { public int Scope = 2; // RESOURCE_GLOBALNET public int ResourceType = 1; // RESOURCETYPE_DISK public int DisplayType = 3; // RESOURCEDISPLAYTYPE_SHARE public int Usage = 1; // RESOURCEUSAGE_CONNECTABLE public string LocalName; public string RemoteName; public string Comment; public string Provider; } public static class NetApi { [DllImport("netapi32.dll", CharSet = CharSet.Unicode)] private static extern int NetShareEnum(string serverName, int level, out IntPtr bufPtr, int prefMaxLen, ref int entriesRead, ref int totalEntries, ref int resumeHandle); [DllImport("netapi32.dll")] private static extern int NetApiBufferFree(IntPtr buffer); [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetGetConnection(string localName, StringBuilder remoteName, ref int length); [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetAddConnection2(NetResource netResource, string password, string username, int flags); [DllImport("mpr.dll", CharSet = CharSet.Unicode)] private static extern int WNetCancelConnection2(string name, int flags, bool force); // Shares on a server, RAP level 1: readable by any authenticated user // (level 2 and above would require Administrator on the target). public static ShareEntry[] EnumShares(string server) { IntPtr buf = IntPtr.Zero; int read = 0, total = 0, resume = 0; List list = new List(); int rc = NetShareEnum(server, 1, out buf, -1, ref read, ref total, ref resume); // 0 = success, 234 = ERROR_MORE_DATA (we still keep what came back). if (rc != 0 && rc != 234) throw new System.ComponentModel.Win32Exception(rc); try { int size = Marshal.SizeOf(typeof(ShareInfo1)); for (int i = 0; i < read; i++) { IntPtr p = new IntPtr(buf.ToInt64() + (i * size)); ShareInfo1 si = (ShareInfo1)Marshal.PtrToStructure(p, typeof(ShareInfo1)); ShareEntry e = new ShareEntry(); e.Name = si.NetName; e.Remark = si.Remark; e.IsSpecial = (si.ShareType & 0x80000000u) != 0; // STYPE_SPECIAL (ADMIN$, C$, IPC$) e.IsDisk = (si.ShareType & 0xFFu) == 0; // STYPE_DISKTREE list.Add(e); } } finally { if (buf != IntPtr.Zero) NetApiBufferFree(buf); } return list.ToArray(); } // UNC target behind a mapped drive letter ("Z:"), or null when the letter // is not a network mapping in this token. public static string GetUncForDrive(string drive) { StringBuilder sb = new StringBuilder(2048); int len = sb.Capacity; int rc = WNetGetConnection(drive, sb, ref len); if (rc == 0) return sb.ToString(); return null; } // Opens a connection to a share, with or without a drive letter. Passing // null for localName authenticates the UNC path without consuming a letter. public static int Connect(string remoteName, string localName, string user, string password, bool persist) { NetResource nr = new NetResource(); nr.RemoteName = remoteName; nr.LocalName = string.IsNullOrEmpty(localName) ? null : localName; int flags = persist ? 1 : 0; // CONNECT_UPDATE_PROFILE return WNetAddConnection2(nr, password, user, flags); } public static int Disconnect(string name, bool force) { return WNetCancelConnection2(name, 0, force); } } } "@ } function Invoke-FilerTimed { <# Runs a scriptblock on a throw-away runspace and gives up after -TimeoutSeconds. Every network probe goes through this: an offline share makes Test-Path / GetDirectories block for tens of seconds, which would freeze the WinForms UI thread. The scriptblock must declare its own param() block to receive -Arguments. #> param( [scriptblock]$Script, [object[]]$Arguments = @(), [int]$TimeoutSeconds = 8 ) $out = [pscustomobject]@{ Result = $null; Error = $null; TimedOut = $false } $rs = $null; $ps = $null try { $rs = [runspacefactory]::CreateRunspace() $rs.ApartmentState = 'MTA' $rs.Open() $ps = [powershell]::Create() $ps.Runspace = $rs [void]$ps.AddScript($Script.ToString()) foreach ($a in $Arguments) { [void]$ps.AddArgument($a) } $handle = $ps.BeginInvoke() if (-not $handle.AsyncWaitHandle.WaitOne([timespan]::FromSeconds($TimeoutSeconds))) { # The call is stuck in a blocking Win32 wait; ask for a stop and walk # away rather than blocking the caller on Dispose(). $out.TimedOut = $true try { [void]$ps.BeginStop($null, $null) } catch { } return $out } $out.Result = $ps.EndInvoke($handle) if ($ps.Streams.Error.Count -gt 0) { # GetBaseException() unwraps the "Exception calling GetDirectories..." # noise and keeps the useful part (access denied, path not found, ...). $out.Error = (($ps.Streams.Error | ForEach-Object { $_.Exception.GetBaseException().Message }) -join ' ; ') } } catch { $out.Error = $_.Exception.Message } finally { if (-not $out.TimedOut) { if ($ps) { try { $ps.Dispose() } catch { } } if ($rs) { try { $rs.Dispose() } catch { } } } } return $out } function Get-FilerDriveInventory { <# Every drive the tool can reach, local and network, merged from three sources: - [IO.DriveInfo] : what is mounted in THIS process token, - HKCU\Network : the user's persistent mappings, still listed when the letter is absent from an elevated token (the hive is shared by both tokens of the same user), - Get-PSDrive : PowerShell-only mappings, invisible to Win32. Network entries are never probed for readiness or free space: an offline share would block the call for tens of seconds. #> Initialize-FilerNetApi $byKey = [ordered]@{} # 1) Drives actually mounted in this process. foreach ($d in [System.IO.DriveInfo]::GetDrives()) { $name = $d.Name.Substring(0, 2) $key = $name.ToUpperInvariant() $type = switch ([string]$d.DriveType) { 'Fixed' { 'Local' } 'Network' { 'Network' } 'Removable' { 'Removable' } 'CDRom' { 'CDRom' } 'Ram' { 'RAM' } default { 'Unknown' } } $entry = [ordered]@{ Name = $name; Type = $type; Unc = $null; Label = '' Mounted = $true; Ready = $null; Persistent = $false Source = 'Session'; FreeBytes = $null; TotalBytes = $null } if ($type -eq 'Network') { try { $entry['Unc'] = [FilerManager.NetApi]::GetUncForDrive($name) } catch { } } else { # Local media only: probing a network drive here can hang. try { $entry['Ready'] = [bool]$d.IsReady if ($d.IsReady) { $entry['Label'] = [string]$d.VolumeLabel $entry['FreeBytes'] = [long]$d.AvailableFreeSpace $entry['TotalBytes'] = [long]$d.TotalSize } } catch { } } $byKey[$key] = $entry } # 2) The user's persistent mappings (HKCU is shared between the elevated and # non-elevated token of the same user, the mounted letters are not). try { $regRoot = 'Registry::HKEY_CURRENT_USER\Network' if (Test-Path -LiteralPath $regRoot) { foreach ($k in (Get-ChildItem -LiteralPath $regRoot -ErrorAction Stop)) { $letter = ([string]$k.PSChildName).ToUpperInvariant() if ($letter -notmatch '^[A-Z]$') { continue } $remote = $null try { $remote = (Get-ItemProperty -LiteralPath $k.PSPath -Name 'RemotePath' -ErrorAction Stop).RemotePath } catch { } if ([string]::IsNullOrWhiteSpace($remote)) { continue } $key = "${letter}:" if ($byKey.Contains($key)) { $e = $byKey[$key] if (-not $e['Unc']) { $e['Unc'] = $remote } $e['Persistent'] = $true } else { $byKey[$key] = [ordered]@{ Name = $key; Type = 'Network'; Unc = $remote; Label = '' Mounted = $false; Ready = $null; Persistent = $true Source = 'Profil'; FreeBytes = $null; TotalBytes = $null } } } } } catch { } # 3) PowerShell-only drives rooted on a UNC path. try { foreach ($pd in (Get-PSDrive -PSProvider FileSystem -ErrorAction Stop)) { $root = [string]$pd.Root if (-not $root.StartsWith('\\')) { continue } $key = ([string]$pd.Name + ':').ToUpperInvariant() if ($byKey.Contains($key)) { continue } $byKey[$key] = [ordered]@{ Name = ([string]$pd.Name + ':'); Type = 'Network'; Unc = $root.TrimEnd('\'); Label = '' Mounted = $false; Ready = $null; Persistent = $false Source = 'PSDrive'; FreeBytes = $null; TotalBytes = $null } } } catch { } $out = New-Object System.Collections.ArrayList foreach ($key in $byKey.Keys) { $e = $byKey[$key] [void]$out.Add([pscustomobject]@{ Name = [string]$e['Name'] Root = ([string]$e['Name'] + '\') Type = [string]$e['Type'] Unc = [string]$e['Unc'] Label = [string]$e['Label'] Mounted = [bool]$e['Mounted'] Ready = $e['Ready'] Persistent = [bool]$e['Persistent'] Source = [string]$e['Source'] FreeBytes = $e['FreeBytes'] TotalBytes = $e['TotalBytes'] IsNetwork = ([string]$e['Type'] -eq 'Network') }) } # Local media first, then network, each alphabetically. return @($out | Sort-Object @{E = { [int][bool]$_.IsNetwork } }, Name) } function Get-FilerUncRoot { # '\\srv\share\a\b' -> '\\srv\share' ; '\\srv' -> '\\srv' ; $null otherwise. param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } $p = $Path.Trim() if (-not $p.StartsWith('\\')) { return $null } $parts = @($p.Substring(2) -split '\\' | Where-Object { $_ }) if ($parts.Count -eq 0) { return $null } if ($parts.Count -eq 1) { return ('\\' + $parts[0]) } return ('\\' + $parts[0] + '\' + $parts[1]) } function Resolve-FilerScanPath { <# Normalises a user-supplied path and works out the form that is actually scannable from this process. A drive letter is rewritten to its UNC target when the mapping is not mounted in the current token (the usual case for an elevated run of a drive mapped by the interactive user), or when -PreferUnc is set. Pass -Inventory to reuse a Get-FilerDriveInventory result across many paths. #> param( [string]$Path, [switch]$PreferUnc, $Inventory ) $res = [pscustomobject]@{ Input = $Path; Path = $null; Unc = $null; Letter = $null IsNetwork = $false; Mounted = $true; Rewritten = $false; Note = $null } if ([string]::IsNullOrWhiteSpace($Path)) { return $res } # Tidy up: quotes, environment variables, forward slashes, duplicate separators. $p = $Path.Trim().Trim('"').Trim("'").Trim() try { $p = [System.Environment]::ExpandEnvironmentVariables($p) } catch { } if ($p.StartsWith('//')) { $p = '\\' + $p.Substring(2) } $p = $p.Replace('/', '\') $prefix = '' if ($p.StartsWith('\\')) { $prefix = '\\'; $p = $p.Substring(2) } while ($p.Contains('\\')) { $p = $p.Replace('\\', '\') } $p = $prefix + $p # Trailing separator is dropped, except on a drive root where it is required. if ($p.Length -gt 3 -and $p.EndsWith('\')) { $p = $p.TrimEnd('\') } # Extended-length syntax is passed through untouched. if ($p.StartsWith('\\?\')) { $res.Path = $p $res.IsNetwork = $p.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase) return $res } if ($p -match '^([A-Za-z]):($|\\)') { $letter = $Matches[1].ToUpperInvariant() + ':' $rest = $p.Substring(2).TrimEnd('\') $res.Letter = $letter if (-not $Inventory) { $Inventory = Get-FilerDriveInventory } $drv = @($Inventory | Where-Object { $_.Name -eq $letter }) | Select-Object -First 1 if ($drv) { $res.IsNetwork = [bool]$drv.IsNetwork $res.Mounted = [bool]$drv.Mounted if ($drv.Unc) { $res.Unc = ($drv.Unc.TrimEnd('\') + $rest) } } else { $res.Mounted = $false } if ($res.Unc -and ($PreferUnc -or -not $res.Mounted)) { $res.Path = $res.Unc $res.Rewritten = $true if (-not $res.Mounted) { $res.Note = "$letter n'est pas monté dans cette session (élévation ?) ; chemin UNC utilisé : $($res.Unc)" } } else { $res.Path = $p if ($res.IsNetwork -and -not $res.Mounted) { $res.Note = "$letter n'est pas monté dans cette session et aucune cible UNC n'est connue." } } return $res } if ($p.StartsWith('\\')) { $res.IsNetwork = $true $res.Unc = $p $res.Path = $p return $res } $res.Path = $p return $res } function Test-FilerPathReachable { <# Timeout-guarded Test-Path. Returns Exists / TimedOut / Error so the caller can tell "not there" from "the server never answered". #> param([string]$Path, [int]$TimeoutSeconds = 8) $r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($Path) -Script { param($p) [bool](Test-Path -LiteralPath $p -PathType Container) } $exists = $false if (-not $r.TimedOut -and -not $r.Error) { $exists = [bool](@($r.Result) | Select-Object -First 1) } return [pscustomobject]@{ Exists = $exists; TimedOut = [bool]$r.TimedOut; Error = $r.Error } } function Get-FilerShare { <# Disk shares published by a server, via NetShareEnum (locale-independent, no Administrator rights needed). Falls back to WMI when the RAP call is refused, which happens on some hardened hosts. #> param( [string]$Server, [switch]$IncludeSpecial, [int]$TimeoutSeconds = 10 ) Initialize-FilerNetApi $srv = ([string]$Server).Trim().TrimStart('\') $srv = @($srv -split '\\' | Where-Object { $_ })[0] if (-not $srv) { throw 'Nom de serveur vide.' } $r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($srv) -Script { param($s) [FilerManager.NetApi]::EnumShares('\\' + $s) } if ($r.TimedOut) { throw "Délai dépassé en interrogeant \\$srv." } $entries = @() if ($r.Error) { # WMI fallback (needs rights on the target, but works where RAP is blocked). $w = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($srv) -Script { param($s) Get-CimInstance -ClassName Win32_Share -ComputerName $s -ErrorAction Stop | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Remark = $_.Description; IsSpecial = ([string]$_.Name).EndsWith('$'); IsDisk = ($_.Type -eq 0) } } } if ($w.TimedOut -or $w.Error) { throw ("Énumération des partages impossible sur \\{0} : {1}" -f $srv, $r.Error) } $entries = @($w.Result) } else { $entries = @($r.Result) } $shares = @($entries | Where-Object { $_ -and $_.Name -and $_.IsDisk }) if (-not $IncludeSpecial) { $shares = @($shares | Where-Object { -not $_.IsSpecial }) } return @($shares | ForEach-Object { [pscustomobject]@{ Name = [string]$_.Name Remark = [string]$_.Remark IsSpecial = [bool]$_.IsSpecial Path = ('\\' + $srv + '\' + $_.Name) } } | Sort-Object Name) } function Get-FilerChildFolder { <# Timeout-guarded subfolder listing, usable on local and UNC paths alike. Returns Folders / TimedOut / Error. #> param([string]$Path, [int]$TimeoutSeconds = 15) $r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($Path) -Script { param($p) [System.IO.Directory]::GetDirectories($p) } $folders = @() if (-not $r.TimedOut -and -not $r.Error) { $folders = @($r.Result | Where-Object { $_ } | Sort-Object) } return [pscustomobject]@{ Folders = $folders; TimedOut = [bool]$r.TimedOut; Error = $r.Error } } function Connect-FilerShare { <# Opens an authenticated connection to a UNC path, optionally on a drive letter. Without a letter the credentials are simply registered for the session, which is what a scan needs and costs no letter. Note: WNetAddConnection2 takes the password as plain text, so it is materialised from the PSCredential for the duration of the call only. #> param( [Parameter(Mandatory)] [string]$RemotePath, [string]$DriveLetter, [System.Management.Automation.PSCredential]$Credential, [switch]$Persistent ) Initialize-FilerNetApi $remote = ([string]$RemotePath).Trim().TrimEnd('\') if (-not $remote.StartsWith('\\')) { throw "Chemin réseau attendu (\\serveur\partage) : $RemotePath" } $user = $null; $pass = $null if ($Credential) { $user = $Credential.UserName $pass = $Credential.GetNetworkCredential().Password } $local = if ($DriveLetter) { ([string]$DriveLetter).Trim().TrimEnd('\') } else { $null } $rc = [FilerManager.NetApi]::Connect($remote, $local, $user, $pass, [bool]$Persistent) $pass = $null if ($rc -eq 0) { return } $msg = switch ($rc) { 5 { "Accès refusé." } 53 { "Serveur ou partage introuvable." } 67 { "Nom de partage introuvable." } 86 { "Mot de passe incorrect." } 1219 { "Une autre connexion à ce serveur existe déjà avec d'autres identifiants (fermez-la d'abord : net use /delete)." } 1326 { "Nom d'utilisateur ou mot de passe incorrect." } default { (New-Object System.ComponentModel.Win32Exception($rc)).Message } } throw ("Connexion à {0} échouée : {1} (code {2})" -f $remote, $msg, $rc) } '@ # 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 # ============================================================================ $runHeadless = $NoGui -or ($Path -and ($Output -or $CsvOutput)) if ($runHeadless) { if (-not $Path) { throw "Le mode sans interface requiert -Path." } if (-not $Output -and -not $CsvOutput) { throw "Le mode sans interface requiert -Output et/ou -CsvOutput." } if ($GrantAccess -and -not (Test-IsElevated)) { 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 ` -PermissionDepth $PermissionDepth ` -IncludeFilesInTree:$IncludeFilesInTree ` -ExcludeFolder $ExcludeFolder -ExcludeHidden:$ExcludeHidden ` -GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress if ($Output) { $out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category -HideInheritedChildPerms:$HideInheritedChildPerms -HideSystemPrincipals:$HideSystemPrincipals Write-Host "Rapport HTML généré : $out" -ForegroundColor Green } if ($CsvOutput) { $csvFiles = @(ConvertTo-FilerCsvReport -Scan $scan -Path $CsvOutput -Categories $Category -HideInheritedChildPerms:$HideInheritedChildPerms -HideSystemPrincipals:$HideSystemPrincipals) if ($csvFiles.Count -gt 0) { Write-Host "Rapport(s) CSV généré(s) :" -ForegroundColor Green foreach ($f in $csvFiles) { Write-Host " $f" -ForegroundColor Green } } else { Write-Host "Aucun fichier CSV généré (catégories sans données)." -ForegroundColor Yellow } } Write-Host (" {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs" -f ` (Format-Bytes $scan.Stats.TotalSize), $scan.Stats.TotalFolders, $scan.Stats.TotalFiles, $scan.Stats.LongPaths, $scan.Stats.Errors) if ($GrantAccess) { Write-Host (" {0} élément(s) ont reçu un accès ({1})" -f ` $scan.Stats.Grants, $(if ($KeepGrants) { 'conservé' } else { 'annulé' })) -ForegroundColor Yellow } return } # ============================================================================ # GUI MODE # ============================================================================ Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() $form = New-Object System.Windows.Forms.Form $form.Text = 'Filer Manager' $form.Size = New-Object System.Drawing.Size(960, 720) $form.StartPosition = 'CenterScreen' $form.MinimumSize = New-Object System.Drawing.Size(860, 600) # ---- top: folder list + add/remove ---- $lblFolders = New-Object System.Windows.Forms.Label $lblFolders.Text = 'Dossiers à analyser :' $lblFolders.Location = '12,12'; $lblFolders.AutoSize = $true $form.Controls.Add($lblFolders) $lstFolders = New-Object System.Windows.Forms.ListBox $lstFolders.Location = '12,32'; $lstFolders.Size = '800,84' $lstFolders.Anchor = 'Top,Left,Right' $lstFolders.HorizontalScrollbar = $true $lstFolders.SelectionMode = 'MultiExtended' $form.Controls.Add($lstFolders) $btnAdd = New-Object System.Windows.Forms.Button $btnAdd.Text = 'A&jouter...'; $btnAdd.Location = '824,32'; $btnAdd.Size = '110,28' $btnAdd.Anchor = 'Top,Right' $form.Controls.Add($btnAdd) $btnRemove = New-Object System.Windows.Forms.Button $btnRemove.Text = '&Supprimer'; $btnRemove.Location = '824,66'; $btnRemove.Size = '110,28' $btnRemove.Anchor = 'Top,Right'; $btnRemove.Enabled = $false $form.Controls.Add($btnRemove) # ---- settings row ---- $lblMax = New-Object System.Windows.Forms.Label $lblMax.Text = 'Longueur max :'; $lblMax.Location = '12,128'; $lblMax.AutoSize = $true $form.Controls.Add($lblMax) $numMax = New-Object System.Windows.Forms.NumericUpDown $numMax.Location = '118,126'; $numMax.Size = '70,24' $numMax.Minimum = 1; $numMax.Maximum = 32767; $numMax.Value = $MaxPathLength $form.Controls.Add($numMax) $lblDepth = New-Object System.Windows.Forms.Label $lblDepth.Text = 'Profondeur :'; $lblDepth.Location = '202,128'; $lblDepth.AutoSize = $true $form.Controls.Add($lblDepth) $numDepth = New-Object System.Windows.Forms.NumericUpDown $numDepth.Location = '288,126'; $numDepth.Size = '60,24' $numDepth.Minimum = 0; $numDepth.Maximum = 20; $numDepth.Value = $PermissionDepth $form.Controls.Add($numDepth) $chkFiles = New-Object System.Windows.Forms.CheckBox $chkFiles.Text = 'Inclure les fichiers'; $chkFiles.Location = '362,127'; $chkFiles.AutoSize = $true $chkFiles.Checked = [bool]$IncludeFilesInTree $form.Controls.Add($chkFiles) # ---- exclusions row ---- $lblExclude = New-Object System.Windows.Forms.Label $lblExclude.Text = 'Exclure dossiers :'; $lblExclude.Location = '12,160'; $lblExclude.AutoSize = $true $form.Controls.Add($lblExclude) $txtExclude = New-Object System.Windows.Forms.TextBox $txtExclude.Location = '118,157'; $txtExclude.Size = '230,24' # Patterns are separated by ';' or ','. Wildcards (* ?) and casing are honoured. $txtExclude.Text = ($ExcludeFolder -join '; ') $tip = New-Object System.Windows.Forms.ToolTip $tip.SetToolTip($txtExclude, "Noms de dossiers à ignorer, séparés par ';'. Jokers acceptés (ex. #recycle; @eaDir; Thumbs*).") $form.Controls.Add($txtExclude) $chkHidden = New-Object System.Windows.Forms.CheckBox $chkHidden.Text = 'Dossiers masqués'; $chkHidden.Location = '362,159'; $chkHidden.AutoSize = $true $chkHidden.Checked = [bool]$ExcludeHidden $tip.SetToolTip($chkHidden, "Exclure aussi les dossiers portant l'attribut « masqué ».") $form.Controls.Add($chkHidden) # ---- auto-grant row ---- $chkGrant = New-Object System.Windows.Forms.CheckBox $chkGrant.Text = "Accorder l'accès Administrators aux éléments refusés" $chkGrant.Location = '12,188'; $chkGrant.AutoSize = $true $chkGrant.Checked = [bool]$GrantAccess $form.Controls.Add($chkGrant) $chkRevert = New-Object System.Windows.Forms.CheckBox $chkRevert.Text = "Annuler les modifications après l'analyse" $chkRevert.Location = '380,188'; $chkRevert.AutoSize = $true $chkRevert.Checked = (-not $KeepGrants) $chkRevert.Enabled = $chkGrant.Checked $form.Controls.Add($chkRevert) $chkGrant.Add_CheckedChanged({ $chkRevert.Enabled = $chkGrant.Checked }) $btnScan = New-Object System.Windows.Forms.Button $btnScan.Text = '&Analyser'; $btnScan.Location = '700,124'; $btnScan.Size = '110,30' $btnScan.Anchor = 'Top,Right' $btnScan.BackColor = [System.Drawing.Color]::FromArgb(79,140,255) $btnScan.ForeColor = [System.Drawing.Color]::White $btnScan.FlatStyle = 'Flat' $btnScan.Cursor = [System.Windows.Forms.Cursors]::Hand $btnScan.FlatAppearance.BorderSize = 0 $btnScan.FlatAppearance.MouseOverBackColor = [System.Drawing.Color]::FromArgb(104, 158, 255) $btnScan.FlatAppearance.MouseDownBackColor = [System.Drawing.Color]::FromArgb(56, 114, 220) # A flat button keeps its custom BackColor when disabled, which reads as "still # clickable". Dim it explicitly instead. $btnScan.Add_EnabledChanged({ $btnScan.BackColor = if ($btnScan.Enabled) { [System.Drawing.Color]::FromArgb(79, 140, 255) } else { [System.Drawing.Color]::FromArgb(176, 186, 200) } }) $form.Controls.Add($btnScan) $btnExport = New-Object System.Windows.Forms.Button $btnExport.Text = '&Exporter...'; $btnExport.Location = '824,124'; $btnExport.Size = '110,30' $btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false $form.Controls.Add($btnExport) # Defined here because the list views below hook it on Resize, which can fire # while the tab pages are still being built. function Update-FilerListFill { <# Gives one column whatever width the other columns leave, so the long path/folder column follows the window instead of staying at its design width with a horizontal scrollbar. #> param($List, [int]$Col) if ($null -eq $List -or $List.Columns.Count -le $Col) { return } $other = 0 for ($i = 0; $i -lt $List.Columns.Count; $i++) { if ($i -ne $Col) { $other += $List.Columns[$i].Width } } $w = $List.ClientSize.Width - $other - 4 if ($w -gt 140) { $List.Columns[$Col].Width = $w } } # ---- tabs ---- $tabs = New-Object System.Windows.Forms.TabControl $tabs.Location = '12,220'; $tabs.Size = '922,437' $tabs.Anchor = 'Top,Bottom,Left,Right' $form.Controls.Add($tabs) $tabTree = New-Object System.Windows.Forms.TabPage; $tabTree.Text = 'Tailles des dossiers' $tabLong = New-Object System.Windows.Forms.TabPage; $tabLong.Text = 'Chemins trop longs' $tabPerm = New-Object System.Windows.Forms.TabPage; $tabPerm.Text = 'Permissions' $tabGrant = New-Object System.Windows.Forms.TabPage; $tabGrant.Text = 'Accès accordé' $tabRename = New-Object System.Windows.Forms.TabPage; $tabRename.Text = 'Renommer' $tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant, $tabRename)) $tree = New-Object System.Windows.Forms.TreeView $tree.Dock = 'Fill'; $tree.HideSelection = $false # Owner-draw the labels so we can paint a proportional "space taken" bar after # each node, mirroring the bars in the HTML report. The control still draws the # +/- expanders and connecting lines itself. $tree.DrawMode = 'OwnerDrawText' $tabTree.Controls.Add($tree) # Fill a node's real children the first time it is expanded (lazy loading keeps # the GUI responsive no matter how large the scanned tree is). $tree.Add_BeforeExpand({ param($s, $e) Expand-FilerTreeNode -TreeNode $e.Node }) # DrawNode fires for every visible row on every repaint, so the brushes and the # pen are built once here instead of three GDI allocations per row per paint. $script:BarTrack = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(228, 230, 235)) $script:BarFill = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(59, 111, 212)) $script:BarEdge = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 175, 185)) $script:BarSel = New-Object System.Drawing.SolidBrush ([System.Drawing.SystemColors]::Highlight) $script:TreeTextFlags = [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor ` [System.Windows.Forms.TextFormatFlags]::Left -bor ` [System.Windows.Forms.TextFormatFlags]::NoPrefix # Render each tree node: the label, then a bar showing the folder's share of its # parent's size (roots = 100%), then the percentage - matching the HTML report. $tree.Add_DrawNode({ param($s, $e) $nodeData = $e.Node.Tag # Placeholder ("Chargement...") and not-yet-measured nodes: draw normally. if ($null -eq $nodeData -or $e.Bounds.Width -le 0 -or -not ($nodeData.PSObject.Properties.Name -contains 'Size')) { $e.DrawDefault = $true; return } $selected = ($e.State -band [System.Windows.Forms.TreeNodeStates]::Selected) -ne 0 $foreColor = if ($selected) { [System.Drawing.SystemColors]::HighlightText } else { $tree.ForeColor } if ($selected) { $e.Graphics.FillRectangle($script:BarSel, $e.Bounds) } $flags = $script:TreeTextFlags [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, $e.Node.Text, $tree.Font, $e.Bounds, $foreColor, $flags) # Share of the parent folder's size (top-level roots have no parent = 100%). $pct = 100.0 $parent = $e.Node.Parent if ($parent -and $parent.Tag -and ($parent.Tag.PSObject.Properties.Name -contains 'Size') -and $parent.Tag.Size -gt 0) { $pct = [math]::Round(($nodeData.Size / $parent.Tag.Size) * 100, 1) } if ($pct -lt 0) { $pct = 0 } elseif ($pct -gt 100) { $pct = 100 } # Bar just to the right of the label - skipped entirely when a long label has # already pushed it out of view, so deep trees do not pay for invisible paint. $barW = 120; $barH = 9 $barX = $e.Bounds.Right + 10 $barY = $e.Bounds.Top + [int](($e.Bounds.Height - $barH) / 2) if ($barX -ge $tree.ClientSize.Width) { return } $e.Graphics.FillRectangle($script:BarTrack, $barX, $barY, $barW, $barH) $fillW = [int][math]::Round($barW * $pct / 100) if ($fillW -gt 0) { $e.Graphics.FillRectangle($script:BarFill, $barX, $barY, $fillW, $barH) } $e.Graphics.DrawRectangle($script:BarEdge, $barX, $barY, $barW, $barH) $pctRect = New-Object System.Drawing.Rectangle (($barX + $barW + 6), $e.Bounds.Top, 52, $e.Bounds.Height) [System.Windows.Forms.TextRenderer]::DrawText($e.Graphics, ('{0:N1}%' -f $pct), $tree.Font, $pctRect, $tree.ForeColor, $flags) }) $lvLong = New-Object System.Windows.Forms.ListView $lvLong.Dock = 'Fill'; $lvLong.View = 'Details'; $lvLong.FullRowSelect = $true; $lvLong.GridLines = $true [void]$lvLong.Columns.Add('Longueur', 70); [void]$lvLong.Columns.Add('Type', 70); [void]$lvLong.Columns.Add('Chemin', 760) $tabLong.Controls.Add($lvLong) $lvPerm = New-Object System.Windows.Forms.ListView $lvPerm.Dock = 'Fill'; $lvPerm.View = 'Details'; $lvPerm.FullRowSelect = $true; $lvPerm.GridLines = $true [void]$lvPerm.Columns.Add('Dossier', 320); [void]$lvPerm.Columns.Add('Identité', 200) [void]$lvPerm.Columns.Add('Droits', 200); [void]$lvPerm.Columns.Add('Type', 60); [void]$lvPerm.Columns.Add('Hérité', 70) $tabPerm.Controls.Add($lvPerm) $lvGrant = New-Object System.Windows.Forms.ListView $lvGrant.Dock = 'Fill'; $lvGrant.View = 'Details'; $lvGrant.FullRowSelect = $true; $lvGrant.GridLines = $true [void]$lvGrant.Columns.Add('Chemin', 360); [void]$lvGrant.Columns.Add('Raison', 110) [void]$lvGrant.Columns.Add('Modification', 240); [void]$lvGrant.Columns.Add("Propriétaire d'origine", 150) [void]$lvGrant.Columns.Add('État', 90) $tabGrant.Controls.Add($lvGrant) # Filter toolbar for the permissions tab (docked above the list). $tabW = 914 # baseline width the right-aligned tab controls are anchored to $permBar = New-Object System.Windows.Forms.Panel $permBar.Dock = 'Top'; $permBar.Height = 30; $permBar.Width = $tabW $lblPermHint = New-Object System.Windows.Forms.Label $lblPermHint.Text = 'Cliquez sur un en-tête de colonne pour filtrer ou trier.' # Sized rather than auto-sized: it shares the row with the two check boxes, so # it has to give ground (and ellipsize) as the window narrows. $lblPermHint.AutoSize = $false; $lblPermHint.Location = '6,8'; $lblPermHint.Size = '330,18' $lblPermHint.Anchor = 'Top,Left,Right'; $lblPermHint.AutoEllipsis = $true $lblPermHint.ForeColor = [System.Drawing.Color]::DimGray $chkPermHideInh = New-Object System.Windows.Forms.CheckBox $chkPermHideInh.Text = 'Masquer les héritées (enfants)' $chkPermHideInh.Size = '220,20'; $chkPermHideInh.Location = '342,6'; $chkPermHideInh.Anchor = 'Top,Right' $chkPermHideSys = New-Object System.Windows.Forms.CheckBox $chkPermHideSys.Text = 'Masquer les comptes système' $chkPermHideSys.Size = '200,20'; $chkPermHideSys.Location = '570,6'; $chkPermHideSys.Anchor = 'Top,Right' $btnPermClear = New-Object System.Windows.Forms.Button $btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24' $btnPermClear.Location = '778,3'; $btnPermClear.Anchor = 'Top,Right'; $btnPermClear.Enabled = $false $permBar.Controls.AddRange(@($lblPermHint, $chkPermHideInh, $chkPermHideSys, $btnPermClear)) $tabPerm.Controls.Add($permBar) $tip.SetToolTip($chkPermHideInh, "Masquer les permissions héritées portées par les dossiers enfants.`nCelles des dossiers racines sont conservées : leur parent est hors analyse.") $tip.SetToolTip($chkPermHideSys, "Masquer les entrées détenues par les comptes et groupes système bien connus`n(SYSTEM, Administrators, CREATOR OWNER, NT SERVICE\*) pour ne garder que les identités métier.") $tip.SetToolTip($btnPermClear, 'Supprimer tous les filtres de colonne actifs.') $lvLong.Add_Resize({ Update-FilerListFill -List $lvLong -Col 2 }) $lvPerm.Add_Resize({ Update-FilerListFill -List $lvPerm -Col 0 }) $lvGrant.Add_Resize({ Update-FilerListFill -List $lvGrant -Col 0 }) $lvPerm.Add_ColumnClick({ param($s, $e) Show-PermColumnMenu -ColumnIndex $e.Column }) $btnPermClear.Add_Click({ $script:PermFilters = @{}; Update-PermView }) $chkPermHideInh.Add_CheckedChanged({ $script:PermHideInherited = $chkPermHideInh.Checked; Update-PermView }) $chkPermHideSys.Add_CheckedChanged({ $script:PermHideSystem = $chkPermHideSys.Checked; Update-PermView }) # ---- Renommer tab: batch rename with find/replace, regex, case, affixes, numbering ---- $rnW = $tabW # baseline width used to anchor the right-aligned controls # Preview grid (added first so the docked panels below claim their edges and the # grid fills the remaining space - same ordering trick used by the perm filter bar). $lvRename = New-Object System.Windows.Forms.ListView $lvRename.Dock = 'Fill'; $lvRename.View = 'Details'; $lvRename.FullRowSelect = $true $lvRename.GridLines = $true; $lvRename.CheckBoxes = $true [void]$lvRename.Columns.Add('Type', 60); [void]$lvRename.Columns.Add('Ancien nom', 250) [void]$lvRename.Columns.Add('Nouveau nom', 250); [void]$lvRename.Columns.Add('État', 130) [void]$lvRename.Columns.Add('Dossier', 320) $tabRename.Controls.Add($lvRename) # Top options panel. $rnTop = New-Object System.Windows.Forms.Panel $rnTop.Dock = 'Top'; $rnTop.Height = 200; $rnTop.Width = $rnW # Row 1: source folder + browse + list. $lblRnPath = New-Object System.Windows.Forms.Label $lblRnPath.Text = 'Dossier :'; $lblRnPath.Location = '6,13'; $lblRnPath.AutoSize = $true $rnPath = New-Object System.Windows.Forms.TextBox $rnPath.Location = '90,10'; $rnPath.Width = ($rnW - 90 - 198); $rnPath.Anchor = 'Top,Left,Right' $rnBrowse = New-Object System.Windows.Forms.Button $rnBrowse.Text = 'Parcourir...'; $rnBrowse.Size = '88,24'; $rnBrowse.Location = "$($rnW - 190),9"; $rnBrowse.Anchor = 'Top,Right' $rnList = New-Object System.Windows.Forms.Button $rnList.Text = 'Lister'; $rnList.Size = '88,24'; $rnList.Location = "$($rnW - 94),9"; $rnList.Anchor = 'Top,Right' $rnTop.Controls.AddRange(@($lblRnPath, $rnPath, $rnBrowse, $rnList)) # Row 2: scope options. $chkRnRecurse = New-Object System.Windows.Forms.CheckBox $chkRnRecurse.Text = 'Inclure les sous-dossiers'; $chkRnRecurse.Location = '90,40'; $chkRnRecurse.AutoSize = $true $chkRnFiles = New-Object System.Windows.Forms.CheckBox $chkRnFiles.Text = 'Fichiers'; $chkRnFiles.Location = '270,40'; $chkRnFiles.AutoSize = $true; $chkRnFiles.Checked = $true $chkRnFolders = New-Object System.Windows.Forms.CheckBox $chkRnFolders.Text = 'Dossiers'; $chkRnFolders.Location = '365,40'; $chkRnFolders.AutoSize = $true; $chkRnFolders.Checked = $true $chkRnHidden = New-Object System.Windows.Forms.CheckBox $chkRnHidden.Text = 'Exclure les dossiers masqués'; $chkRnHidden.Location = '470,40'; $chkRnHidden.AutoSize = $true; $chkRnHidden.Checked = $true $rnTop.Controls.AddRange(@($chkRnRecurse, $chkRnFiles, $chkRnFolders, $chkRnHidden)) # Row 3: find. $lblRnFind = New-Object System.Windows.Forms.Label $lblRnFind.Text = 'Rechercher :'; $lblRnFind.Location = '6,75'; $lblRnFind.AutoSize = $true $rnFind = New-Object System.Windows.Forms.TextBox $rnFind.Location = '90,72'; $rnFind.Width = ($rnW - 90 - 222); $rnFind.Anchor = 'Top,Left,Right' $chkRnRegex = New-Object System.Windows.Forms.CheckBox $chkRnRegex.Text = 'Regex'; $chkRnRegex.Size = '70,22'; $chkRnRegex.Location = "$($rnW - 214),74"; $chkRnRegex.Anchor = 'Top,Right' $chkRnIgnoreCase = New-Object System.Windows.Forms.CheckBox $chkRnIgnoreCase.Text = 'Ignorer la casse'; $chkRnIgnoreCase.Size = '130,22'; $chkRnIgnoreCase.Location = "$($rnW - 136),74"; $chkRnIgnoreCase.Anchor = 'Top,Right' $rnTop.Controls.AddRange(@($lblRnFind, $rnFind, $chkRnRegex, $chkRnIgnoreCase)) # Row 4: replace. $lblRnReplace = New-Object System.Windows.Forms.Label $lblRnReplace.Text = 'Remplacer :'; $lblRnReplace.Location = '6,105'; $lblRnReplace.AutoSize = $true $rnReplace = New-Object System.Windows.Forms.TextBox $rnReplace.Location = '90,102'; $rnReplace.Width = ($rnW - 90 - 222); $rnReplace.Anchor = 'Top,Left,Right' $chkRnIgnoreExt = New-Object System.Windows.Forms.CheckBox $chkRnIgnoreExt.Text = "Ignorer l'extension"; $chkRnIgnoreExt.Size = '160,22'; $chkRnIgnoreExt.Location = "$($rnW - 166),104"; $chkRnIgnoreExt.Anchor = 'Top,Right'; $chkRnIgnoreExt.Checked = $true $rnTop.Controls.AddRange(@($lblRnReplace, $rnReplace, $chkRnIgnoreExt)) # Row 5: case + affixes. $lblRnCase = New-Object System.Windows.Forms.Label $lblRnCase.Text = 'Casse :'; $lblRnCase.Location = '6,137'; $lblRnCase.AutoSize = $true $rnCase = New-Object System.Windows.Forms.ComboBox $rnCase.DropDownStyle = 'DropDownList'; $rnCase.Location = '90,134'; $rnCase.Width = 150 [void]$rnCase.Items.AddRange(@('Conserver', 'MAJUSCULES', 'minuscules', 'Première Lettre Des Mots', 'Première lettre')) $rnCase.SelectedIndex = 0 $lblRnPrefix = New-Object System.Windows.Forms.Label $lblRnPrefix.Text = 'Préfixe :'; $lblRnPrefix.Location = '256,137'; $lblRnPrefix.AutoSize = $true $rnPrefix = New-Object System.Windows.Forms.TextBox $rnPrefix.Location = '310,134'; $rnPrefix.Width = 150 $lblRnSuffix = New-Object System.Windows.Forms.Label $lblRnSuffix.Text = 'Suffixe :'; $lblRnSuffix.Location = '476,137'; $lblRnSuffix.AutoSize = $true $rnSuffix = New-Object System.Windows.Forms.TextBox $rnSuffix.Location = '530,134'; $rnSuffix.Width = 150 $rnTop.Controls.AddRange(@($lblRnCase, $rnCase, $lblRnPrefix, $rnPrefix, $lblRnSuffix, $rnSuffix)) # Row 6: sequential numbering + preview. $chkRnNumber = New-Object System.Windows.Forms.CheckBox $chkRnNumber.Text = 'Numéroter ({n})'; $chkRnNumber.Location = '6,170'; $chkRnNumber.AutoSize = $true $lblRnStart = New-Object System.Windows.Forms.Label $lblRnStart.Text = 'Début'; $lblRnStart.Location = '150,172'; $lblRnStart.AutoSize = $true $numRnStart = New-Object System.Windows.Forms.NumericUpDown $numRnStart.Location = '196,168'; $numRnStart.Width = 60; $numRnStart.Minimum = 0; $numRnStart.Maximum = 1000000; $numRnStart.Value = 1 $lblRnStep = New-Object System.Windows.Forms.Label $lblRnStep.Text = 'Pas'; $lblRnStep.Location = '266,172'; $lblRnStep.AutoSize = $true $numRnStep = New-Object System.Windows.Forms.NumericUpDown $numRnStep.Location = '298,168'; $numRnStep.Width = 55; $numRnStep.Minimum = 1; $numRnStep.Maximum = 100000; $numRnStep.Value = 1 $lblRnPad = New-Object System.Windows.Forms.Label $lblRnPad.Text = 'Chiffres'; $lblRnPad.Location = '364,172'; $lblRnPad.AutoSize = $true $numRnPad = New-Object System.Windows.Forms.NumericUpDown $numRnPad.Location = '418,168'; $numRnPad.Width = 50; $numRnPad.Minimum = 1; $numRnPad.Maximum = 10; $numRnPad.Value = 2 $rnPreview = New-Object System.Windows.Forms.Button $rnPreview.Text = "Rafraîchir l'aperçu"; $rnPreview.Size = '160,26'; $rnPreview.Location = "$($rnW - 166),167"; $rnPreview.Anchor = 'Top,Right' $rnTop.Controls.AddRange(@($chkRnNumber, $lblRnStart, $numRnStart, $lblRnStep, $numRnStep, $lblRnPad, $numRnPad, $rnPreview)) $tabRename.Controls.Add($rnTop) # Bottom action panel. $rnBottom = New-Object System.Windows.Forms.Panel $rnBottom.Dock = 'Bottom'; $rnBottom.Height = 44 $rnApply = New-Object System.Windows.Forms.Button $rnApply.Text = 'Renommer la sélection'; $rnApply.Size = '180,30'; $rnApply.Location = '6,7'; $rnApply.Enabled = $false $rnCheckAll = New-Object System.Windows.Forms.Button $rnCheckAll.Text = 'Tout cocher'; $rnCheckAll.Size = '110,30'; $rnCheckAll.Location = '194,7' $rnCheckNone = New-Object System.Windows.Forms.Button $rnCheckNone.Text = 'Tout décocher'; $rnCheckNone.Size = '110,30'; $rnCheckNone.Location = '310,7' $lblRnStatus = New-Object System.Windows.Forms.Label $lblRnStatus.Text = 'Choisissez un dossier puis cliquez sur Lister.'; $lblRnStatus.AutoSize = $true; $lblRnStatus.Location = '432,14' $lblRnStatus.ForeColor = [System.Drawing.Color]::DimGray $rnBottom.Controls.AddRange(@($rnApply, $rnCheckAll, $rnCheckNone, $lblRnStatus)) $tabRename.Controls.Add($rnBottom) # Wire the Renommer controls (handlers reference functions defined further below). $rnBrowse.Add_Click({ $picked = @(Show-FilerFolderPicker -Owner $form -SingleSelection ` -Description 'Sélectionnez le dossier contenant les éléments à renommer.' ` -InitialPath $rnPath.Text) if ($picked.Count -gt 0) { $rnPath.Text = [string]$picked[0] } }) $lvRename.Add_Resize({ Update-FilerListFill -List $lvRename -Col 4 }) $rnList.Add_Click({ Invoke-RenameListing }) $rnPreview.Add_Click({ Update-RenamePreview }) # Recompute the preview live as the rename options change. $rnOptionRecalc = { Update-RenamePreview } $rnFind.Add_TextChanged($rnOptionRecalc); $rnReplace.Add_TextChanged($rnOptionRecalc) $rnPrefix.Add_TextChanged($rnOptionRecalc); $rnSuffix.Add_TextChanged($rnOptionRecalc) $chkRnRegex.Add_CheckedChanged($rnOptionRecalc); $chkRnIgnoreCase.Add_CheckedChanged($rnOptionRecalc) $chkRnIgnoreExt.Add_CheckedChanged($rnOptionRecalc); $chkRnNumber.Add_CheckedChanged($rnOptionRecalc) $rnCase.Add_SelectedIndexChanged($rnOptionRecalc) $numRnStart.Add_ValueChanged($rnOptionRecalc); $numRnStep.Add_ValueChanged($rnOptionRecalc); $numRnPad.Add_ValueChanged($rnOptionRecalc) $rnCheckAll.Add_Click({ foreach ($it in $lvRename.Items) { if ($it.Tag -and $it.Tag.Changed) { $it.Checked = $true } } }) $rnCheckNone.Add_Click({ foreach ($it in $lvRename.Items) { $it.Checked = $false } }) # Only renameable rows (changed + no conflict/error) may be ticked. $lvRename.Add_ItemCheck({ param($s, $e) if ($e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) { $it = $lvRename.Items[$e.Index] if (-not ($it.Tag -and $it.Tag.Changed)) { $e.NewValue = [System.Windows.Forms.CheckState]::Unchecked } } }) $rnApply.Add_Click({ Invoke-RenameApply }) # ---- status bar ---- $status = New-Object System.Windows.Forms.StatusStrip $statusLbl = New-Object System.Windows.Forms.ToolStripStatusLabel $statusLbl.Text = 'Prêt. Ajoutez un ou plusieurs dossiers, puis Analyser.' $progressBar = New-Object System.Windows.Forms.ToolStripProgressBar $progressBar.Style = 'Marquee'; $progressBar.Visible = $false; $progressBar.Width = 140 [void]$status.Items.Add($statusLbl) [void]$status.Items.Add($progressBar) $form.Controls.Add($status) # ---- state ---- $script:LastScan = $null $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 $script:ExportPowerShell = $null $script:ExportHandle = $null $script:ExportShared = $null $script:ExportHtmlPath = $null # Permissions view state: source rows, active per-column filters, and sort. $script:AllPerms = @() $script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # column index -> property # Display labels for the permission columns (property name -> French header). $script:PermColLabels = @{ Folder = 'Dossier'; Identity = 'Identité'; Rights = 'Droits'; Type = 'Type'; Inherited = 'Hérité' } $script:PermFilters = @{} # property -> System.Collections.Generic.HashSet[string] of allowed values $script:PermMenu = $null # last column menu, disposed when the next one opens $script:PermSort = @{ Prop = 'Folder'; Asc = $true } # When $true, inherited ACEs on child folders are hidden (scan roots keep theirs). $script:PermHideInherited = $false # When $true, ACEs held by well-known system/built-in principals are hidden. $script:PermHideSystem = $false $script:PermRootPaths = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) # Rename view state: items listed from the chosen folder and the computed plan. $script:RenameItems = @() # source items: Name, FullPath, IsFile, Dir, Depth $script:RenameRows = @() # computed rows: Item, New, Status, Changed $script:RnCaseModes = @('none', 'upper', 'lower', 'title', 'first') # combobox index -> mode # ---- helpers ---- function New-FilerTreeNode { # Creates a TreeNode for a single scan node WITHOUT recursing into its # children. A placeholder child is added so the [+] expander shows; the real # children are filled on demand by Expand-FilerTreeNode (see the tree's # BeforeExpand handler). This makes even very large trees appear instantly. param($Node) $sizeStr = Format-Bytes $Node.Size $isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.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)" } $tn = New-Object System.Windows.Forms.TreeNode($text) $tn.Tag = $Node # keep the scan node so its children can be filled lazily $kids = @($Node.Children | Where-Object { $_ }) if ($kids.Count -gt 0) { # Placeholder (Tag stays $null, which marks the node as "not yet loaded"). [void]$tn.Nodes.Add((New-Object System.Windows.Forms.TreeNode('Chargement...'))) } return $tn } function Expand-FilerTreeNode { # Replaces the placeholder with the node's real children the first time it is # expanded. A node is "not yet loaded" while its single child has a $null Tag. param($TreeNode) if ($TreeNode.Nodes.Count -ne 1 -or $null -ne $TreeNode.Nodes[0].Tag) { return } $node = $TreeNode.Tag $TreeNode.TreeView.BeginUpdate() $TreeNode.Nodes.Clear() $kids = @($node.Children | Where-Object { $_ } | Sort-Object -Property Size -Descending) foreach ($c in $kids) { [void]$TreeNode.Nodes.Add((New-FilerTreeNode -Node $c)) } $TreeNode.TreeView.EndUpdate() } function Show-Results { param($Scan) $script:LastScan = $Scan $tree.BeginUpdate(); $tree.Nodes.Clear() foreach ($root in $Scan.Roots) { [void]$tree.Nodes.Add((New-FilerTreeNode -Node $root)) } if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() } # loads first level lazily $tree.EndUpdate() # One AddRange instead of a per-row Add: a scan of a big share can report # thousands of long paths and each Add re-lays the control out. $longItems = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($lp in $Scan.LongPaths) { $it = New-Object System.Windows.Forms.ListViewItem([string]$lp.Length) [void]$it.SubItems.Add($lp.Type); [void]$it.SubItems.Add($lp.Path) $longItems.Add($it) } $lvLong.BeginUpdate(); $lvLong.Items.Clear() if ($longItems.Count -gt 0) { $lvLong.Items.AddRange($longItems.ToArray()) } $lvLong.EndUpdate() $tabLong.Text = if ($longItems.Count -gt 0) { "Chemins trop longs ($($longItems.Count))" } else { 'Chemins trop longs' } $script:AllPerms = @($Scan.Permissions) $script:PermRootPaths = Get-FilerRootPathSet -Scan $Scan $script:PermFilters = @{} $script:PermSort = @{ Prop = 'Folder'; Asc = $true } Update-PermView $grantItems = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($g in @($Scan.Grants | Where-Object { $_.Success })) { $it = New-Object System.Windows.Forms.ListViewItem([string]$g.Path) [void]$it.SubItems.Add([string]$g.Reason) [void]$it.SubItems.Add([string]$g.Changes) [void]$it.SubItems.Add([string]$g.OriginalOwner) if ($g.Reverted) { $state = 'annulé' } elseif ($Scan.Settings.RevertGrants) { $state = 'NON annulé'; $it.ForeColor = [System.Drawing.Color]::Firebrick } else { $state = 'conservé'; $it.ForeColor = [System.Drawing.Color]::DarkGoldenrod } [void]$it.SubItems.Add($state) $grantItems.Add($it) } $lvGrant.BeginUpdate(); $lvGrant.Items.Clear() if ($grantItems.Count -gt 0) { $lvGrant.Items.AddRange($grantItems.ToArray()) } $lvGrant.EndUpdate() $tabGrant.Text = if ($grantItems.Count -gt 0) { "Accès accordé ($($grantItems.Count))" } else { 'Accès accordé' } Update-FilerListFill -List $lvLong -Col 2 Update-FilerListFill -List $lvGrant -Col 0 $statusLbl.Text = ("Terminé. {0} au total, {1} dossiers, {2} fichiers, {3} chemins trop longs, {4} erreurs." -f ` (Format-Bytes $Scan.Stats.TotalSize), $Scan.Stats.TotalFolders, $Scan.Stats.TotalFiles, $Scan.Stats.LongPaths, $Scan.Stats.Errors) $btnExport.Enabled = $true } function Get-PermCellValue { param($Ace, [string]$Prop) if ($Prop -eq 'Inherited') { return [string]$Ace.Inherited } return [string]$Ace.$Prop } function Update-PermView { # Re-renders the permissions ListView from $script:AllPerms applying the # active per-column filters and the current sort. # Every active filter in a single pass (the old code rebuilt the whole array # once per filter), and the sort keyed on the property name rather than on a # script block evaluated for each comparison. $rows = $script:AllPerms $hideInh = $script:PermHideInherited $hideSys = $script:PermHideSystem $filters = $script:PermFilters $filterKey = @($filters.Keys) $roots = $script:PermRootPaths if ($hideInh -or $hideSys -or $filterKey.Count -gt 0) { $rows = @($rows | Where-Object { if ($hideInh -and $_.Inherited -and -not $roots.Contains([string]$_.Folder)) { return $false } if ($hideSys -and (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid)) { return $false } foreach ($k in $filterKey) { if (-not $filters[$k].Contains([string]$_.$k)) { return $false } } return $true }) } $sortProp = $script:PermSort.Prop $rows = @($rows | Sort-Object -Property $sortProp) if (-not $script:PermSort.Asc) { [array]::Reverse($rows) } $deny = [System.Drawing.Color]::Firebrick $items = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($ace in $rows) { $it = New-Object System.Windows.Forms.ListViewItem($ace.Folder) [void]$it.SubItems.Add($ace.Identity); [void]$it.SubItems.Add($ace.Rights) [void]$it.SubItems.Add($ace.Type); [void]$it.SubItems.Add([string]$ace.Inherited) if ($ace.Type -eq 'Deny') { $it.ForeColor = $deny } $items.Add($it) } $lvPerm.BeginUpdate(); $lvPerm.Items.Clear() if ($items.Count -gt 0) { $lvPerm.Items.AddRange($items.ToArray()) } $lvPerm.EndUpdate() # Update column headers to show sort arrow and filter funnel. for ($i = 0; $i -lt $script:PermCols.Count; $i++) { $prop = $script:PermCols[$i] $label = $script:PermColLabels[$prop] if ($script:PermFilters.ContainsKey($prop)) { $label += ' (v)' } if ($prop -eq $sortProp) { $label += $(if ($script:PermSort.Asc) { ' ^' } else { ' v' }) } $lvPerm.Columns[$i].Text = $label } $shown = $lvPerm.Items.Count; $total = $script:AllPerms.Count $nFilt = $script:PermFilters.Count $btnPermClear.Enabled = ($nFilt -gt 0) $tabPerm.Text = if ($total -gt 0) { "Permissions ($shown)" } else { 'Permissions' } if ($script:AllPerms.Count -gt 0) { $msg = "Permissions : $shown sur $total affichées" if ($nFilt -gt 0) { $msg += " ($nFilt filtre$(if($nFilt -gt 1){'s'}) de colonne) - cliquez sur un en-tête pour filtrer/trier" } else { $msg += " - cliquez sur un en-tête de colonne pour filtrer ou trier" } $statusLbl.Text = $msg } } function Show-PermColumnMenu { param([int]$ColumnIndex) if ($script:AllPerms.Count -eq 0) { return } $prop = $script:PermCols[$ColumnIndex] # The previous menu is only safe to drop once a new click has replaced it - # disposing it from its own Closed handler would tear it down mid-event. if ($script:PermMenu) { $script:PermMenu.Dispose(); $script:PermMenu = $null } $menu = New-Object System.Windows.Forms.ContextMenuStrip $menu.ShowImageMargin = $false $asc = New-Object System.Windows.Forms.ToolStripMenuItem("Trier A -> Z") $asc.Add_Click({ $script:PermSort = @{ Prop = $prop; Asc = $true }; Update-PermView }.GetNewClosure()) $desc = New-Object System.Windows.Forms.ToolStripMenuItem("Trier Z -> A") $desc.Add_Click({ $script:PermSort = @{ Prop = $prop; Asc = $false }; Update-PermView }.GetNewClosure()) [void]$menu.Items.Add($asc); [void]$menu.Items.Add($desc) [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) # Select all / Clear toggles for the value checklist. $selAll = New-Object System.Windows.Forms.ToolStripMenuItem("(Tout sélectionner)") $clrAll = New-Object System.Windows.Forms.ToolStripMenuItem("(Tout effacer)") [void]$menu.Items.Add($selAll); [void]$menu.Items.Add($clrAll) [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) # Distinct values for this column (from the unfiltered source). $distinct = @($script:AllPerms | ForEach-Object { Get-PermCellValue $_ $prop } | Sort-Object -Unique) $current = $script:PermFilters[$prop] # $null => everything allowed $valueItems = New-Object System.Collections.ArrayList foreach ($v in $distinct) { $label = if ([string]::IsNullOrEmpty($v)) { '(vide)' } else { $v } $mi = New-Object System.Windows.Forms.ToolStripMenuItem($label) $mi.CheckOnClick = $true $mi.Checked = ($null -eq $current) -or $current.Contains($v) $mi.Tag = $v [void]$menu.Items.Add($mi) [void]$valueItems.Add($mi) } $selAll.Add_Click({ foreach ($m in $valueItems) { $m.Checked = $true } }.GetNewClosure()) $clrAll.Add_Click({ foreach ($m in $valueItems) { $m.Checked = $false } }.GetNewClosure()) [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) $apply = New-Object System.Windows.Forms.ToolStripMenuItem("Appliquer le filtre") $apply.Add_Click({ $checked = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($m in $valueItems) { if ($m.Checked) { [void]$checked.Add([string]$m.Tag) } } if ($checked.Count -eq $valueItems.Count) { $script:PermFilters.Remove($prop) } # all selected = no filter else { $script:PermFilters[$prop] = $checked } Update-PermView }.GetNewClosure()) $clear = New-Object System.Windows.Forms.ToolStripMenuItem("Supprimer le filtre de cette colonne") $clear.Add_Click({ [void]$script:PermFilters.Remove($prop); Update-PermView }.GetNewClosure()) [void]$menu.Items.Add($apply); [void]$menu.Items.Add($clear) # Keep the menu open while ticking value checkboxes or using Select/Clear all; # close only on Sort / Apply / Remove filter. $menu.Add_Closing({ param($s, $e) if ($e.CloseReason -eq [System.Windows.Forms.ToolStripDropDownCloseReason]::ItemClicked) { $clicked = $s.GetItemAt($s.PointToClient([System.Windows.Forms.Cursor]::Position)) if ($clicked -and (($valueItems -contains $clicked) -or ($clicked -eq $selAll) -or ($clicked -eq $clrAll))) { $e.Cancel = $true } } }.GetNewClosure()) # Show just under the clicked column header. $x = 0 for ($i = 0; $i -lt $ColumnIndex; $i++) { $x += $lvPerm.Columns[$i].Width } $script:PermMenu = $menu $menu.Show($lvPerm, $x, 4) } function Show-ExportDialog { <# Modal picker for the export: which report categories and which formats (HTML and/or CSV). Returns $null if the user cancelled, otherwise an object with .Categories (array of Tree/LongPaths/Permissions/Grants/Errors or @('All')), .Html and .Csv booleans. Categories with no data are disabled. #> param($Scan) $hasGrants = @($Scan.Grants | Where-Object { $_.Success }).Count -gt 0 $hasErrors = $Scan.Errors.Count -gt 0 # label, canonical name, enabled? $cats = @( @{ Text = 'Tailles des dossiers'; Name = 'Tree'; Enabled = $true }, @{ Text = 'Chemins trop longs'; Name = 'LongPaths'; Enabled = $true }, @{ Text = 'Permissions'; Name = 'Permissions'; Enabled = $true }, @{ Text = 'Accès accordé'; Name = 'Grants'; Enabled = $hasGrants }, @{ Text = 'Erreurs / accès refusé'; Name = 'Errors'; Enabled = $hasErrors } ) $dlg = New-Object System.Windows.Forms.Form $dlg.Text = 'Exporter - choisir les catégories et formats' $dlg.FormBorderStyle = 'FixedDialog' $dlg.StartPosition = 'CenterParent' $dlg.MaximizeBox = $false; $dlg.MinimizeBox = $false $dlg.ClientSize = New-Object System.Drawing.Size(320, 330) # height fixed up below $lbl = New-Object System.Windows.Forms.Label $lbl.Text = 'Inclure ces catégories dans le rapport :' $lbl.Location = '14,12'; $lbl.AutoSize = $true $dlg.Controls.Add($lbl) $boxes = @() $y = 40 foreach ($c in $cats) { $cb = New-Object System.Windows.Forms.CheckBox $cb.Text = $c.Text $cb.Tag = $c.Name $cb.Location = "20,$y"; $cb.AutoSize = $true $cb.Checked = $c.Enabled $cb.Enabled = $c.Enabled if (-not $c.Enabled) { $cb.Text += ' (aucune donnée)' } $dlg.Controls.Add($cb) $boxes += $cb $y += 26 } # ---- format selection (HTML and/or CSV) ---- $y += 8 $lblFmt = New-Object System.Windows.Forms.Label $lblFmt.Text = 'Formats à générer :' $lblFmt.Location = "14,$y"; $lblFmt.AutoSize = $true $dlg.Controls.Add($lblFmt) $y += 26 $chkHtml = New-Object System.Windows.Forms.CheckBox $chkHtml.Text = 'HTML (rapport unique)'; $chkHtml.Tag = 'Html' $chkHtml.Location = "20,$y"; $chkHtml.AutoSize = $true; $chkHtml.Checked = $true $dlg.Controls.Add($chkHtml) $y += 26 $chkCsv = New-Object System.Windows.Forms.CheckBox $chkCsv.Text = 'CSV (un fichier par catégorie)'; $chkCsv.Tag = 'Csv' $chkCsv.Location = "20,$y"; $chkCsv.AutoSize = $true; $chkCsv.Checked = $false $dlg.Controls.Add($chkCsv) $y += 36 $btnOk = New-Object System.Windows.Forms.Button $btnOk.Text = 'Exporter...'; $btnOk.Size = '90,28'; $btnOk.Location = "124,$y" $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK $dlg.Controls.Add($btnOk); $dlg.AcceptButton = $btnOk $btnCancel = New-Object System.Windows.Forms.Button $btnCancel.Text = 'Annuler'; $btnCancel.Size = '90,28'; $btnCancel.Location = "220,$y" $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $dlg.Controls.Add($btnCancel); $dlg.CancelButton = $btnCancel $dlg.ClientSize = New-Object System.Drawing.Size(320, ($y + 42)) # Enable Export only when at least one category AND at least one format is ticked. $sync = { $anyCat = @($boxes | Where-Object { $_.Checked }).Count -gt 0 $anyFmt = $chkHtml.Checked -or $chkCsv.Checked $btnOk.Enabled = $anyCat -and $anyFmt }.GetNewClosure() foreach ($cb in $boxes) { $cb.Add_CheckedChanged($sync) } $chkHtml.Add_CheckedChanged($sync) $chkCsv.Add_CheckedChanged($sync) $result = $dlg.ShowDialog($form) $picked = @($boxes | Where-Object { $_.Checked } | ForEach-Object { [string]$_.Tag }) $wantHtml = $chkHtml.Checked $wantCsv = $chkCsv.Checked $dlg.Dispose() if ($result -ne [System.Windows.Forms.DialogResult]::OK -or $picked.Count -eq 0) { return $null } if (-not ($wantHtml -or $wantCsv)) { return $null } # All categories ticked -> 'All' so the header shows the full report. $categories = if ($picked.Count -eq $boxes.Count) { @('All') } else { $picked } return [pscustomobject]@{ Categories = $categories; Html = $wantHtml; Csv = $wantCsv } } function Get-FilerNewName { <# Computes the new leaf name for one item by applying, in order: find/replace (literal or regex), a case transform, prefix/suffix and an optional sequence number ({n}). Returns the candidate name plus a status. The file extension is preserved untouched when $O.IgnoreExt is set. #> param([string]$Name, [bool]$IsFile, [int]$Counter, [hashtable]$O) $ext = '' $base = $Name if ($IsFile -and $O.IgnoreExt) { $ext = [System.IO.Path]::GetExtension($Name) $base = [System.IO.Path]::GetFileNameWithoutExtension($Name) } $work = $base if ($O.Find -ne '') { try { if ($O.Regex) { $opt = if ($O.IgnoreCase) { [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } else { [System.Text.RegularExpressions.RegexOptions]::None } $work = [regex]::Replace($work, $O.Find, $O.Replace, $opt) } elseif ($O.IgnoreCase) { # Literal, case-insensitive: escape the pattern, and escape any '$' # in the replacement so it stays literal for the regex engine. $rep = $O.Replace -replace '\$', '$$$$' $work = [regex]::Replace($work, [regex]::Escape($O.Find), $rep, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) } else { $work = $work.Replace($O.Find, $O.Replace) } } catch { return [pscustomobject]@{ NewName = $Name; Status = 'erreur regex'; Changed = $false } } } switch ($O.Case) { 'upper' { $work = $work.ToUpper() } 'lower' { $work = $work.ToLower() } 'title' { $work = (Get-Culture).TextInfo.ToTitleCase($work.ToLower()) } 'first' { if ($work.Length -gt 0) { $work = $work.Substring(0, 1).ToUpper() + $work.Substring(1) } } } $combined = $O.Prefix + $work + $O.Suffix if ($O.Number) { $num = ([string]$Counter).PadLeft($O.Pad, '0') if ($combined -match '\{n\}') { $combined = $combined -replace '\{n\}', $num } else { $combined += $num } } $newName = $combined + $ext if ([string]::IsNullOrWhiteSpace($newName)) { return [pscustomobject]@{ NewName = $newName; Status = 'nom vide'; Changed = $false } } if ($newName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) { return [pscustomobject]@{ NewName = $newName; Status = 'caractères invalides'; Changed = $false } } $changed = -not $newName.Equals($Name, [System.StringComparison]::Ordinal) return [pscustomobject]@{ NewName = $newName; Status = $(if ($changed) { 'modifié' } else { 'inchangé' }); Changed = $changed } } function Invoke-RenameListing { # Walks the chosen folder (optionally recursively, honouring the same folder # exclusions as the scan) and loads its files/folders into $script:RenameItems. $root = $rnPath.Text.Trim() if (-not $root) { [System.Windows.Forms.MessageBox]::Show('Indiquez un dossier à parcourir.', 'Filer Manager', 'OK', 'Information') | Out-Null return } if (-not (Test-Path -LiteralPath $root -PathType Container)) { [System.Windows.Forms.MessageBox]::Show("Dossier introuvable :`n$root", 'Filer Manager', 'OK', 'Warning') | Out-Null return } $recurse = $chkRnRecurse.Checked $wantFile = $chkRnFiles.Checked $wantDir = $chkRnFolders.Checked $exclHide = $chkRnHidden.Checked $patterns = @($txtExclude.Text -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $items = New-Object System.Collections.ArrayList $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor try { $stack = New-Object System.Collections.Stack $stack.Push([pscustomobject]@{ Path = $root; Depth = 0 }) while ($stack.Count -gt 0) { $cur = $stack.Pop() $children = @() try { $children = Get-ChildItem -LiteralPath $cur.Path -Force -ErrorAction Stop } catch { continue } foreach ($c in $children) { if ($c.PSIsContainer) { if (Test-IsExcludedFolder -Entry $c -Patterns $patterns -ExcludeHidden $exclHide) { continue } if ($wantDir) { [void]$items.Add([pscustomobject]@{ Name = $c.Name; FullPath = $c.FullName; IsFile = $false Dir = ([System.IO.Path]::GetDirectoryName($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]@{ Name = $c.Name; FullPath = $c.FullName; IsFile = $true Dir = ([System.IO.Path]::GetDirectoryName($c.FullName)); Depth = ($cur.Depth + 1) }) } } } } finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default } $script:RenameItems = @($items | Sort-Object -Property FullPath) Update-RenamePreview } function Update-RenamePreview { # Recomputes every item's target name from the current options, flags # duplicate / pre-existing collisions, and refreshes the preview grid. if ($null -eq $script:RenameItems -or $script:RenameItems.Count -eq 0) { $lvRename.Items.Clear(); $script:RenameRows = @(); $rnApply.Enabled = $false $lblRnStatus.Text = 'Aucun élément listé. Choisissez un dossier puis cliquez sur Lister.' return } $o = @{ Find = $rnFind.Text Replace = $rnReplace.Text Regex = $chkRnRegex.Checked IgnoreCase = $chkRnIgnoreCase.Checked IgnoreExt = $chkRnIgnoreExt.Checked Case = $script:RnCaseModes[$rnCase.SelectedIndex] Prefix = $rnPrefix.Text Suffix = $rnSuffix.Text Number = $chkRnNumber.Checked Pad = [int]$numRnPad.Value } $start = [int]$numRnStart.Value $step = [int]$numRnStep.Value $rows = New-Object System.Collections.ArrayList $i = 0 foreach ($item in $script:RenameItems) { $r = Get-FilerNewName -Name $item.Name -IsFile $item.IsFile -Counter ($start + $i * $step) -O $o [void]$rows.Add([pscustomobject]@{ Item = $item; New = $r.NewName; Status = $r.Status; Changed = $r.Changed }) $i++ } # Per-directory collision checks (only matter for items that actually change). foreach ($grp in ($rows | Group-Object { $_.Item.Dir })) { $seen = @{} foreach ($row in $grp.Group) { if (-not $row.Changed) { continue } $key = $row.New.ToLowerInvariant() if ($seen.ContainsKey($key)) { $row.Status = 'conflit (doublon)'; $row.Changed = $false $seen[$key].Status = 'conflit (doublon)'; $seen[$key].Changed = $false } else { $seen[$key] = $row } } # Current name -> row, so "is the occupant one of the items that moves # away?" is a hashtable hit instead of a scan of the whole group. The row # object is stored, not a flag, so its live .Changed state is still read. $movers = @{} foreach ($r in $grp.Group) { if ($r.Changed) { $movers[$r.Item.Name.ToLowerInvariant()] = $r } } foreach ($row in $grp.Group) { if (-not $row.Changed) { continue } $target = Join-Path $row.Item.Dir $row.New # Skip a case-only rename of the item onto itself (-ne is case-insensitive). if ($target -ne $row.Item.FullPath -and (Test-Path -LiteralPath $target)) { $occupant = $movers[$row.New.ToLowerInvariant()] if (-not ($occupant -and $occupant.Changed)) { $row.Status = 'conflit (existe déjà)'; $row.Changed = $false [void]$movers.Remove($row.Item.Name.ToLowerInvariant()) } } } } $script:RenameRows = @($rows) # Built off-control then inserted in one go: this runs on every keystroke in # the find/replace boxes, so a per-row Add would make typing feel sticky. $bad = [System.Drawing.Color]::Firebrick $dim = [System.Drawing.Color]::Gray $view = New-Object 'System.Collections.Generic.List[System.Windows.Forms.ListViewItem]' foreach ($row in $rows) { $it = New-Object System.Windows.Forms.ListViewItem($(if ($row.Item.IsFile) { 'Fichier' } else { 'Dossier' })) [void]$it.SubItems.Add($row.Item.Name) [void]$it.SubItems.Add($row.New) [void]$it.SubItems.Add($row.Status) [void]$it.SubItems.Add($row.Item.Dir) $it.Tag = $row if ($row.Changed) { $it.Checked = $true } elseif ($row.Status -like 'conflit*' -or $row.Status -like 'erreur*' -or $row.Status -eq 'caractères invalides' -or $row.Status -eq 'nom vide') { $it.ForeColor = $bad } else { $it.ForeColor = $dim } # inchangé $view.Add($it) } $lvRename.BeginUpdate(); $lvRename.Items.Clear() if ($view.Count -gt 0) { $lvRename.Items.AddRange($view.ToArray()) } $lvRename.EndUpdate() $nChange = @($rows | Where-Object { $_.Changed }).Count $nConf = @($rows | Where-Object { $_.Status -like 'conflit*' }).Count $rnApply.Enabled = ($nChange -gt 0) $msg = "$($rows.Count) élément(s), $nChange à renommer" if ($nConf -gt 0) { $msg += ", $nConf conflit(s) ignoré(s)" } $lblRnStatus.Text = $msg } function Invoke-RenameApply { # Renames every ticked, changed row. Deepest paths first so a folder is only # renamed after its contents, keeping the captured child paths valid. $todo = @() foreach ($it in $lvRename.Items) { if ($it.Checked -and $it.Tag -and $it.Tag.Changed) { $todo += $it.Tag } } if ($todo.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('Aucun élément coché à renommer.', 'Filer Manager', 'OK', 'Information') | Out-Null return } $confirm = [System.Windows.Forms.MessageBox]::Show( "Renommer $($todo.Count) élément(s) ? Cette action modifie les fichiers/dossiers sur le disque.", 'Filer Manager', 'YesNo', 'Warning') if ($confirm -ne 'Yes') { return } $todo = @($todo | Sort-Object @{ E = { $_.Item.Depth }; Descending = $true }) $ok = 0; $failures = New-Object System.Collections.ArrayList $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor try { foreach ($row in $todo) { try { Rename-Item -LiteralPath $row.Item.FullPath -NewName $row.New -ErrorAction Stop $ok++ } catch { [void]$failures.Add("$($row.Item.Name) -> $($row.New) : $($_.Exception.Message)") } } } finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default } if ($failures.Count -gt 0) { $detail = (@($failures) | Select-Object -First 15) -join "`n" if ($failures.Count -gt 15) { $detail += "`n... (+$($failures.Count - 15))" } [System.Windows.Forms.MessageBox]::Show("$ok renommé(s), $($failures.Count) échec(s) :`n`n$detail", 'Filer Manager', 'OK', 'Warning') | Out-Null } else { [System.Windows.Forms.MessageBox]::Show("$ok élément(s) renommé(s).", 'Filer Manager', 'OK', 'Information') | Out-Null } # Re-list from disk so the grid reflects the new names. Invoke-RenameListing } # ---- poll timer (reads background runspace) ---- $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 200 $timer.Add_Tick({ if ($script:Shared) { if ($script:Shared.Status) { $statusLbl.Text = $script:Shared.Status } if ($script:Handle -and $script:Handle.IsCompleted) { $timer.Stop() try { $null = $script:PowerShell.EndInvoke($script:Handle) $scan = $script:Shared.Result if ($scan) { Show-Results -Scan $scan } else { $statusLbl.Text = 'Analyse terminée mais aucune donnée renvoyée.' } } catch { [System.Windows.Forms.MessageBox]::Show("Échec de l'analyse :`n$($_.Exception.Message)", 'Filer Manager', 'OK', 'Error') | Out-Null $statusLbl.Text = "Échec de l'analyse." } finally { 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 = ($lstFolders.SelectedIndices.Count -gt 0) } } } }) # ---- poll timer (reads background export runspace) ---- $exportTimer = New-Object System.Windows.Forms.Timer $exportTimer.Interval = 200 $exportTimer.Add_Tick({ if (-not $script:ExportHandle) { $exportTimer.Stop(); return } if (-not $script:ExportHandle.IsCompleted) { return } $exportTimer.Stop() $written = $null; $err = $null try { $script:ExportPowerShell.EndInvoke($script:ExportHandle) | Out-Null if ($script:ExportShared) { $written = $script:ExportShared.Written; $err = $script:ExportShared.Error } } catch { $err = $_.Exception.Message } finally { if ($script:ExportPowerShell) { $script:ExportPowerShell.Dispose() } if ($script:ExportRunspace) { $script:ExportRunspace.Close(); $script:ExportRunspace.Dispose() } $script:ExportPowerShell = $null; $script:ExportRunspace = $null; $script:ExportHandle = $null $progressBar.Visible = $false $btnScan.Enabled = $true; $btnAdd.Enabled = $true $btnRemove.Enabled = ($lstFolders.SelectedIndices.Count -gt 0) $btnExport.Enabled = ($null -ne $script:LastScan) } $htmlPath = $script:ExportHtmlPath if ($err) { $statusLbl.Text = "Échec de l'export." [System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$err", 'Filer Manager', 'OK', 'Error') | Out-Null return } $written = @($written) if ($written.Count -eq 0) { $statusLbl.Text = 'Aucun fichier généré.' [System.Windows.Forms.MessageBox]::Show('Aucun fichier généré (catégories CSV sans données).', 'Filer Manager', 'OK', 'Information') | Out-Null return } $statusLbl.Text = "Export terminé : $($written.Count) fichier(s)." $list = ($written -join "`n") $prompt = if ($htmlPath) { "`n`nOuvrir le rapport HTML maintenant ?" } else { "`n`nOuvrir le dossier maintenant ?" } if ([System.Windows.Forms.MessageBox]::Show("Fichier(s) enregistré(s) :`n$list$prompt", 'Filer Manager', 'YesNo', 'Question') -eq 'Yes') { if ($htmlPath) { Start-Process $htmlPath } else { Start-Process (Split-Path -Path $written[0] -Parent) } } }) # ============================================================================ # FOLDER PICKER (local drives, mapped network drives, UNC paths, shares) # # FolderBrowserDialog is not usable here: on Windows PowerShell 5.1 it is the # legacy shell dialog (no path box, no way to type a UNC path) and it only ever # shows the drive letters mounted in the current process token - so an elevated # Filer Manager sees none of the drives the user mapped. This picker works from # Get-FilerDriveInventory instead, browses servers share by share, accepts a # pasted UNC path and can open an authenticated connection. # ============================================================================ # Servers added by hand during this session, kept between two openings of the picker. $script:FilerPickerServers = New-Object System.Collections.ArrayList function Show-FilerInputBox { # Minimal one-line prompt (avoids a dependency on Microsoft.VisualBasic). param( [System.Windows.Forms.Form]$Owner, [string]$Title = 'Filer Manager', [string]$Prompt = '', [string]$Default = '' ) $dlg = New-Object System.Windows.Forms.Form $dlg.Text = $Title $dlg.FormBorderStyle = 'FixedDialog' $dlg.StartPosition = 'CenterParent' $dlg.MinimizeBox = $false; $dlg.MaximizeBox = $false $dlg.ClientSize = New-Object System.Drawing.Size(452, 122) $lbl = New-Object System.Windows.Forms.Label $lbl.Text = $Prompt; $lbl.Location = '12,12'; $lbl.Size = '428,34' $txt = New-Object System.Windows.Forms.TextBox $txt.Location = '12,52'; $txt.Size = '428,24'; $txt.Text = $Default $ok = New-Object System.Windows.Forms.Button $ok.Text = 'OK'; $ok.Size = '96,28'; $ok.Location = '246,84' $ok.DialogResult = [System.Windows.Forms.DialogResult]::OK $cancel = New-Object System.Windows.Forms.Button $cancel.Text = 'Annuler'; $cancel.Size = '96,28'; $cancel.Location = '346,84' $cancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $dlg.Controls.AddRange(@($lbl, $txt, $ok, $cancel)) $dlg.AcceptButton = $ok; $dlg.CancelButton = $cancel $r = $dlg.ShowDialog($Owner) $val = [string]$txt.Text $dlg.Dispose() if ($r -ne [System.Windows.Forms.DialogResult]::OK) { return $null } if ([string]::IsNullOrWhiteSpace($val)) { return $null } return $val.Trim() } function Show-FilerConnectDialog { <# Opens a connection to a share, with optional credentials and an optional drive letter. Returns the UNC path on success, $null when cancelled. Connecting without a letter is enough for a scan and is the default: it registers the credentials for the session without consuming a letter. #> param( [System.Windows.Forms.Form]$Owner, [string]$InitialPath = '\\' ) $dlg = New-Object System.Windows.Forms.Form $dlg.Text = 'Connecter un partage réseau' $dlg.FormBorderStyle = 'FixedDialog' $dlg.StartPosition = 'CenterParent' $dlg.MinimizeBox = $false; $dlg.MaximizeBox = $false $dlg.ClientSize = New-Object System.Drawing.Size(470, 250) $lblPath = New-Object System.Windows.Forms.Label $lblPath.Text = 'Chemin réseau (\\serveur\partage) :'; $lblPath.Location = '12,12'; $lblPath.AutoSize = $true $txtPath = New-Object System.Windows.Forms.TextBox $txtPath.Location = '12,32'; $txtPath.Size = '446,24'; $txtPath.Text = $InitialPath $lblUser = New-Object System.Windows.Forms.Label $lblUser.Text = 'Utilisateur (vide = session en cours) :'; $lblUser.Location = '12,64'; $lblUser.AutoSize = $true $txtUser = New-Object System.Windows.Forms.TextBox $txtUser.Location = '12,84'; $txtUser.Size = '216,24' $lblPass = New-Object System.Windows.Forms.Label $lblPass.Text = 'Mot de passe :'; $lblPass.Location = '242,64'; $lblPass.AutoSize = $true $txtPass = New-Object System.Windows.Forms.TextBox $txtPass.Location = '242,84'; $txtPass.Size = '216,24'; $txtPass.UseSystemPasswordChar = $true $lblLetter = New-Object System.Windows.Forms.Label $lblLetter.Text = 'Lettre de lecteur :'; $lblLetter.Location = '12,118'; $lblLetter.AutoSize = $true $cmbLetter = New-Object System.Windows.Forms.ComboBox $cmbLetter.Location = '12,138'; $cmbLetter.Size = '216,24'; $cmbLetter.DropDownStyle = 'DropDownList' [void]$cmbLetter.Items.Add('(aucune - authentifier seulement)') $used = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $_.Name.Substring(0, 1).ToUpperInvariant() }) foreach ($code in 68..90) { # D..Z $c = [string][char]$code if ($used -notcontains $c) { [void]$cmbLetter.Items.Add("${c}:") } } $cmbLetter.SelectedIndex = 0 $chkPersist = New-Object System.Windows.Forms.CheckBox $chkPersist.Text = 'Reconnecter à l''ouverture de session'; $chkPersist.Location = '242,138'; $chkPersist.Size = '216,24' $lblStatus = New-Object System.Windows.Forms.Label $lblStatus.Location = '12,172'; $lblStatus.Size = '446,36' $lblStatus.ForeColor = [System.Drawing.Color]::Firebrick $btnOk = New-Object System.Windows.Forms.Button $btnOk.Text = 'Connecter'; $btnOk.Size = '110,28'; $btnOk.Location = '236,212' $btnCancel = New-Object System.Windows.Forms.Button $btnCancel.Text = 'Annuler'; $btnCancel.Size = '110,28'; $btnCancel.Location = '350,212' $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $dlg.Controls.AddRange(@($lblPath, $txtPath, $lblUser, $txtUser, $lblPass, $txtPass, $lblLetter, $cmbLetter, $chkPersist, $lblStatus, $btnOk, $btnCancel)) $dlg.AcceptButton = $btnOk; $dlg.CancelButton = $btnCancel $script:FilerConnectResult = $null $btnOk.Add_Click({ $remote = ([string]$txtPath.Text).Trim().TrimEnd('\') if (-not $remote.StartsWith('\\') -or $remote.Length -lt 5) { $lblStatus.Text = 'Chemin attendu sous la forme \\serveur\partage.' return } $letter = $null if ($cmbLetter.SelectedIndex -gt 0) { $letter = [string]$cmbLetter.SelectedItem } $cred = $null if (-not [string]::IsNullOrWhiteSpace($txtUser.Text)) { $sec = New-Object System.Security.SecureString foreach ($ch in ([string]$txtPass.Text).ToCharArray()) { $sec.AppendChar($ch) } $sec.MakeReadOnly() $cred = New-Object System.Management.Automation.PSCredential(([string]$txtUser.Text).Trim(), $sec) } $lblStatus.Text = 'Connexion...'; $lblStatus.Refresh() try { Connect-FilerShare -RemotePath $remote -DriveLetter $letter -Credential $cred -Persistent:([bool]$chkPersist.Checked) $script:FilerConnectResult = $remote $dlg.DialogResult = [System.Windows.Forms.DialogResult]::OK $dlg.Close() } catch { $lblStatus.Text = $_.Exception.Message } }.GetNewClosure()) [void]$dlg.ShowDialog($Owner) $dlg.Dispose() return $script:FilerConnectResult } function New-FilerPickerNode { <# Tree node carrying its own metadata: Kind (group / drive / server / share / folder / typed), the path to scan, and whether its children were loaded. A dummy child makes the node expandable before anything is enumerated. #> param( [string]$Text, [string]$Kind, [string]$Path, [switch]$Expandable ) $n = New-Object System.Windows.Forms.TreeNode($Text) $n.Tag = [pscustomobject]@{ Kind = $Kind; Path = $Path; Loaded = $false } if ($Expandable) { [void]$n.Nodes.Add((New-Object System.Windows.Forms.TreeNode('...'))) } return $n } function Expand-FilerPickerNode { <# Fills a node on demand: shares for a server node, subfolders otherwise. Every enumeration is timeout-guarded, so an offline share reports itself instead of freezing the dialog. #> param( [System.Windows.Forms.TreeNode]$Node, [System.Windows.Forms.Label]$Status ) $tag = $Node.Tag if (-not $tag) { return } if ($tag.Kind -eq 'group') { return } if ($tag.Loaded) { return } if (-not $tag.Path) { return } $prev = [System.Windows.Forms.Cursor]::Current [System.Windows.Forms.Cursor]::Current = [System.Windows.Forms.Cursors]::WaitCursor if ($Status) { $Status.Text = "Lecture de $($tag.Path)..."; $Status.Refresh() } try { $children = @() $err = $null if ($tag.Kind -eq 'server') { try { $children = @(Get-FilerShare -Server $tag.Path | ForEach-Object { [pscustomobject]@{ Text = $_.Name; Path = $_.Path; Kind = 'share' } }) } catch { $err = $_.Exception.Message } } else { $r = Get-FilerChildFolder -Path $tag.Path if ($r.TimedOut) { $err = 'délai dépassé (serveur injoignable)' } elseif ($r.Error) { $err = $r.Error } else { $children = @($r.Folders | ForEach-Object { [pscustomobject]@{ Text = ([System.IO.Path]::GetFileName($_)); Path = $_; Kind = 'folder' } }) } } $Node.Nodes.Clear() foreach ($c in $children) { [void]$Node.Nodes.Add((New-FilerPickerNode -Text $c.Text -Kind $c.Kind -Path $c.Path -Expandable)) } # Keep the node reloadable after a failure (the share may come back, or the # user may connect with credentials and try again). $tag.Loaded = (-not $err) if ($err) { $bad = New-Object System.Windows.Forms.TreeNode("(inaccessible : $err)") $bad.ForeColor = [System.Drawing.Color]::Firebrick [void]$Node.Nodes.Add($bad) if ($Status) { $Status.Text = "$($tag.Path) : $err" } } elseif ($Status) { $label = if ($tag.Kind -eq 'server') { 'partage(s)' } else { 'sous-dossier(s)' } $Status.Text = "$($tag.Path) : $($children.Count) $label." } } finally { [System.Windows.Forms.Cursor]::Current = $prev } } function Get-FilerPickerCheckedPath { # Paths of every ticked node, depth-first. Group nodes and the placeholder / # error nodes (no Tag) are ignored. param($Nodes) $out = New-Object System.Collections.ArrayList foreach ($n in $Nodes) { $t = $n.Tag if ($n.Checked -and $t -and $t.Kind -ne 'group' -and $t.Path) { [void]$out.Add([string]$t.Path) } if ($n.Nodes.Count -gt 0) { foreach ($c in (Get-FilerPickerCheckedPath -Nodes $n.Nodes)) { [void]$out.Add($c) } } } return $out.ToArray() } function Get-FilerServerName { # '\\nas\share\sub' -> '\\nas' param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } $p = $Path.Trim() if (-not $p.StartsWith('\\')) { return $null } $first = @($p.TrimStart('\') -split '\\' | Where-Object { $_ }) | Select-Object -First 1 if (-not $first) { return $null } return ('\\' + $first) } function Update-FilerPickerTree { <# (Re)builds the two fixed branches: 'Ce PC' from the drive inventory, and 'Réseau' from the servers behind the known mappings plus those added by hand. Network drives are listed even when the letter is missing from this token; their UNC target is then used for browsing. #> param( [System.Windows.Forms.TreeView]$Tree, [System.Windows.Forms.TreeNode]$PcNode, [System.Windows.Forms.TreeNode]$NetNode, [System.Windows.Forms.Label]$Status ) $typeLabel = @{ Local = 'Local'; Network = 'Réseau'; Removable = 'Amovible'; CDRom = 'CD/DVD'; RAM = 'RAM'; Unknown = 'Inconnu' } $Tree.BeginUpdate() try { $PcNode.Nodes.Clear() $NetNode.Nodes.Clear() $inv = @(Get-FilerDriveInventory) $servers = New-Object System.Collections.ArrayList foreach ($d in $inv) { if ($d.IsNetwork) { $path = if ($d.Mounted) { $d.Root } else { $d.Unc } if (-not $path) { continue } # unmounted letter with no known target $target = if ($d.Unc) { $d.Unc } else { 'cible inconnue' } $state = if ($d.Mounted) { 'Réseau' } else { 'Réseau, non monté dans cette session' } $text = "$($d.Name) -> $target [$state]" [void]$PcNode.Nodes.Add((New-FilerPickerNode -Text $text -Kind 'drive' -Path $path -Expandable)) $srv = Get-FilerServerName -Path $d.Unc if ($srv -and ($servers -notcontains $srv)) { [void]$servers.Add($srv) } } else { # Skip empty optical / removable slots: they can only fail. if (($d.Type -eq 'CDRom' -or $d.Type -eq 'Removable') -and $d.Ready -eq $false) { continue } $bits = New-Object System.Collections.ArrayList [void]$bits.Add([string]$typeLabel[$d.Type]) if ($d.Label) { [void]$bits.Add([string]$d.Label) } if ($null -ne $d.FreeBytes) { [void]$bits.Add(((Format-Bytes $d.FreeBytes) + ' libres')) } $text = "$($d.Name) [" + (($bits | Where-Object { $_ }) -join ', ') + ']' [void]$PcNode.Nodes.Add((New-FilerPickerNode -Text $text -Kind 'drive' -Path $d.Root -Expandable)) } } foreach ($s in @($script:FilerPickerServers)) { if ($s -and ($servers -notcontains $s)) { [void]$servers.Add($s) } } foreach ($s in ($servers | Sort-Object)) { [void]$NetNode.Nodes.Add((New-FilerPickerNode -Text $s -Kind 'server' -Path $s -Expandable)) } if ($NetNode.Nodes.Count -eq 0) { $hint = New-Object System.Windows.Forms.TreeNode('(aucun serveur connu - utilisez « Ajouter un serveur... »)') $hint.ForeColor = [System.Drawing.Color]::DimGray [void]$NetNode.Nodes.Add($hint) } $PcNode.Expand() $NetNode.Expand() } finally { $Tree.EndUpdate() } if ($Status) { $Status.Text = "$($PcNode.Nodes.Count) lecteur(s), $($NetNode.Nodes.Count) serveur(s)." } } function Show-FilerFolderPicker { <# Folder picker able to reach network storage. Returns the selected paths as a string[] (empty when cancelled). With -SingleSelection the tree has no check boxes and the highlighted node is returned. #> param( [System.Windows.Forms.Form]$Owner, [string]$Description = 'Cochez le ou les dossiers à analyser, ou collez un chemin \\serveur\partage.', [string]$InitialPath, [switch]$SingleSelection ) $dlg = New-Object System.Windows.Forms.Form $dlg.Text = if ($SingleSelection) { 'Sélectionner un dossier' } else { 'Sélectionner des dossiers' } $dlg.StartPosition = 'CenterParent' $dlg.ClientSize = New-Object System.Drawing.Size(780, 620) $dlg.MinimumSize = New-Object System.Drawing.Size(740, 560) $dlg.ShowInTaskbar = $false $lblDesc = New-Object System.Windows.Forms.Label $lblDesc.Text = $Description; $lblDesc.Location = '12,10'; $lblDesc.AutoSize = $true $txtPath = New-Object System.Windows.Forms.TextBox $txtPath.Location = '12,34'; $txtPath.Size = '638,24'; $txtPath.Anchor = 'Top,Left,Right' if ($InitialPath) { $txtPath.Text = $InitialPath } $btnGo = New-Object System.Windows.Forms.Button $btnGo.Text = 'Ouvrir'; $btnGo.Location = '658,33'; $btnGo.Size = '110,26'; $btnGo.Anchor = 'Top,Right' $tree = New-Object System.Windows.Forms.TreeView $tree.Location = '12,68'; $tree.Size = '756,448' $tree.Anchor = 'Top,Bottom,Left,Right' $tree.CheckBoxes = (-not $SingleSelection) $tree.HideSelection = $false $tree.ShowLines = $true $lblStatus = New-Object System.Windows.Forms.Label $lblStatus.Location = '12,524'; $lblStatus.Size = '756,18'; $lblStatus.Anchor = 'Bottom,Left,Right' $lblStatus.ForeColor = [System.Drawing.Color]::DimGray $chkUnc = New-Object System.Windows.Forms.CheckBox $chkUnc.Text = 'Utiliser les chemins UNC pour les lecteurs réseau (recommandé en mode Administrateur)' $chkUnc.Location = '12,548'; $chkUnc.AutoSize = $true; $chkUnc.Checked = $true; $chkUnc.Anchor = 'Bottom,Left' $btnRefresh = New-Object System.Windows.Forms.Button $btnRefresh.Text = 'Actualiser'; $btnRefresh.Location = '12,578'; $btnRefresh.Size = '110,30'; $btnRefresh.Anchor = 'Bottom,Left' $btnServer = New-Object System.Windows.Forms.Button $btnServer.Text = 'Ajouter un serveur...'; $btnServer.Location = '130,578'; $btnServer.Size = '160,30'; $btnServer.Anchor = 'Bottom,Left' $btnConnect = New-Object System.Windows.Forms.Button $btnConnect.Text = 'Connecter un partage...'; $btnConnect.Location = '298,578'; $btnConnect.Size = '170,30'; $btnConnect.Anchor = 'Bottom,Left' $btnOk = New-Object System.Windows.Forms.Button $btnOk.Text = 'Valider'; $btnOk.Size = '104,30'; $btnOk.Location = '552,578'; $btnOk.Anchor = 'Bottom,Right' $btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK $btnCancel = New-Object System.Windows.Forms.Button $btnCancel.Text = 'Annuler'; $btnCancel.Size = '104,30'; $btnCancel.Location = '664,578'; $btnCancel.Anchor = 'Bottom,Right' $btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel $dlg.Controls.AddRange(@($lblDesc, $txtPath, $btnGo, $tree, $btnRefresh, $btnServer, $btnConnect, $chkUnc, $lblStatus, $btnOk, $btnCancel)) $dlg.CancelButton = $btnCancel # no AcceptButton: Entrée in the path box means "Ouvrir" # ---- fixed branches ---- $nodePc = New-FilerPickerNode -Text 'Ce PC' -Kind 'group' -Path '' $nodeNet = New-FilerPickerNode -Text 'Réseau' -Kind 'group' -Path '' $nodeTyped = New-FilerPickerNode -Text 'Chemins saisis' -Kind 'group' -Path '' [void]$tree.Nodes.Add($nodePc) [void]$tree.Nodes.Add($nodeNet) [void]$tree.Nodes.Add($nodeTyped) Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus # ---- handlers ---- $tree.Add_BeforeExpand({ param($sender, $e) Expand-FilerPickerNode -Node $e.Node -Status $lblStatus }.GetNewClosure()) $tree.Add_AfterSelect({ param($sender, $e) $t = $e.Node.Tag if ($t -and $t.Path) { $txtPath.Text = [string]$t.Path } }.GetNewClosure()) if (-not $SingleSelection) { $tree.Add_AfterCheck({ param($sender, $e) $n = @(Get-FilerPickerCheckedPath -Nodes $tree.Nodes).Count $lblStatus.Text = "$n dossier(s) coché(s)." }.GetNewClosure()) } # "Ouvrir": resolve what was typed/pasted, then show it in the tree. A bare # \\server is treated as a server to browse; anything else must be an existing # folder before it is added. $goAction = { $raw = [string]$txtPath.Text if ([string]::IsNullOrWhiteSpace($raw)) { return } $r = Resolve-FilerScanPath -Path $raw $p = [string]$r.Path if (-not $p) { $lblStatus.Text = 'Chemin invalide.'; return } # \\server (no share): browse it instead of testing it as a folder. if ($p -match '^\\\\[^\\]+\\?$') { $srv = Get-FilerServerName -Path $p if ($script:FilerPickerServers -notcontains $srv) { [void]$script:FilerPickerServers.Add($srv) } Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus foreach ($n in $nodeNet.Nodes) { if ($n.Tag -and [string]$n.Tag.Path -eq $srv) { $tree.SelectedNode = $n; $n.Expand(); break } } return } $lblStatus.Text = "Vérification de $p..."; $lblStatus.Refresh() $reach = Test-FilerPathReachable -Path $p -TimeoutSeconds 10 if (-not $reach.Exists) { $why = if ($reach.TimedOut) { 'serveur injoignable (délai dépassé)' } elseif ($reach.Error) { $reach.Error } elseif ($r.Letter -and -not $r.Mounted) { "lecteur $($r.Letter) non monté dans cette session" } else { 'introuvable' } $lblStatus.Text = "$p : $why" return } $existing = $null foreach ($n in $nodeTyped.Nodes) { if ($n.Tag -and ([string]$n.Tag.Path).Equals($p, [System.StringComparison]::OrdinalIgnoreCase)) { $existing = $n; break } } if (-not $existing) { $existing = New-FilerPickerNode -Text $p -Kind 'typed' -Path $p -Expandable [void]$nodeTyped.Nodes.Add($existing) } $nodeTyped.Expand() $tree.SelectedNode = $existing if ($tree.CheckBoxes) { $existing.Checked = $true } $existing.Expand() if ($r.Note) { $lblStatus.Text = $r.Note } }.GetNewClosure() $btnGo.Add_Click($goAction) $txtPath.Add_KeyDown({ param($sender, $e) if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) { $e.SuppressKeyPress = $true & $goAction } }.GetNewClosure()) $btnRefresh.Add_Click({ Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus }.GetNewClosure()) $btnServer.Add_Click({ $srvRaw = Show-FilerInputBox -Owner $dlg -Title 'Ajouter un serveur' ` -Prompt 'Nom ou adresse du serveur de fichiers (nas1, 10.0.0.5, \\nas1) :' if (-not $srvRaw) { return } $srv = Get-FilerServerName -Path $srvRaw if (-not $srv) { $srv = '\\' + $srvRaw.Trim().TrimStart('\') } if ($script:FilerPickerServers -notcontains $srv) { [void]$script:FilerPickerServers.Add($srv) } Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus foreach ($n in $nodeNet.Nodes) { if ($n.Tag -and [string]$n.Tag.Path -eq $srv) { $tree.SelectedNode = $n; $n.Expand(); break } } }.GetNewClosure()) $btnConnect.Add_Click({ $seed = [string]$txtPath.Text if (-not $seed.StartsWith('\\')) { $seed = '\\' } $connected = Show-FilerConnectDialog -Owner $dlg -InitialPath $seed if (-not $connected) { return } $srv = Get-FilerServerName -Path $connected if ($srv -and ($script:FilerPickerServers -notcontains $srv)) { [void]$script:FilerPickerServers.Add($srv) } Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus $txtPath.Text = $connected $lblStatus.Text = "Connecté à $connected." }.GetNewClosure()) # ---- result ---- $result = $dlg.ShowDialog($Owner) $picked = @() if ($result -eq [System.Windows.Forms.DialogResult]::OK) { if ($SingleSelection) { $sel = $tree.SelectedNode if ($sel -and $sel.Tag -and $sel.Tag.Path) { $picked = @([string]$sel.Tag.Path) } } else { $picked = @(Get-FilerPickerCheckedPath -Nodes $tree.Nodes) } } $preferUnc = [bool]$chkUnc.Checked $dlg.Dispose() if ($picked.Count -eq 0) { return @() } # Normalise, optionally forcing the UNC form, and drop duplicates. $inv = Get-FilerDriveInventory $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) $out = New-Object System.Collections.ArrayList foreach ($p in $picked) { $r = Resolve-FilerScanPath -Path $p -PreferUnc:$preferUnc -Inventory $inv if ($r.Path -and $seen.Add($r.Path)) { [void]$out.Add([string]$r.Path) } } # Ticking a folder and one of its parents would walk the same tree twice and # count its bytes twice in the totals. Shortest paths first, then drop every # path already covered by one that was kept. $keep = New-Object System.Collections.ArrayList foreach ($p in @($out | Sort-Object -Property Length)) { $nested = $false foreach ($k in $keep) { if ($p.StartsWith(($k.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase)) { $nested = $true; break } } if (-not $nested) { [void]$keep.Add($p) } } return $keep.ToArray() } # ---- events ---- $btnAdd.Add_Click({ # Custom picker: handles local drives, mapped network drives (even when the # letter is missing from an elevated token), pasted UNC paths and shares. $paths = @(Show-FilerFolderPicker -Owner $form) $added = 0 foreach ($p in $paths) { if (-not $p) { continue } $dupe = $false foreach ($existing in $lstFolders.Items) { if (([string]$existing).Equals([string]$p, [System.StringComparison]::OrdinalIgnoreCase)) { $dupe = $true; break } } if (-not $dupe) { [void]$lstFolders.Items.Add([string]$p); $added++ } } if ($added -gt 0) { $statusLbl.Text = "$added dossier(s) ajouté(s)." } }) # Remove every selected row, highest index first so the earlier ones keep theirs. $removeSelectedFolders = { foreach ($i in @($lstFolders.SelectedIndices | Sort-Object -Descending)) { $lstFolders.Items.RemoveAt($i) } } $btnRemove.Add_Click($removeSelectedFolders) $lstFolders.Add_KeyDown({ param($s, $e) if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Delete -and $btnRemove.Enabled) { & $removeSelectedFolders } }) $lstFolders.Add_SelectedIndexChanged({ if ($btnAdd.Enabled) { $btnRemove.Enabled = ($lstFolders.SelectedIndices.Count -gt 0) } }) $btnScan.Add_Click({ if ($lstFolders.Items.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show('Ajoutez au moins un dossier à analyser.', 'Filer Manager', 'OK', 'Information') | Out-Null return } $paths = @($lstFolders.Items) $maxLen = [int]$numMax.Value $depth = [int]$numDepth.Value $incFiles = [bool]$chkFiles.Checked $exclude = @($txtExclude.Text -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $exclHidden = [bool]$chkHidden.Checked $grant = [bool]$chkGrant.Checked $revert = [bool]$chkRevert.Checked if ($grant -and -not (Test-IsElevated)) { $msg = "L'octroi automatique nécessite des droits Administrateur, mais Filer Manager n'est pas exécuté en mode élevé.`n`n" + "Les éléments refusés peuvent ne pas être corrigés. Continuer quand même ?" if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -ne 'Yes') { return } } $btnScan.Enabled = $false; $btnAdd.Enabled = $false; $btnRemove.Enabled = $false; $btnExport.Enabled = $false $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' $script:Runspace.Open() $script:Runspace.SessionStateProxy.SetVariable('Shared', $script:Shared) $script:PowerShell = [powershell]::Create() $script:PowerShell.Runspace = $script:Runspace [void]$script:PowerShell.AddScript($CoreFunctions) [void]$script:PowerShell.AddScript(@' param($Paths, $MaxLen, $Depth, $IncFiles, $Exclude, $ExclHidden, $Grant, $Revert, $Shared) $Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -ExcludeFolder $Exclude -ExcludeHidden $ExclHidden -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared '@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).AddArgument($exclude).AddArgument($exclHidden).AddArgument($grant).AddArgument($revert).AddArgument($script:Shared) $script:Handle = $script:PowerShell.BeginInvoke() $timer.Start() }) $btnExport.Add_Click({ if (-not $script:LastScan) { return } $choice = Show-ExportDialog -Scan $script:LastScan if (-not $choice) { return } # cancelled or nothing selected $categories = $choice.Categories # Build a filename hint reflecting the chosen scope. $scope = if ($categories -contains 'All') { 'all' } else { ($categories -join '-').ToLower() } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $baseName = "filer-report-$scope-$stamp" $dlg = New-Object System.Windows.Forms.SaveFileDialog if ($choice.Html -and $choice.Csv) { $dlg.Title = 'Enregistrer les rapports (nom de base) - HTML + CSV' $dlg.Filter = 'Rapports HTML + CSV|*.html' $dlg.FileName = "$baseName.html" } elseif ($choice.Html) { $dlg.Title = 'Enregistrer le rapport HTML' $dlg.Filter = 'Rapport HTML (*.html)|*.html' $dlg.FileName = "$baseName.html" } else { $dlg.Title = 'Enregistrer le(s) rapport(s) CSV (nom de base)' $dlg.Filter = 'Rapport CSV (*.csv)|*.csv' $dlg.FileName = "$baseName.csv" } if ($dlg.ShowDialog() -ne 'OK') { return } # Generate the report(s) on a background runspace so the GUI stays responsive; # rendering a large scan to HTML/CSV can take several seconds. $htmlPath = if ($choice.Html) { [System.IO.Path]::ChangeExtension($dlg.FileName, '.html') } else { $null } $csvBase = $dlg.FileName $script:ExportHtmlPath = $htmlPath $btnScan.Enabled = $false; $btnAdd.Enabled = $false; $btnRemove.Enabled = $false; $btnExport.Enabled = $false $progressBar.Visible = $true $statusLbl.Text = 'Export en cours...' $script:ExportShared = [hashtable]::Synchronized(@{ Written = $null; Error = $null }) $script:ExportRunspace = [runspacefactory]::CreateRunspace() $script:ExportRunspace.ApartmentState = 'STA' $script:ExportRunspace.Open() $script:ExportPowerShell = [powershell]::Create() $script:ExportPowerShell.Runspace = $script:ExportRunspace [void]$script:ExportPowerShell.AddScript($CoreFunctions) [void]$script:ExportPowerShell.AddScript(@' param($Scan, $HtmlPath, $WantHtml, $WantCsv, $CsvBase, $Categories, $HidePerm, $HideSys, $Shared) try { $written = New-Object System.Collections.ArrayList if ($WantHtml) { $out = ConvertTo-FilerHtmlReport -Scan $Scan -Path $HtmlPath -Categories $Categories -HideInheritedChildPerms $HidePerm -HideSystemPrincipals $HideSys [void]$written.Add($out) } if ($WantCsv) { $csvFiles = @(ConvertTo-FilerCsvReport -Scan $Scan -Path $CsvBase -Categories $Categories -HideInheritedChildPerms $HidePerm -HideSystemPrincipals $HideSys) foreach ($f in $csvFiles) { [void]$written.Add($f) } } $Shared.Written = $written.ToArray() } catch { $Shared.Error = $_.Exception.Message } '@).AddArgument($script:LastScan).AddArgument($htmlPath).AddArgument([bool]$choice.Html).AddArgument([bool]$choice.Csv).AddArgument($csvBase).AddArgument($categories).AddArgument([bool]$script:PermHideInherited).AddArgument([bool]$script:PermHideSystem).AddArgument($script:ExportShared) $script:ExportHandle = $script:ExportPowerShell.BeginInvoke() $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) } } [void]$form.ShowDialog() $timer.Stop(); $timer.Dispose() $exportTimer.Stop(); $exportTimer.Dispose() if ($script:PermMenu) { $script:PermMenu.Dispose(); $script:PermMenu = $null } $script:BarTrack.Dispose(); $script:BarFill.Dispose(); $script:BarEdge.Dispose(); $script:BarSel.Dispose() $tip.Dispose() $form.Dispose()