From dfe8bc97b7c884ba03e56e745e6a63ab67c47960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= <2+kawa@not.obvious> Date: Wed, 10 Jun 2026 14:37:50 +0200 Subject: [PATCH] Upload files to "/" --- filer-manager.ps1 | 1762 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1762 insertions(+) create mode 100644 filer-manager.ps1 diff --git a/filer-manager.ps1 b/filer-manager.ps1 new file mode 100644 index 0000000..ee546fa --- /dev/null +++ b/filer-manager.ps1 @@ -0,0 +1,1762 @@ +#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() \ No newline at end of file