Update filer-manager.ps1

This commit is contained in:
2026-06-10 14:35:10 +02:00
parent 3793a3ec88
commit 946057232d
+590 -48
View File
@@ -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("<tr><td colspan='3' class='ok'>Aucun chemin égal ou supérieur à $($Scan.Settings.MaxPathLength) caractères. &#9989;</td></tr>") }
}
# ---- 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("<details class='perm'><summary><span class='nm'>$(_enc $grp.Name)</span> <span class='meta'>Propriétaire : $(_enc $owner)</span></summary>")
[void]$permSb.Append("<table class='grid'><thead><tr><th>Identité</th><th>Droits</th><th>Type</th><th>Hérité</th></tr></thead><tbody>")
foreach ($ace in $grp.Group) {
$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("<details class='perm$topCls' open><summary><span class='nm'>$(_enc $label)</span> <span class='meta'>Propriétaire : $(_enc $Node.Owner)</span></summary>")
[void]$Out.Append("<table class='grid'><thead><tr><th>Identité</th><th>Droits</th><th>Type</th><th>Hérité</th></tr></thead><tbody>")
foreach ($ace in $Node.Aces) {
$cls = if ($ace.Type -eq 'Deny') { ' class=deny' } else { '' }
[void]$permSb.Append("<tr$cls><td>$(_enc $ace.Identity)</td><td>$(_enc $ace.Rights)</td><td>$(_enc $ace.Type)</td><td>$($ace.Inherited)</td></tr>")
$sys = Test-IsSystemPrincipal -Identity $ace.Identity -Sid $ace.Sid
[void]$Out.Append("<tr$cls data-inh='$($ace.Inherited)' data-sys='$sys'><td>$(_enc $ace.Identity)</td><td>$(_enc $ace.Rights)</td><td>$(_enc $ace.Type)</td><td>$($ace.Inherited)</td></tr>")
}
[void]$permSb.Append("</tbody></table></details>")
[void]$Out.Append("</tbody></table>")
if ($Node.Children.Count -gt 0) {
[void]$Out.Append("<div class='kids'>")
foreach ($c in ($Node.Children | Sort-Object Display)) { _renderPermNode -Node $c -IsTop $false -Out $Out }
[void]$Out.Append("</div>")
}
[void]$Out.Append("</details>")
}
foreach ($t in ($tops | Sort-Object Display)) { _renderPermNode -Node $t -IsTop $true -Out $permSb }
if ($byFolder.Count -eq 0) { [void]$permSb.Append("<p class='muted'>Aucune permission collectée.</p>") }
}
@@ -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) { @"
<section>
<h2>&#128272; Permissions</h2>
$($permSb.ToString())
<label class='permtoggle'><input id='hideInh' type='checkbox' onchange='applyPermFilters()'>
Masquer les permissions héritées (afficher uniquement les permissions propres)</label>
<label class='permtoggle'><input id='hideSys' type='checkbox'$(if ($HideSystemPrincipals) { ' checked' }) onchange='applyPermFilters()'>
Masquer les comptes/groupes système (SYSTEM, Administrators, CREATOR OWNER&hellip;)</label>
<div id='permroot'>$($permSb.ToString())</div>
</section>
"@ })
@@ -647,6 +803,31 @@ function ConvertTo-FilerHtmlReport {
var q = inp.value.toLowerCase(), rows = document.getElementById(tableId).tBodies[0].rows;
for (var i=0;i<rows.length;i++){ rows[i].style.display = rows[i].innerText.toLowerCase().indexOf(q)>-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();
</script>
</body></html>
"@
@@ -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 $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,28 +1688,71 @@ $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
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 = "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
$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"
}
catch {
[System.Windows.Forms.MessageBox]::Show("Échec de l'export :`n$($_.Exception.Message)", 'Filer Manager', 'OK', 'Error') | Out-Null
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