#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. .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 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 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. #> [CmdletBinding()] param( [string[]]$Path, [string]$Output, [string]$CsvOutput, [int]$MaxPathLength = 260, [int]$PermissionDepth = 1, [switch]$IncludeFilesInTree, [switch]$HideInheritedChildPerms, [switch]$HideSystemPrincipals, [switch]$GrantAccess, [switch]$KeepGrants, [switch]$NoGui, [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 } 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 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 }) 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" }) } } } 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 = Split-Path -Path $Path -Leaf 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) { try { $entries = Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop break } catch { if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) { $granted = $true if ($Progress) { $Progress.Status = "Octroi de l'accès : $Path" } if (Grant-AdminAccess -Path $Path -Reason 'énumérer le dossier' -Grants $GrantList -ScanErrors $ScanErrors) { continue # retry once, now that access has been granted } } [void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = $_.Exception.Message }) return [pscustomobject]$node } } foreach ($entry in $entries) { if ($entry.PSIsContainer) { $child = Get-FolderNode -Path $entry.FullName -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 = 0 try { $len = [long]$entry.Length } catch { $len = 0 } $node.Size += $len $node.FileCount += 1 if ($entry.FullName.Length -ge $MaxLen) { [void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullName.Length; Path = $entry.FullName }) } if ($IncludeFilesInTreeFlag) { [void]$node.Children.Add([pscustomobject]@{ Name = $entry.Name; FullPath = $entry.FullName; 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 foreach ($ace in $acl.Access) { [void]$results.Add([pscustomobject]@{ Folder = $Path Owner = $acl.Owner Identity = [string]$ace.IdentityReference Sid = Resolve-PrincipalSid $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 = $null try { $subDirs = Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction Stop } catch { $subDirs = @() } foreach ($d in $subDirs) { $child = Get-FolderPermissions -Path $d.FullName -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, [bool]$GrantAccess = $false, [bool]$RevertGrants = $true, [hashtable]$Progress ) $script:IncludeFilesInTreeFlag = $IncludeFilesInTree $script:GrantAccessFlag = $GrantAccess $script:GrantList = New-Object System.Collections.ArrayList $longPaths = New-Object System.Collections.ArrayList $scanErrors = New-Object System.Collections.ArrayList $permissions = New-Object System.Collections.ArrayList $roots = New-Object System.Collections.ArrayList foreach ($p in $Paths) { if (-not (Test-Path -LiteralPath $p)) { [void]$scanErrors.Add([pscustomobject]@{ Path = $p; Error = 'Chemin introuvable' }) continue } $fullPath = (Resolve-Path -LiteralPath $p).Path $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 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) $icon = if ($isFile) { '📄' } else { '📁' } $meta = if ($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') # 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
$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' } $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 UTF8 -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) [void]$Acc.Add([pscustomobject]@{ Racine = $Root Chemin = $Node.FullPath Nom = $Node.Name Type = if ($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() } '@ # Dot-source core functions into the current scope (for headless + GUI export). . ([scriptblock]::Create($CoreFunctions)) # ============================================================================ # 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." } 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 ` -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(760, 560) # ---- 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 = '700,84' $lstFolders.Anchor = 'Top,Left,Right' $lstFolders.HorizontalScrollbar = $true $form.Controls.Add($lstFolders) $btnAdd = New-Object System.Windows.Forms.Button $btnAdd.Text = 'Ajouter...'; $btnAdd.Location = '724,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 = '724,66'; $btnRemove.Size = '110,28' $btnRemove.Anchor = 'Top,Right' $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) # ---- 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,156'; $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,156'; $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 = '600,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' $form.Controls.Add($btnScan) $btnExport = New-Object System.Windows.Forms.Button $btnExport.Text = 'Exporter...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30' $btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false $form.Controls.Add($btnExport) # ---- tabs ---- $tabs = New-Object System.Windows.Forms.TabControl $tabs.Location = '12,188'; $tabs.Size = '922,456' $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é' $tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant)) $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 }) # 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) { $hl = New-Object System.Drawing.SolidBrush ([System.Drawing.SystemColors]::Highlight) $e.Graphics.FillRectangle($hl, $e.Bounds); $hl.Dispose() } $flags = [System.Windows.Forms.TextFormatFlags]::VerticalCenter -bor ` [System.Windows.Forms.TextFormatFlags]::Left -bor ` [System.Windows.Forms.TextFormatFlags]::NoPrefix [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. $barW = 120; $barH = 9 $barX = $e.Bounds.Right + 10 $barY = $e.Bounds.Top + [int](($e.Bounds.Height - $barH) / 2) $track = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(228, 230, 235)) $e.Graphics.FillRectangle($track, $barX, $barY, $barW, $barH); $track.Dispose() $fillW = [int][math]::Round($barW * $pct / 100) if ($fillW -gt 0) { $fill = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(59, 111, 212)) $e.Graphics.FillRectangle($fill, $barX, $barY, $fillW, $barH); $fill.Dispose() } $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(170, 175, 185)) $e.Graphics.DrawRectangle($pen, $barX, $barY, $barW, $barH); $pen.Dispose() $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). $permBar = New-Object System.Windows.Forms.Panel $permBar.Dock = 'Top'; $permBar.Height = 30 $lblPermHint = New-Object System.Windows.Forms.Label $lblPermHint.Text = 'Cliquez sur un en-tête de colonne pour filtrer ou trier.' $lblPermHint.AutoSize = $true; $lblPermHint.Location = '6,7' $lblPermHint.ForeColor = [System.Drawing.Color]::DimGray $btnPermClear = New-Object System.Windows.Forms.Button $btnPermClear.Text = 'Effacer les filtres'; $btnPermClear.Size = '130,24' $btnPermClear.Dock = 'Right'; $btnPermClear.Enabled = $false $chkPermHideInh = New-Object System.Windows.Forms.CheckBox $chkPermHideInh.Text = 'Masquer les permissions héritées (enfants)' $chkPermHideInh.AutoSize = $true; $chkPermHideInh.Dock = 'Right' $chkPermHideInh.Padding = '0,4,8,0' $chkPermHideSys = New-Object System.Windows.Forms.CheckBox $chkPermHideSys.Text = 'Masquer les comptes/groupes système' $chkPermHideSys.AutoSize = $true; $chkPermHideSys.Dock = 'Right' $chkPermHideSys.Padding = '0,4,8,0' $permBar.Controls.Add($lblPermHint) $permBar.Controls.Add($chkPermHideInh) $permBar.Controls.Add($chkPermHideSys) $permBar.Controls.Add($btnPermClear) $tabPerm.Controls.Add($permBar) $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 }) # ---- 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 # 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: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) # ---- 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) if ($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 @{E={$_.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() $lvLong.BeginUpdate(); $lvLong.Items.Clear() 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) [void]$lvLong.Items.Add($it) } $lvLong.EndUpdate() $script:AllPerms = @($Scan.Permissions) $script:PermRootPaths = Get-FilerRootPathSet -Scan $Scan $script:PermFilters = @{} $script:PermSort = @{ Prop = 'Folder'; Asc = $true } Update-PermView $lvGrant.BeginUpdate(); $lvGrant.Items.Clear() 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) [void]$lvGrant.Items.Add($it) } $lvGrant.EndUpdate() $tabGrant.Text = if ($lvGrant.Items.Count -gt 0) { "Accès accordé ($($lvGrant.Items.Count))" } else { 'Accès accordé' } $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. $rows = $script:AllPerms if ($script:PermHideInherited) { $rows = @($rows | Where-Object { (-not $_.Inherited) -or $script:PermRootPaths.Contains([string]$_.Folder) }) } if ($script:PermHideSystem) { $rows = @($rows | Where-Object { -not (Test-IsSystemPrincipal -Identity $_.Identity -Sid $_.Sid) }) } foreach ($prop in $script:PermFilters.Keys) { $allowed = $script:PermFilters[$prop] $rows = @($rows | Where-Object { $allowed.Contains((Get-PermCellValue $_ $prop)) }) } $sortProp = $script:PermSort.Prop $rows = @($rows | Sort-Object @{ E = { Get-PermCellValue $_ $sortProp } }) if (-not $script:PermSort.Asc) { [array]::Reverse($rows) } $lvPerm.BeginUpdate(); $lvPerm.Items.Clear() 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 = [System.Drawing.Color]::Firebrick } [void]$lvPerm.Items.Add($it) } $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) 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] $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 } $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) $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 # 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 } } # ---- 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 { $result = $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 $progressBar.Visible = $false $btnScan.Enabled = $true; $btnAdd.Enabled = $true; $btnRemove.Enabled = $true } } } }) # ---- 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 = $true $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) } } }) # ---- events ---- $btnAdd.Add_Click({ $dlg = New-Object System.Windows.Forms.FolderBrowserDialog $dlg.Description = 'Sélectionnez un dossier à analyser' if ($dlg.ShowDialog() -eq 'OK') { if (-not $lstFolders.Items.Contains($dlg.SelectedPath)) { [void]$lstFolders.Items.Add($dlg.SelectedPath) } } }) $btnRemove.Add_Click({ if ($lstFolders.SelectedIndex -ge 0) { $lstFolders.Items.RemoveAt($lstFolders.SelectedIndex) } }) $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 $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: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, $Grant, $Revert, $Shared) $Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared '@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).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() }) # Pre-fill folders if -Path was supplied without -Output if ($Path) { foreach ($p in $Path) { [void]$lstFolders.Items.Add($p) } } [void]$form.ShowDialog() $form.Dispose()