diff --git a/filer-manager.ps1 b/filer-manager.ps1 index e8b8b68..dbb5f8b 100644 --- a/filer-manager.ps1 +++ b/filer-manager.ps1 @@ -1,8 +1,8 @@ -#Requires -Version 5.1 +#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 un export HTML. + 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 @@ -19,6 +19,12 @@ .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). @@ -32,6 +38,19 @@ 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 à @@ -68,9 +87,12 @@ 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, @@ -110,6 +132,56 @@ function Test-IsAccessDenied { 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 @@ -318,6 +390,7 @@ function Get-FolderPermissions { 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 @@ -420,6 +493,32 @@ function Invoke-FilerScan { } } +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, @@ -427,7 +526,11 @@ function ConvertTo-FilerHtmlReport { # 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') + [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) } @@ -482,20 +585,63 @@ function ConvertTo-FilerHtmlReport { if ($Scan.LongPaths.Count -eq 0) { [void]$longRows.Append("Aucun chemin égal ou supérieur à $($Scan.Settings.MaxPathLength) caractères. ✅") } } - # ---- permissions (grouped by folder) ---- + # ---- permissions (nested folder tree; every level is collapsible) ---- $permSb = New-Object System.Text.StringBuilder if ($wantPerm) { - $byFolder = $Scan.Permissions | Group-Object Folder + # 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) { - $owner = ($grp.Group | Select-Object -First 1).Owner - [void]$permSb.Append("
$(_enc $grp.Name) Propriétaire : $(_enc $owner)") - [void]$permSb.Append("") - foreach ($ace in $grp.Group) { - $cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' } - [void]$permSb.Append("") + $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 } - [void]$permSb.Append("
IdentitéDroitsTypeHérité
$(_enc $ace.Identity)$(_enc $ace.Rights)$(_enc $ace.Type)$($ace.Inherited)
") } + # 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.

") } } @@ -575,6 +721,12 @@ function ConvertTo-FilerHtmlReport { tr.deny td { color:var(--deny); } .ok { color:var(--ok); } .muted { color:var(--mut); } details.perm { background:var(--pan2); border-radius:10px; padding:6px 10px; margin:6px 0; border-left:2px solid var(--acc); } + details.perm.top { border-left:2px solid var(--bar); } + details.perm.top > summary > .nm { font-family:Consolas,'Cascadia Code',monospace; } + details.perm .kids { margin-left:16px; } + .permtoggle { display:inline-flex; align-items:center; gap:8px; margin-bottom:14px; color:var(--mut); + font-size:13px; cursor:pointer; user-select:none; } + .permtoggle input { accent-color:var(--acc); width:15px; height:15px; cursor:pointer; } .scroll { max-height:480px; overflow:auto; } input.filter { width:100%; padding:8px 12px; margin-bottom:12px; background:var(--pan2); border:1px solid var(--line); border-radius:8px; color:var(--ink); font-size:13px; } @@ -617,7 +769,11 @@ function ConvertTo-FilerHtmlReport { $(if ($wantPerm) { @"

🔐 Permissions

