Files
FilerManager/filer-manager.ps1
T
2026-06-10 14:37:50 +02:00

1762 lines
82 KiB
PowerShell

#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) { '&#128196;' } else { '&#128193;' }
$meta = if ($isFile) { '' } else { " <span class='meta'>$($Node.FolderCount) dossiers, $($Node.FileCount) fichiers</span>" }
$kids = @($Node.Children | Where-Object { $_ } )
$summary = "$icon <span class='nm'>$(_enc $Node.Name)</span> <span class='sz'>$sizeStr</span>" +
"<span class='barwrap'><span class='bar' style='width:$pct%'></span></span><span class='pct'>$pct%</span>$meta"
if ($kids.Count -gt 0) {
$open = if ($Node.Depth -eq 0) { ' open' } else { '' }
[void]$Out.Append("<details$open><summary>$summary</summary><div class='kids'>")
foreach ($c in ($kids | Sort-Object @{E={$_.Size}} -Descending)) {
_renderNode -Node $c -ParentSize $Node.Size -Out $Out
}
[void]$Out.Append("</div></details>")
}
else {
[void]$Out.Append("<div class='leaf'>$summary</div>")
}
}
$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("<tr><td class='num'>$($lp.Length)</td><td>$(_enc $lp.Type)</td><td class='path'>$(_enc $lp.Path)</td></tr>")
}
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 (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("<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 { '' }
$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]$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>") }
}
# ---- 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 = "<span class='ok'>annulé</span>" }
elseif ($Scan.Settings.RevertGrants) { $state = "<span class='deny'>NON annulé$(if($g.RevertError){' - ' + (_enc $g.RevertError)})</span>" }
else { $state = "<span class='warn'>conservé</span>" }
[void]$grantSb.Append("<tr><td class='path'>$(_enc $g.Path)</td><td>$(_enc $g.Reason)</td><td>$(_enc $g.Changes)</td><td>$(_enc $g.OriginalOwner)</td><td>$state</td></tr>")
}
}
# ---- errors ----
$errSb = New-Object System.Text.StringBuilder
if ($wantErrors) {
foreach ($e in $Scan.Errors) {
[void]$errSb.Append("<tr><td class='path'>$(_enc $e.Path)</td><td>$(_enc $e.Error)</td></tr>")
}
}
$rootsList = ($Scan.Roots | ForEach-Object { _enc $_.FullPath }) -join '<br>'
$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 { "<div class='sub' style='margin-top:6px'>Catégories : $(_enc ($included -join ', '))</div>" }
$html = @"
<!DOCTYPE html>
<html lang='fr'><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Rapport Filer Manager - $gen</title>
<style>
:root { --bg:#0f1116; --panel:#171a21; --pan2:#1d2129; --ink:#e7eaf0; --mut:#8b93a7;
--acc:#4f8cff; --bar:#3b6fd4; --warn:#ffb454; --deny:#ff6b6b; --ok:#42d392; --line:#2a2f3a; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.5 'Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--ink); }
header { padding:24px 32px; background:linear-gradient(120deg,#1b2a4a,#142035); border-bottom:1px solid var(--line); }
header h1 { margin:0 0 4px; font-size:22px; }
header .sub { color:var(--mut); font-size:13px; }
main { padding:24px 32px; max-width:1200px; margin:0 auto; }
.cards { display:flex; flex-wrap:wrap; gap:14px; margin:8px 0 28px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px 20px; min-width:150px; flex:1; }
.card .v { font-size:24px; font-weight:600; }
.card .l { color:var(--mut); font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
.card.warn .v { color:var(--warn); } .card.bad .v { color:var(--deny); }
section { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px 22px; margin-bottom:22px; }
section h2 { margin:0 0 14px; font-size:16px; display:flex; align-items:center; gap:8px; }
details { border-left:2px solid var(--line); margin:2px 0; }
summary { cursor:pointer; padding:3px 6px; border-radius:6px; list-style:none; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
summary:hover { background:var(--pan2); }
summary::-webkit-details-marker { display:none; }
.kids { margin-left:18px; }
.leaf { padding:3px 6px; display:flex; align-items:center; gap:8px; margin-left:4px; }
.nm { font-weight:500; } .sz { color:var(--acc); font-variant-numeric:tabular-nums; min-width:84px; }
.pct { color:var(--mut); font-size:12px; min-width:42px; text-align:right; }
.meta { color:var(--mut); font-size:12px; }
.barwrap { flex:0 0 120px; height:7px; background:var(--pan2); border-radius:4px; overflow:hidden; }
.bar { display:block; height:100%; background:linear-gradient(90deg,var(--bar),var(--acc)); }
table.grid { width:100%; border-collapse:collapse; font-size:13px; }
table.grid th, table.grid td { text-align:left; padding:6px 10px; border-bottom:1px solid var(--line); }
table.grid th { color:var(--mut); font-weight:600; position:sticky; top:0; background:var(--panel); }
td.num { text-align:right; font-variant-numeric:tabular-nums; color:var(--warn); font-weight:600; }
td.path { font-family:Consolas,'Cascadia Code',monospace; font-size:12px; word-break:break-all; }
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; }
footer { color:var(--mut); font-size:12px; text-align:center; padding:24px; }
</style></head>
<body>
<header>
<h1>&#128193; Rapport Filer Manager</h1>
<div class='sub'>Généré le $gen &middot; Hôte <b>$(_enc $Scan.Computer)</b> &middot;
Seuil chemin trop long $($Scan.Settings.MaxPathLength) &middot; Profondeur des permissions $($Scan.Settings.PermissionDepth)</div>
<div class='sub' style='margin-top:6px'>Analysé : $rootsList</div>
$catNote
</header>
<main>
<div class='cards'>
<div class='card'><div class='v'>$(Format-Bytes $Scan.Stats.TotalSize)</div><div class='l'>Taille totale</div></div>
<div class='card'><div class='v'>$('{0:N0}' -f $Scan.Stats.TotalFolders)</div><div class='l'>Dossiers</div></div>
<div class='card'><div class='v'>$('{0:N0}' -f $Scan.Stats.TotalFiles)</div><div class='l'>Fichiers</div></div>
<div class='card $(if($Scan.Stats.LongPaths){'warn'})'><div class='v'>$($Scan.Stats.LongPaths)</div><div class='l'>Chemins trop longs</div></div>
<div class='card $(if($Scan.Stats.Errors){'bad'})'><div class='v'>$($Scan.Stats.Errors)</div><div class='l'>Erreurs / refusés</div></div>
$(if ($Scan.Settings.GrantAccess) { "<div class='card $(if($Scan.Stats.Grants){'warn'})'><div class='v'>$($Scan.Stats.Grants)</div><div class='l'>Accès accordé</div></div>" })
</div>
$(if ($wantTree) { @"
<section>
<h2>&#128202; Tailles des dossiers</h2>
<div class='tree'>$($treeSb.ToString())</div>
</section>
"@ })
$(if ($wantLong) { @"
<section>
<h2>&#9888;&#65039; Noms de fichiers / chemins trop longs (&ge; $($Scan.Settings.MaxPathLength) caractères)</h2>
<input class='filter' placeholder='Filtrer les chemins&hellip;' oninput="filterRows(this,'longtbl')">
<div class='scroll'><table class='grid' id='longtbl'><thead><tr><th>Longueur</th><th>Type</th><th>Chemin</th></tr></thead>
<tbody>$($longRows.ToString())</tbody></table></div>
</section>
"@ })
$(if ($wantPerm) { @"
<section>
<h2>&#128272; Permissions</h2>
<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>
"@ })
$(if ($wantGrants -and $Scan.Settings.GrantAccess) { @"
<section>
<h2>&#128296; Accès accordé pour terminer l'analyse</h2>
<p class='muted'>É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' }).</p>
$(if ($grantRows.Count -gt 0) { @"
<div class='scroll'><table class='grid'><thead><tr><th>Chemin</th><th>Raison</th><th>Modification</th><th>Propriétaire d'origine</th><th>État</th></tr></thead>
<tbody>$($grantSb.ToString())</tbody></table></div>
"@ } else { "<p class='ok'>Aucune modification d'accès n'était nécessaire. &#9989;</p>" })
</section>
"@ })
$(if ($wantErrors -and $Scan.Errors.Count -gt 0) { @"
<section>
<h2>&#10060; Erreurs et accès refusé</h2>
<div class='scroll'><table class='grid'><thead><tr><th>Chemin</th><th>Erreur</th></tr></thead>
<tbody>$($errSb.ToString())</tbody></table></div>
</section>
"@ })
</main>
<footer>Filer Manager &middot; rapport autonome &middot; $gen</footer>
<script>
function filterRows(inp, tableId){
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>
"@
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()