- $($permSb.ToString()) + + +
$($permSb.ToString())
"@ }) @@ -647,6 +803,31 @@ function ConvertTo-FilerHtmlReport { var q = inp.value.toLowerCase(), rows = document.getElementById(tableId).tBodies[0].rows; for (var i=0;i-1?'':'none'; } } + function applyPermFilters(){ + var inhCb = document.getElementById('hideInh'), sysCb = document.getElementById('hideSys'); + var hideInh = inhCb && inhCb.checked, hideSys = sysCb && sysCb.checked; + var anyHide = hideInh || hideSys; + // Hide each folder's own rows that match an active filter (inherited and/or system). + document.querySelectorAll('#permroot details.perm').forEach(function(g){ + g.querySelectorAll(':scope > table.grid > tbody > tr').forEach(function(r){ + var drop = (hideInh && r.getAttribute('data-inh') === 'True') || + (hideSys && r.getAttribute('data-sys') === 'True'); + r.style.display = drop ? 'none' : ''; + }); + }); + // Walk the tree bottom-up: a folder stays visible if it keeps any own row or + // any visible child folder. + function visit(node){ + var vis = false; + node.querySelectorAll(':scope > table.grid > tbody > tr').forEach(function(r){ if (r.style.display !== 'none') vis = true; }); + node.querySelectorAll(':scope > .kids > details.perm').forEach(function(c){ if (visit(c)) vis = true; }); + node.style.display = (anyHide && !vis) ? 'none' : ''; + return vis; + } + document.querySelectorAll('#permroot > details.perm').forEach(function(top){ visit(top); }); + } + // Apply the initial filter state (e.g. when the system toggle starts checked). + applyPermFilters(); "@ @@ -654,6 +835,127 @@ function ConvertTo-FilerHtmlReport { 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). @@ -662,10 +964,10 @@ function ConvertTo-FilerHtmlReport { # ============================================================================ # HEADLESS MODE # ============================================================================ -$runHeadless = $NoGui -or ($Path -and $Output) +$runHeadless = $NoGui -or ($Path -and ($Output -or $CsvOutput)) if ($runHeadless) { - if (-not $Path) { throw "Le mode sans interface requiert -Path." } - if (-not $Output) { throw "Le mode sans interface requiert -Output." } + 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." @@ -678,8 +980,19 @@ if ($runHeadless) { -IncludeFilesInTree:$IncludeFilesInTree ` -GrantAccess:$GrantAccess -RevertGrants:(-not $KeepGrants) -Progress $progress - $out = ConvertTo-FilerHtmlReport -Scan $scan -Path $Output -Categories $Category - Write-Host "Rapport généré : $out" -ForegroundColor Green + 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) @@ -774,7 +1087,7 @@ $btnScan.FlatStyle = 'Flat' $form.Controls.Add($btnScan) $btnExport = New-Object System.Windows.Forms.Button -$btnExport.Text = 'Exporter HTML...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30' +$btnExport.Text = 'Exporter...'; $btnExport.Location = '724,124'; $btnExport.Size = '110,30' $btnExport.Anchor = 'Top,Right'; $btnExport.Enabled = $false $form.Controls.Add($btnExport) @@ -792,7 +1105,66 @@ $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 @@ -822,12 +1194,24 @@ $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 @@ -846,6 +1230,13 @@ $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 @@ -853,10 +1244,19 @@ $script:PermCols = @('Folder', 'Identity', 'Rights', 'Type', 'Inherited') # c $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 Add-TreeNodes { - param($Parent, $Node) +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) { @@ -865,10 +1265,26 @@ function Add-TreeNodes { $text = "$($Node.Name) - $sizeStr ($($Node.FolderCount) dossiers, $($Node.FileCount) fichiers)" } $tn = New-Object System.Windows.Forms.TreeNode($text) - $tn.Tag = $Node.FullPath - [void]$Parent.Add($tn) - $kids = @($Node.Children | Where-Object { $_ } | Sort-Object @{E={$_.Size}} -Descending) - foreach ($c in $kids) { Add-TreeNodes -Parent $tn.Nodes -Node $c } + $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 { @@ -876,8 +1292,8 @@ function Show-Results { $script:LastScan = $Scan $tree.BeginUpdate(); $tree.Nodes.Clear() - foreach ($root in $Scan.Roots) { Add-TreeNodes -Parent $tree.Nodes -Node $root } - if ($tree.Nodes.Count -gt 0) { $tree.Nodes[0].Expand() } + 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() @@ -889,6 +1305,7 @@ function Show-Results { $lvLong.EndUpdate() $script:AllPerms = @($Scan.Permissions) + $script:PermRootPaths = Get-FilerRootPathSet -Scan $Scan $script:PermFilters = @{} $script:PermSort = @{ Prop = 'Folder'; Asc = $true } Update-PermView @@ -924,6 +1341,12 @@ 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)) }) @@ -1033,9 +1456,10 @@ function Show-PermColumnMenu { } function Show-ExportDialog { - <# Modal picker for which report categories to export. Returns an array of - canonical category names (Tree/LongPaths/Permissions/Grants/Errors) or - $null if the user cancelled. Categories with no data are disabled. #> + <# 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 @@ -1051,11 +1475,11 @@ function Show-ExportDialog { ) $dlg = New-Object System.Windows.Forms.Form - $dlg.Text = 'Exporter HTML - choisir les catégories' + $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(300, 250) + $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 :' @@ -1077,30 +1501,57 @@ function Show-ExportDialog { $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 = '104,210' + $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 = '200,210' + $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 - # Disable Export when nothing is ticked. + # Enable Export only when at least one category AND at least one format is ticked. $sync = { - $btnOk.Enabled = @($boxes | Where-Object { $_.Checked }).Count -gt 0 + $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. - if ($picked.Count -eq $boxes.Count) { return @('All') } - return $picked + $categories = if ($picked.Count -eq $boxes.Count) { @('All') } else { $picked } + return [pscustomobject]@{ Categories = $categories; Html = $wantHtml; Csv = $wantCsv } } # ---- poll timer (reads background runspace) ---- @@ -1133,6 +1584,54 @@ $timer.Add_Tick({ } }) +# ---- 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 @@ -1189,32 +1688,75 @@ $Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -Permissi $btnExport.Add_Click({ if (-not $script:LastScan) { return } - $categories = Show-ExportDialog -Scan $script:LastScan - if (-not $categories) { return } # cancelled or nothing selected + $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 - $dlg.Filter = 'Rapport HTML (*.html)|*.html' - $dlg.FileName = "filer-report-$scope-$(Get-Date -Format 'yyyyMMdd-HHmmss').html" - if ($dlg.ShowDialog() -eq 'OK') { - try { - $out = ConvertTo-FilerHtmlReport -Scan $script:LastScan -Path $dlg.FileName -Categories $categories - $statusLbl.Text = "Rapport enregistré : $out" - if ([System.Windows.Forms.MessageBox]::Show("Rapport enregistré dans :`n$out`n`nL'ouvrir maintenant ?", 'Filer Manager', - 'YesNo', 'Question') -eq 'Yes') { - Start-Process $out - } - } - catch { - [System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$($_.Exception.Message)", 'Filer Manager', 'OK', 'Error') | Out-Null - } + 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() +$form.Dispose() \ No newline at end of file