3801 lines
177 KiB
PowerShell
3801 lines
177 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.
|
|
|
|
Accepte un chemin local, un chemin UNC (\\serveur\partage\dossier) ou un
|
|
lecteur réseau mappé (Z:\dossier). Un lecteur mappé est automatiquement
|
|
réécrit vers sa cible UNC lorsque la lettre n'est pas montée dans le jeton
|
|
courant : les mappages appartiennent à la session qui les a créés, donc ceux
|
|
de l'utilisateur interactif sont absents d'une exécution en mode élevé (et
|
|
inversement). En tâche planifiée sous un autre compte, indiquez l'UNC.
|
|
|
|
.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 ExcludeFolder
|
|
Noms de dossiers à exclure de l'analyse (tailles, chemins trop longs et
|
|
permissions). Les jokers sont acceptés et la comparaison est insensible à la
|
|
casse, p. ex. '#recycle', '@eaDir', 'Thumbs*'. Par défaut, les corbeilles et
|
|
dossiers système courants sont exclus : #recycle et @eaDir (NAS Synology) et
|
|
$RECYCLE.BIN (corbeille Windows). Passez une liste vide (-ExcludeFolder @())
|
|
pour ne rien exclure par nom.
|
|
|
|
.PARAMETER ExcludeHidden
|
|
Exclure de l'analyse les dossiers portant l'attribut « masqué ».
|
|
|
|
.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 RevertPendingGrants
|
|
Annuler les modifications d'accès laissées par une exécution précédente qui
|
|
s'est arrêtée avant de les restaurer (interface fermée pendant l'analyse,
|
|
plantage, fermeture de session). Chaque octroi est journalisé sous
|
|
%LOCALAPPDATA%\FilerManager avant d'être appliqué ; ce commutateur rejoue les
|
|
journaux orphelins puis se termine. Ne demande aucun autre paramètre.
|
|
|
|
.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.
|
|
|
|
.EXAMPLE
|
|
.\filer-manager.ps1 -Path "\\NAS\Public" -Output filer.html -NoGui -ExcludeFolder '#recycle','@eaDir','Thumbs*' -ExcludeHidden
|
|
Exclut les dossiers système Synology (#recycle, @eaDir), tout dossier « Thumbs… »
|
|
et les dossiers masqués de l'analyse.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string[]]$Path,
|
|
[string]$Output,
|
|
[string]$CsvOutput,
|
|
[int]$MaxPathLength = 260,
|
|
[int]$PermissionDepth = 1,
|
|
[switch]$IncludeFilesInTree,
|
|
[string[]]$ExcludeFolder = @('#recycle', '@eaDir', '$RECYCLE.BIN'),
|
|
[switch]$ExcludeHidden,
|
|
[switch]$HideInheritedChildPerms,
|
|
[switch]$HideSystemPrincipals,
|
|
[switch]$GrantAccess,
|
|
[switch]$KeepGrants,
|
|
[switch]$NoGui,
|
|
[switch]$RevertPendingGrants,
|
|
[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 }
|
|
# A failure raised inside a .NET call reaches us wrapped in a
|
|
# MethodInvocationException, so the real cause is only visible on the base
|
|
# exception. Windows PowerShell 5.1 also reports some denials as a bare
|
|
# Win32Exception carrying ERROR_ACCESS_DENIED.
|
|
$base = $null
|
|
try { $base = $ex.GetBaseException() } catch { }
|
|
if ($base -is [System.UnauthorizedAccessException]) { return $true }
|
|
if ($base -is [System.ComponentModel.Win32Exception] -and $base.NativeErrorCode -eq 5) { 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 Resolve-SidName {
|
|
# Friendly account name for a SID, falling back to the raw SID string when it
|
|
# cannot be translated (deleted account, broken trust, offline domain).
|
|
# Results are cached: a large scan meets the same handful of principals
|
|
# thousands of times and every miss costs an LSA / directory round trip.
|
|
param($Sid)
|
|
if (-not $Sid) { return '' }
|
|
if ($null -eq $script:FilerSidNameCache) { $script:FilerSidNameCache = @{} }
|
|
$key = [string]$Sid
|
|
if ($script:FilerSidNameCache.ContainsKey($key)) { return $script:FilerSidNameCache[$key] }
|
|
$name = $key
|
|
try {
|
|
if ($Sid -is [System.Security.Principal.SecurityIdentifier]) {
|
|
$name = $Sid.Translate([System.Security.Principal.NTAccount]).Value
|
|
}
|
|
else {
|
|
$name = (New-Object System.Security.Principal.SecurityIdentifier $key).Translate([System.Security.Principal.NTAccount]).Value
|
|
}
|
|
} catch { }
|
|
$script:FilerSidNameCache[$key] = $name
|
|
return $name
|
|
}
|
|
|
|
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
|
|
})
|
|
|
|
# Persist before returning: from here on the change is live on disk, so it
|
|
# has to be recoverable even if this process never reaches Restore-Grants.
|
|
if ($success) { Save-FilerGrantJournal -Grants $Grants }
|
|
|
|
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" }) }
|
|
}
|
|
Save-FilerGrantJournal -Grants $Grants
|
|
}
|
|
|
|
function Get-FilerGrantJournalDir {
|
|
# Where the pending-grant journals live: one file per process id.
|
|
$dir = Join-Path ([string]$env:LOCALAPPDATA) 'FilerManager'
|
|
if (-not (Test-Path -LiteralPath $dir)) {
|
|
try { $null = New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop } catch { return $null }
|
|
}
|
|
return $dir
|
|
}
|
|
|
|
function Save-FilerGrantJournal {
|
|
<# Mirrors the not-yet-reverted grants to disk. Without it, losing the process
|
|
between a grant and Restore-Grants - the GUI closed mid-scan, a crash, a
|
|
logoff - would leave Administrators as owner with FullControl on the
|
|
scanned folders and no record that Filer Manager put it there. Best effort
|
|
by design: a journal failure must never abort a scan. The file is removed
|
|
as soon as nothing is pending. #>
|
|
param([System.Collections.ArrayList]$Grants)
|
|
if (-not $script:FilerGrantJournalPath) { return }
|
|
try {
|
|
$pending = @($Grants | Where-Object { $_.Success -and -not $_.Reverted } | ForEach-Object {
|
|
[pscustomobject]@{
|
|
Path = [string]$_.Path
|
|
IsDir = [bool]$_.IsDir
|
|
OriginalOwner = [string]$_.OriginalOwner
|
|
OriginalSddl = [string]$_.OriginalSddl
|
|
}
|
|
})
|
|
if ($pending.Count -eq 0) {
|
|
Remove-Item -LiteralPath $script:FilerGrantJournalPath -Force -ErrorAction SilentlyContinue
|
|
return
|
|
}
|
|
# Wrapped in an object: ConvertTo-Json collapses a one-element array.
|
|
$doc = [pscustomobject]@{
|
|
Computer = [string]$env:COMPUTERNAME
|
|
Pid = $PID
|
|
Written = (Get-Date).ToString('o')
|
|
Grants = $pending
|
|
}
|
|
(ConvertTo-Json -InputObject $doc -Depth 4) |
|
|
Set-Content -LiteralPath $script:FilerGrantJournalPath -Encoding UTF8 -ErrorAction Stop
|
|
} catch { }
|
|
}
|
|
|
|
function Get-FilerPendingGrantJournal {
|
|
<# Journals left behind by a run that never reverted. A journal whose owning
|
|
process is still alive is skipped - that scan is running and will clean up
|
|
after itself. A recycled process id can therefore delay a recovery, never
|
|
break one: the file simply stays until a later run finds the id free. #>
|
|
$dir = Get-FilerGrantJournalDir
|
|
if (-not $dir) { return @() }
|
|
$out = New-Object System.Collections.ArrayList
|
|
foreach ($f in @(Get-ChildItem -LiteralPath $dir -Filter 'grants-*.json' -File -ErrorAction SilentlyContinue)) {
|
|
$doc = $null
|
|
try { $doc = (Get-Content -LiteralPath $f.FullName -Raw -ErrorAction Stop) | ConvertFrom-Json } catch { continue }
|
|
$rows = @($doc.Grants | Where-Object { $_ -and $_.Path })
|
|
if ($rows.Count -eq 0) { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction SilentlyContinue; continue }
|
|
$owner = 0
|
|
try { $owner = [int]$doc.Pid } catch { }
|
|
if ($owner -eq $PID) { continue }
|
|
if ($owner -gt 0) {
|
|
$alive = $null
|
|
try { $alive = Get-Process -Id $owner -ErrorAction Stop } catch { }
|
|
if ($alive) { continue }
|
|
}
|
|
[void]$out.Add([pscustomobject]@{ File = $f.FullName; Grants = $rows; Written = [string]$doc.Written })
|
|
}
|
|
return $out.ToArray()
|
|
}
|
|
|
|
function Restore-FilerPendingGrants {
|
|
<# Replays one orphaned journal through Restore-Grants, deleting it only once
|
|
every entry came back. Returns a small report. #>
|
|
param([Parameter(Mandatory)] $Journal)
|
|
$grants = New-Object System.Collections.ArrayList
|
|
foreach ($g in @($Journal.Grants)) {
|
|
[void]$grants.Add([pscustomobject]@{
|
|
Path = [string]$g.Path
|
|
IsDir = [bool]$g.IsDir
|
|
Reason = 'journal'
|
|
Changes = ''
|
|
OriginalOwner = [string]$g.OriginalOwner
|
|
OriginalSddl = [string]$g.OriginalSddl
|
|
Success = $true
|
|
Error = $null
|
|
Reverted = $false
|
|
RevertError = $null
|
|
})
|
|
}
|
|
$errs = New-Object System.Collections.ArrayList
|
|
# Blank the journal path while replaying: Restore-Grants must not rewrite the
|
|
# very file we are consuming.
|
|
$saved = $script:FilerGrantJournalPath
|
|
$script:FilerGrantJournalPath = $null
|
|
try { Restore-Grants -Grants $grants -ScanErrors $errs -Progress $null }
|
|
finally { $script:FilerGrantJournalPath = $saved }
|
|
|
|
$failed = @($grants | Where-Object { -not $_.Reverted })
|
|
if ($failed.Count -eq 0) { Remove-Item -LiteralPath $Journal.File -Force -ErrorAction SilentlyContinue }
|
|
return [pscustomobject]@{
|
|
Total = $grants.Count
|
|
Reverted = @($grants | Where-Object { $_.Reverted }).Count
|
|
Failed = $failed.Count
|
|
Errors = @($errs)
|
|
}
|
|
}
|
|
|
|
function Test-IsExcludedFolder {
|
|
# True when a directory entry should be skipped from the analysis. A folder is
|
|
# excluded when its name matches one of the (wildcard, case-insensitive)
|
|
# patterns - e.g. '#recycle', '@eaDir', 'Thumbs*' - or when -ExcludeHidden is
|
|
# set and the directory carries the Hidden attribute. Exclusions apply to
|
|
# descendants only; a scan root is always analysed even if it would match.
|
|
param($Entry, [string[]]$Patterns, [bool]$ExcludeHidden)
|
|
|
|
if ($ExcludeHidden) {
|
|
try { if (($Entry.Attributes -band [System.IO.FileAttributes]::Hidden) -ne 0) { return $true } } catch { }
|
|
}
|
|
if ($Patterns) {
|
|
$name = [string]$Entry.Name
|
|
foreach ($pat in $Patterns) {
|
|
if ([string]::IsNullOrWhiteSpace($pat)) { continue }
|
|
if ($name -like $pat) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-IsReparsePoint {
|
|
# True for a junction, a directory symlink or any other reparse point. Those
|
|
# must never be descended into: a link that points at one of its own
|
|
# ancestors makes the walk recurse until the stack (or the path length) gives
|
|
# out, and a lateral one counts the very same bytes twice. They are still
|
|
# reported as an entry, just never followed.
|
|
param($Entry)
|
|
try { return (($Entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) } catch { return $false }
|
|
}
|
|
|
|
function Expand-FilerShortPath {
|
|
<# Expands 8.3 short components of a path to their long form. Resolve-Path
|
|
keeps them as-is and [IO.Path]::GetFullPath expands them, so neither is a
|
|
reliable answer on its own; GetLongPathNameW is authoritative and also
|
|
accepts an extended-length path. #>
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
|
|
if (-not ('FilerManager.LongPath' -as [type])) { try { Initialize-FilerNetApi } catch { } }
|
|
if ('FilerManager.LongPath' -as [type]) {
|
|
try { return [FilerManager.LongPath]::ExpandShort($Path) } catch { }
|
|
}
|
|
return $Path
|
|
}
|
|
|
|
function Get-FilerChildEntry {
|
|
<# Directory listing that is not bounded by MAX_PATH, used by every part of
|
|
the scan that walks a tree. Returns Entries / ErrorCode / ErrorMessage
|
|
instead of throwing, so the caller can tell "access denied" (code 5) from
|
|
"not there" without unwrapping a MethodInvocationException.
|
|
|
|
Entries carry Name / FullPath / Attributes / IsDirectory / IsReparsePoint /
|
|
Length, which is the subset of FileSystemInfo the scan actually used, so
|
|
Test-IsExcludedFolder and Test-IsReparsePoint work on them unchanged.
|
|
Get-ChildItem remains as a fallback in case the P/Invoke shim cannot be
|
|
compiled. #>
|
|
param([string]$Path)
|
|
|
|
if (-not ('FilerManager.LongPath' -as [type])) { try { Initialize-FilerNetApi } catch { } }
|
|
if ('FilerManager.LongPath' -as [type]) {
|
|
try { return [FilerManager.LongPath]::GetEntries($Path) } catch { }
|
|
}
|
|
|
|
try {
|
|
$out = New-Object System.Collections.ArrayList
|
|
foreach ($i in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) {
|
|
$isDir = [bool]$i.PSIsContainer
|
|
$len = [long]0
|
|
if (-not $isDir) { try { $len = [long]$i.Length } catch { $len = [long]0 } }
|
|
[void]$out.Add([pscustomobject]@{
|
|
Name = [string]$i.Name
|
|
FullPath = [string]$i.FullName
|
|
Attributes = $i.Attributes
|
|
IsDirectory = $isDir
|
|
IsReparsePoint = (($i.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
|
|
Length = $len
|
|
})
|
|
}
|
|
return [pscustomobject]@{ Entries = $out.ToArray(); ErrorCode = 0; ErrorMessage = $null }
|
|
}
|
|
catch {
|
|
$code = if (Test-IsAccessDenied $_) { 5 } else { -1 }
|
|
return [pscustomobject]@{ Entries = @(); ErrorCode = $code; ErrorMessage = $_.Exception.GetBaseException().Message }
|
|
}
|
|
}
|
|
|
|
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) {
|
|
$listing = Get-FilerChildEntry -Path $Path
|
|
if ($listing.ErrorCode -eq 0) { $entries = $listing.Entries; break }
|
|
if ($GrantAccessFlag -and -not $granted -and $listing.ErrorCode -eq 5) {
|
|
$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 = [string]$listing.ErrorMessage })
|
|
return [pscustomobject]$node
|
|
}
|
|
|
|
foreach ($entry in $entries) {
|
|
if ($entry.IsDirectory) {
|
|
if (Test-IsExcludedFolder -Entry $entry -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue }
|
|
if (Test-IsReparsePoint -Entry $entry) {
|
|
# Junction / directory symlink: recorded as a leaf, never walked,
|
|
# and contributing no size (the bytes belong to the link target).
|
|
$node.FolderCount += 1
|
|
[void]$node.Children.Add([pscustomobject]@{
|
|
Name = $entry.Name; FullPath = $entry.FullPath; Size = [long]0
|
|
FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsLink = $true
|
|
Children = (New-Object System.Collections.ArrayList)
|
|
})
|
|
if ($entry.FullPath.Length -ge $MaxLen) {
|
|
[void]$LongPaths.Add([pscustomobject]@{ Type = 'Lien'; Length = $entry.FullPath.Length; Path = $entry.FullPath })
|
|
}
|
|
continue
|
|
}
|
|
$child = Get-FolderNode -Path $entry.FullPath -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 = [long]0
|
|
try { $len = [long]$entry.Length } catch { $len = [long]0 }
|
|
$node.Size += $len
|
|
$node.FileCount += 1
|
|
if ($entry.FullPath.Length -ge $MaxLen) {
|
|
[void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullPath.Length; Path = $entry.FullPath })
|
|
}
|
|
if ($IncludeFilesInTreeFlag) {
|
|
[void]$node.Children.Add([pscustomobject]@{
|
|
Name = $entry.Name; FullPath = $entry.FullPath; 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
|
|
# Read the owner and the ACEs as raw SIDs, then translate each name on its
|
|
# own. The NTAccount form of these APIs ($acl.Owner / $acl.Access) throws
|
|
# IdentityNotMappedException as soon as ONE principal is unresolvable - a
|
|
# single orphaned SID would otherwise cost us the whole folder's ACL.
|
|
$ownerSid = $null
|
|
try { $ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]) } catch { }
|
|
$owner = Resolve-SidName $ownerSid
|
|
foreach ($ace in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
|
|
[void]$results.Add([pscustomobject]@{
|
|
Folder = $Path
|
|
Owner = $owner
|
|
Identity = (Resolve-SidName $ace.IdentityReference)
|
|
Sid = [string]$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 = @()
|
|
$listing = Get-FilerChildEntry -Path $Path
|
|
if ($listing.ErrorCode -eq 0) { $subDirs = @($listing.Entries | Where-Object { $_.IsDirectory }) }
|
|
foreach ($d in $subDirs) {
|
|
if (Test-IsExcludedFolder -Entry $d -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue }
|
|
if (Test-IsReparsePoint -Entry $d) { continue } # never follow a junction / symlink
|
|
$child = Get-FolderPermissions -Path $d.FullPath -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,
|
|
[string[]]$ExcludeFolder = @(),
|
|
[bool]$ExcludeHidden = $false,
|
|
[bool]$GrantAccess = $false,
|
|
[bool]$RevertGrants = $true,
|
|
[hashtable]$Progress
|
|
)
|
|
|
|
$script:IncludeFilesInTreeFlag = $IncludeFilesInTree
|
|
$script:ExcludeFolderFlag = $ExcludeFolder
|
|
$script:ExcludeHiddenFlag = $ExcludeHidden
|
|
$script:GrantAccessFlag = $GrantAccess
|
|
$script:GrantList = New-Object System.Collections.ArrayList
|
|
|
|
# Arm the on-disk journal only when we actually intend to revert: with
|
|
# -KeepGrants the changes are meant to stay, so there is nothing to recover.
|
|
$script:FilerGrantJournalPath = $null
|
|
if ($GrantAccess -and $RevertGrants) {
|
|
$jdir = Get-FilerGrantJournalDir
|
|
if ($jdir) { $script:FilerGrantJournalPath = Join-Path $jdir ("grants-{0}.json" -f $PID) }
|
|
}
|
|
|
|
# Compile the P/Invoke shim up front: Get-FilerChildEntry needs
|
|
# FilerManager.LongPath to get past MAX_PATH, and doing it once here keeps the
|
|
# cost off the recursion.
|
|
try { Initialize-FilerNetApi } catch { }
|
|
|
|
$longPaths = New-Object System.Collections.ArrayList
|
|
$scanErrors = New-Object System.Collections.ArrayList
|
|
$permissions = New-Object System.Collections.ArrayList
|
|
$roots = New-Object System.Collections.ArrayList
|
|
|
|
# Drive inventory is only needed when a root is given as a drive letter; it is
|
|
# resolved once and shared, since a network path can be slow to interrogate.
|
|
$inventory = $null
|
|
if (@($Paths | Where-Object { $_ -match '^[A-Za-z]:' }).Count -gt 0) {
|
|
$inventory = Get-FilerDriveInventory
|
|
}
|
|
|
|
foreach ($rawPath in $Paths) {
|
|
# A mapped drive letter is rewritten to its UNC target when the mapping is
|
|
# missing from this process token (typical for an elevated run: UAC gives
|
|
# each token its own set of network drives).
|
|
$resolved = Resolve-FilerScanPath -Path $rawPath -Inventory $inventory
|
|
$p = $resolved.Path
|
|
if (-not $p) {
|
|
[void]$scanErrors.Add([pscustomobject]@{ Path = $rawPath; Error = 'Chemin vide' })
|
|
continue
|
|
}
|
|
if ($Progress -and $resolved.Rewritten) { $Progress.Status = "Chemin réseau : $rawPath -> $p" }
|
|
|
|
$reach = Test-FilerPathReachable -Path $p -TimeoutSeconds 20
|
|
if (-not $reach.Exists) {
|
|
$msg = 'Chemin introuvable'
|
|
if ($reach.TimedOut) {
|
|
$msg = 'Serveur injoignable (délai dépassé)'
|
|
}
|
|
elseif ($resolved.Letter -and -not $resolved.Mounted) {
|
|
$msg = if ($resolved.Unc) {
|
|
"Lecteur $($resolved.Letter) non monté dans cette session ; cible $($resolved.Unc) inaccessible"
|
|
} else {
|
|
"Lecteur $($resolved.Letter) non monté dans cette session et cible UNC inconnue (mappez-le ou saisissez le chemin \\serveur\partage)"
|
|
}
|
|
}
|
|
elseif ($resolved.IsNetwork) {
|
|
$msg = 'Chemin réseau inaccessible (hors ligne ou identifiants manquants)'
|
|
}
|
|
if ($reach.Error) { $msg += " : $($reach.Error)" }
|
|
[void]$scanErrors.Add([pscustomobject]@{ Path = $rawPath; Error = $msg })
|
|
continue
|
|
}
|
|
# Canonicalise the root once. Every child path is derived from this
|
|
# string, and every length compared against -MaxPathLength is measured on
|
|
# it, so an 8.3 alias here would understate how long the real paths are.
|
|
# ProviderPath, not Path: on a UNC root the latter comes back in
|
|
# provider-qualified form ("Microsoft.PowerShell.Core\FileSystem::\\srv\
|
|
# share"), which no Win32 call can open - the scan then found nothing.
|
|
$fullPath = $p
|
|
try {
|
|
$rp = Resolve-Path -LiteralPath $p -ErrorAction Stop
|
|
$cand = [string]$rp.ProviderPath
|
|
if ([string]::IsNullOrWhiteSpace($cand)) { $cand = [string]$rp.Path }
|
|
if ($cand) { $fullPath = $cand }
|
|
} catch { }
|
|
$fullPath = Expand-FilerShortPath -Path $fullPath
|
|
$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
|
|
ExcludeFolder = @($ExcludeFolder | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
ExcludeHidden = $ExcludeHidden
|
|
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)
|
|
$isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink)
|
|
$icon = if ($isLink) { '🔗' } elseif ($isFile) { '📄' } else { '📁' }
|
|
$meta = if ($isLink) { " <span class='meta'>lien (non parcouru)</span>" }
|
|
elseif ($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. ✅</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')
|
|
|
|
# Note which folders were excluded from the scan, when any exclusion is active.
|
|
$exclParts = @()
|
|
if ($Scan.Settings.ExcludeFolder -and $Scan.Settings.ExcludeFolder.Count -gt 0) {
|
|
$exclParts += ($Scan.Settings.ExcludeFolder | ForEach-Object { _enc $_ }) -join ', '
|
|
}
|
|
if ($Scan.Settings.ExcludeHidden) { $exclParts += 'dossiers masqués' }
|
|
$exclNote = if ($exclParts.Count -gt 0) { "<div class='sub' style='margin-top:6px'>Dossiers exclus : $($exclParts -join ' · ')</div>" } else { '' }
|
|
|
|
# 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>📁 Rapport Filer Manager</h1>
|
|
<div class='sub'>Généré le $gen · Hôte <b>$(_enc $Scan.Computer)</b> ·
|
|
Seuil chemin trop long $($Scan.Settings.MaxPathLength) · Profondeur des permissions $($Scan.Settings.PermissionDepth)</div>
|
|
<div class='sub' style='margin-top:6px'>Analysé : $rootsList</div>
|
|
$exclNote
|
|
$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>📊 Tailles des dossiers</h2>
|
|
<div class='tree'>$($treeSb.ToString())</div>
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantLong) { @"
|
|
<section>
|
|
<h2>⚠️ Noms de fichiers / chemins trop longs (≥ $($Scan.Settings.MaxPathLength) caractères)</h2>
|
|
<input class='filter' placeholder='Filtrer les chemins…' 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>🔐 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…)</label>
|
|
<div id='permroot'>$($permSb.ToString())</div>
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantGrants -and $Scan.Settings.GrantAccess) { @"
|
|
<section>
|
|
<h2>🔨 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. ✅</p>" })
|
|
</section>
|
|
"@ })
|
|
|
|
$(if ($wantErrors -and $Scan.Errors.Count -gt 0) { @"
|
|
<section>
|
|
<h2>❌ 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 · rapport autonome · $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' }
|
|
|
|
# 'UTF8' means "with BOM" on Windows PowerShell 5.1 but "without BOM" on
|
|
# PowerShell 7. Excel reads a BOM-less file as the ANSI code page, which turns
|
|
# every accent into mojibake (Proprietaire -> "Propriétaire"), so ask for the
|
|
# BOM explicitly on the hosts that know the name.
|
|
$csvEncoding = if ($PSVersionTable.PSVersion.Major -ge 6) { 'utf8BOM' } else { 'UTF8' }
|
|
|
|
$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 $csvEncoding -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)
|
|
$isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink)
|
|
[void]$Acc.Add([pscustomobject]@{
|
|
Racine = $Root
|
|
Chemin = $Node.FullPath
|
|
Nom = $Node.Name
|
|
Type = if ($isLink) { 'Lien' } elseif ($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()
|
|
}
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# NETWORK / DRIVE SUPPORT
|
|
#
|
|
# Scanning a "mounted network drive" cannot rely on the drive letter alone:
|
|
# a mapping belongs to the logon token that created it, so the letters mapped
|
|
# by the interactive user do not exist inside an elevated process (and the
|
|
# reverse is also true). Filer Manager needs elevation for -GrantAccess, so
|
|
# every network path is resolved to its UNC target, which is token-agnostic.
|
|
# Mappings made with New-PSDrive are worse still: they exist only inside the
|
|
# PowerShell session and are invisible to Win32/Explorer.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
function Initialize-FilerNetApi {
|
|
# Loads the P/Invoke shims used to enumerate shares, read the UNC target of a
|
|
# drive letter and open an authenticated connection. Idempotent: the type is
|
|
# added to the AppDomain once and stays visible to background runspaces.
|
|
if ('FilerManager.NetApi' -as [type]) { return }
|
|
Add-Type -TypeDefinition @"
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
|
|
namespace FilerManager {
|
|
|
|
public class ShareEntry {
|
|
public string Name;
|
|
public string Remark;
|
|
public bool IsSpecial;
|
|
public bool IsDisk;
|
|
}
|
|
|
|
// One directory entry, shaped so the PowerShell side can treat it like the
|
|
// FileSystemInfo it replaces (same Name / Attributes contract).
|
|
public class DirEntry {
|
|
public string Name;
|
|
public string FullPath;
|
|
public System.IO.FileAttributes Attributes;
|
|
public bool IsDirectory;
|
|
public bool IsReparsePoint;
|
|
public long Length;
|
|
}
|
|
|
|
// Result of one listing. Errors are returned, not thrown: the caller has to
|
|
// tell "access denied" from "gone" and PowerShell wraps every exception
|
|
// raised by a .NET call in a MethodInvocationException.
|
|
public class DirListing {
|
|
public DirEntry[] Entries;
|
|
public int ErrorCode;
|
|
public string ErrorMessage;
|
|
}
|
|
|
|
// Directory enumeration that is not bounded by MAX_PATH.
|
|
//
|
|
// This has to go straight to the Win32 API. Windows PowerShell 5.1 runs .NET
|
|
// with legacy path handling, which rejects the \\?\ prefix outright, and
|
|
// Get-ChildItem there stops at 260 characters - exactly the paths this tool
|
|
// exists to report. FindFirstFileW has no such limit.
|
|
public static class LongPath {
|
|
|
|
// Must match WIN32_FIND_DATAW byte for byte (592 bytes). Each FILETIME is
|
|
// two DWORDs and therefore 4-byte aligned: declaring one as a managed
|
|
// long would make the runtime align it on 8 bytes, pad after
|
|
// dwFileAttributes and shift every field that follows.
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
private struct FindData {
|
|
public uint dwFileAttributes;
|
|
public uint ftCreationTimeLow;
|
|
public uint ftCreationTimeHigh;
|
|
public uint ftLastAccessTimeLow;
|
|
public uint ftLastAccessTimeHigh;
|
|
public uint ftLastWriteTimeLow;
|
|
public uint ftLastWriteTimeHigh;
|
|
public uint nFileSizeHigh;
|
|
public uint nFileSizeLow;
|
|
public uint dwReserved0;
|
|
public uint dwReserved1;
|
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string cFileName;
|
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 14)] public string cAlternateFileName;
|
|
}
|
|
|
|
// Guards the layout above against a silent regression.
|
|
public static int StructSize() { return Marshal.SizeOf(typeof(FindData)); }
|
|
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
private static extern IntPtr FindFirstFileW(string lpFileName, out FindData lpFindFileData);
|
|
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
private static extern bool FindNextFileW(IntPtr hFindFile, out FindData lpFindFileData);
|
|
|
|
[DllImport("kernel32.dll", SetLastError = true)]
|
|
private static extern bool FindClose(IntPtr hFindFile);
|
|
|
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
|
private static extern uint GetLongPathNameW(string lpszShortPath, StringBuilder lpszLongPath, uint cchBuffer);
|
|
|
|
private const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
|
|
private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
|
|
private const int ERROR_NO_MORE_FILES = 18;
|
|
private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);
|
|
|
|
// Extended-length form of a path, which lifts MAX_PATH on the Win32 call.
|
|
public static string Extended(string path) {
|
|
if (string.IsNullOrEmpty(path)) return path;
|
|
if (path.StartsWith(@"\\?\")) return path;
|
|
if (path.StartsWith(@"\\")) return @"\\?\UNC\" + path.Substring(2);
|
|
return @"\\?\" + path;
|
|
}
|
|
|
|
// Inverse of Extended, so reports show the path the user typed.
|
|
public static string Trim(string path) {
|
|
if (string.IsNullOrEmpty(path)) return path;
|
|
if (path.StartsWith(@"\\?\UNC\")) return @"\\" + path.Substring(8);
|
|
if (path.StartsWith(@"\\?\")) return path.Substring(4);
|
|
return path;
|
|
}
|
|
|
|
// Expands 8.3 short components ("SEBAST~1") to their real names, keeping
|
|
// the caller's choice about the \\?\ prefix. Returns the input
|
|
// unchanged when the path cannot be resolved.
|
|
public static string ExpandShort(string path) {
|
|
if (string.IsNullOrEmpty(path)) return path;
|
|
bool wasExtended = path.StartsWith(@"\\?\");
|
|
StringBuilder sb = new StringBuilder(32768);
|
|
uint n = GetLongPathNameW(Extended(path), sb, (uint)sb.Capacity);
|
|
if (n == 0 || n > sb.Capacity) return path;
|
|
string expanded = sb.ToString();
|
|
return wasExtended ? expanded : Trim(expanded);
|
|
}
|
|
|
|
public static DirListing GetEntries(string directory) {
|
|
DirListing result = new DirListing();
|
|
result.Entries = new DirEntry[0];
|
|
|
|
if (string.IsNullOrEmpty(directory)) {
|
|
result.ErrorCode = 3; // ERROR_PATH_NOT_FOUND
|
|
result.ErrorMessage = "Chemin vide.";
|
|
return result;
|
|
}
|
|
|
|
// Children are reported under the caller's own spelling of the
|
|
// directory, never under the \\?\ form, so the prefix stays an
|
|
// implementation detail.
|
|
string basePath = Trim(directory).TrimEnd('\\');
|
|
string pattern = Extended(directory).TrimEnd('\\') + @"\*";
|
|
|
|
FindData fd;
|
|
IntPtr h = FindFirstFileW(pattern, out fd);
|
|
if (h == INVALID_HANDLE_VALUE) {
|
|
int rc = Marshal.GetLastWin32Error();
|
|
if (rc == ERROR_NO_MORE_FILES) return result; // empty directory
|
|
result.ErrorCode = rc;
|
|
result.ErrorMessage = new System.ComponentModel.Win32Exception(rc).Message;
|
|
return result;
|
|
}
|
|
|
|
List<DirEntry> list = new List<DirEntry>();
|
|
try {
|
|
do {
|
|
if (fd.cFileName == "." || fd.cFileName == "..") continue;
|
|
DirEntry e = new DirEntry();
|
|
e.Name = fd.cFileName;
|
|
e.FullPath = basePath + @"\" + fd.cFileName;
|
|
e.Attributes = (System.IO.FileAttributes)fd.dwFileAttributes;
|
|
e.IsDirectory = (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
|
e.IsReparsePoint = (fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
|
|
e.Length = e.IsDirectory ? 0L : (((long)fd.nFileSizeHigh) << 32) | (long)fd.nFileSizeLow;
|
|
list.Add(e);
|
|
} while (FindNextFileW(h, out fd));
|
|
|
|
int last = Marshal.GetLastWin32Error();
|
|
if (last != 0 && last != ERROR_NO_MORE_FILES) {
|
|
result.ErrorCode = last;
|
|
result.ErrorMessage = new System.ComponentModel.Win32Exception(last).Message;
|
|
}
|
|
}
|
|
finally { FindClose(h); }
|
|
|
|
result.Entries = list.ToArray();
|
|
return result;
|
|
}
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
internal struct ShareInfo1 {
|
|
[MarshalAs(UnmanagedType.LPWStr)] public string NetName;
|
|
public uint ShareType;
|
|
[MarshalAs(UnmanagedType.LPWStr)] public string Remark;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
internal class NetResource {
|
|
public int Scope = 2; // RESOURCE_GLOBALNET
|
|
public int ResourceType = 1; // RESOURCETYPE_DISK
|
|
public int DisplayType = 3; // RESOURCEDISPLAYTYPE_SHARE
|
|
public int Usage = 1; // RESOURCEUSAGE_CONNECTABLE
|
|
public string LocalName;
|
|
public string RemoteName;
|
|
public string Comment;
|
|
public string Provider;
|
|
}
|
|
|
|
public static class NetApi {
|
|
|
|
[DllImport("netapi32.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int NetShareEnum(string serverName, int level, out IntPtr bufPtr,
|
|
int prefMaxLen, ref int entriesRead, ref int totalEntries, ref int resumeHandle);
|
|
|
|
[DllImport("netapi32.dll")]
|
|
private static extern int NetApiBufferFree(IntPtr buffer);
|
|
|
|
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int WNetGetConnection(string localName, StringBuilder remoteName, ref int length);
|
|
|
|
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int WNetAddConnection2(NetResource netResource, string password, string username, int flags);
|
|
|
|
[DllImport("mpr.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int WNetCancelConnection2(string name, int flags, bool force);
|
|
|
|
// Shares on a server, RAP level 1: readable by any authenticated user
|
|
// (level 2 and above would require Administrator on the target).
|
|
public static ShareEntry[] EnumShares(string server) {
|
|
IntPtr buf = IntPtr.Zero;
|
|
int read = 0, total = 0, resume = 0;
|
|
List<ShareEntry> list = new List<ShareEntry>();
|
|
int rc = NetShareEnum(server, 1, out buf, -1, ref read, ref total, ref resume);
|
|
// 0 = success, 234 = ERROR_MORE_DATA (we still keep what came back).
|
|
if (rc != 0 && rc != 234) throw new System.ComponentModel.Win32Exception(rc);
|
|
try {
|
|
int size = Marshal.SizeOf(typeof(ShareInfo1));
|
|
for (int i = 0; i < read; i++) {
|
|
IntPtr p = new IntPtr(buf.ToInt64() + (i * size));
|
|
ShareInfo1 si = (ShareInfo1)Marshal.PtrToStructure(p, typeof(ShareInfo1));
|
|
ShareEntry e = new ShareEntry();
|
|
e.Name = si.NetName;
|
|
e.Remark = si.Remark;
|
|
e.IsSpecial = (si.ShareType & 0x80000000u) != 0; // STYPE_SPECIAL (ADMIN$, C$, IPC$)
|
|
e.IsDisk = (si.ShareType & 0xFFu) == 0; // STYPE_DISKTREE
|
|
list.Add(e);
|
|
}
|
|
}
|
|
finally { if (buf != IntPtr.Zero) NetApiBufferFree(buf); }
|
|
return list.ToArray();
|
|
}
|
|
|
|
// UNC target behind a mapped drive letter ("Z:"), or null when the letter
|
|
// is not a network mapping in this token.
|
|
public static string GetUncForDrive(string drive) {
|
|
StringBuilder sb = new StringBuilder(2048);
|
|
int len = sb.Capacity;
|
|
int rc = WNetGetConnection(drive, sb, ref len);
|
|
if (rc == 0) return sb.ToString();
|
|
return null;
|
|
}
|
|
|
|
// Opens a connection to a share, with or without a drive letter. Passing
|
|
// null for localName authenticates the UNC path without consuming a letter.
|
|
public static int Connect(string remoteName, string localName, string user, string password, bool persist) {
|
|
NetResource nr = new NetResource();
|
|
nr.RemoteName = remoteName;
|
|
nr.LocalName = string.IsNullOrEmpty(localName) ? null : localName;
|
|
int flags = persist ? 1 : 0; // CONNECT_UPDATE_PROFILE
|
|
return WNetAddConnection2(nr, password, user, flags);
|
|
}
|
|
|
|
public static int Disconnect(string name, bool force) {
|
|
return WNetCancelConnection2(name, 0, force);
|
|
}
|
|
}
|
|
}
|
|
"@
|
|
}
|
|
|
|
function Invoke-FilerTimed {
|
|
<# Runs a scriptblock on a throw-away runspace and gives up after
|
|
-TimeoutSeconds. Every network probe goes through this: an offline share
|
|
makes Test-Path / GetDirectories block for tens of seconds, which would
|
|
freeze the WinForms UI thread. The scriptblock must declare its own
|
|
param() block to receive -Arguments. #>
|
|
param(
|
|
[scriptblock]$Script,
|
|
[object[]]$Arguments = @(),
|
|
[int]$TimeoutSeconds = 8
|
|
)
|
|
|
|
$out = [pscustomobject]@{ Result = $null; Error = $null; TimedOut = $false }
|
|
$rs = $null; $ps = $null
|
|
try {
|
|
$rs = [runspacefactory]::CreateRunspace()
|
|
$rs.ApartmentState = 'MTA'
|
|
$rs.Open()
|
|
$ps = [powershell]::Create()
|
|
$ps.Runspace = $rs
|
|
[void]$ps.AddScript($Script.ToString())
|
|
foreach ($a in $Arguments) { [void]$ps.AddArgument($a) }
|
|
$handle = $ps.BeginInvoke()
|
|
if (-not $handle.AsyncWaitHandle.WaitOne([timespan]::FromSeconds($TimeoutSeconds))) {
|
|
# The call is stuck in a blocking Win32 wait; ask for a stop and walk
|
|
# away rather than blocking the caller on Dispose().
|
|
$out.TimedOut = $true
|
|
try { [void]$ps.BeginStop($null, $null) } catch { }
|
|
return $out
|
|
}
|
|
$out.Result = $ps.EndInvoke($handle)
|
|
if ($ps.Streams.Error.Count -gt 0) {
|
|
# GetBaseException() unwraps the "Exception calling GetDirectories..."
|
|
# noise and keeps the useful part (access denied, path not found, ...).
|
|
$out.Error = (($ps.Streams.Error | ForEach-Object { $_.Exception.GetBaseException().Message }) -join ' ; ')
|
|
}
|
|
}
|
|
catch { $out.Error = $_.Exception.Message }
|
|
finally {
|
|
if (-not $out.TimedOut) {
|
|
if ($ps) { try { $ps.Dispose() } catch { } }
|
|
if ($rs) { try { $rs.Dispose() } catch { } }
|
|
}
|
|
}
|
|
return $out
|
|
}
|
|
|
|
function Get-FilerDriveInventory {
|
|
<# Every drive the tool can reach, local and network, merged from three
|
|
sources:
|
|
- [IO.DriveInfo] : what is mounted in THIS process token,
|
|
- HKCU\Network : the user's persistent mappings, still listed when the
|
|
letter is absent from an elevated token (the hive is
|
|
shared by both tokens of the same user),
|
|
- Get-PSDrive : PowerShell-only mappings, invisible to Win32.
|
|
Network entries are never probed for readiness or free space: an offline
|
|
share would block the call for tens of seconds. #>
|
|
|
|
Initialize-FilerNetApi
|
|
$byKey = [ordered]@{}
|
|
|
|
# 1) Drives actually mounted in this process.
|
|
foreach ($d in [System.IO.DriveInfo]::GetDrives()) {
|
|
$name = $d.Name.Substring(0, 2)
|
|
$key = $name.ToUpperInvariant()
|
|
$type = switch ([string]$d.DriveType) {
|
|
'Fixed' { 'Local' }
|
|
'Network' { 'Network' }
|
|
'Removable' { 'Removable' }
|
|
'CDRom' { 'CDRom' }
|
|
'Ram' { 'RAM' }
|
|
default { 'Unknown' }
|
|
}
|
|
$entry = [ordered]@{
|
|
Name = $name; Type = $type; Unc = $null; Label = ''
|
|
Mounted = $true; Ready = $null; Persistent = $false
|
|
Source = 'Session'; FreeBytes = $null; TotalBytes = $null
|
|
}
|
|
if ($type -eq 'Network') {
|
|
try { $entry['Unc'] = [FilerManager.NetApi]::GetUncForDrive($name) } catch { }
|
|
}
|
|
else {
|
|
# Local media only: probing a network drive here can hang.
|
|
try {
|
|
$entry['Ready'] = [bool]$d.IsReady
|
|
if ($d.IsReady) {
|
|
$entry['Label'] = [string]$d.VolumeLabel
|
|
$entry['FreeBytes'] = [long]$d.AvailableFreeSpace
|
|
$entry['TotalBytes'] = [long]$d.TotalSize
|
|
}
|
|
} catch { }
|
|
}
|
|
$byKey[$key] = $entry
|
|
}
|
|
|
|
# 2) The user's persistent mappings (HKCU is shared between the elevated and
|
|
# non-elevated token of the same user, the mounted letters are not).
|
|
try {
|
|
$regRoot = 'Registry::HKEY_CURRENT_USER\Network'
|
|
if (Test-Path -LiteralPath $regRoot) {
|
|
foreach ($k in (Get-ChildItem -LiteralPath $regRoot -ErrorAction Stop)) {
|
|
$letter = ([string]$k.PSChildName).ToUpperInvariant()
|
|
if ($letter -notmatch '^[A-Z]$') { continue }
|
|
$remote = $null
|
|
try { $remote = (Get-ItemProperty -LiteralPath $k.PSPath -Name 'RemotePath' -ErrorAction Stop).RemotePath } catch { }
|
|
if ([string]::IsNullOrWhiteSpace($remote)) { continue }
|
|
$key = "${letter}:"
|
|
if ($byKey.Contains($key)) {
|
|
$e = $byKey[$key]
|
|
if (-not $e['Unc']) { $e['Unc'] = $remote }
|
|
$e['Persistent'] = $true
|
|
}
|
|
else {
|
|
$byKey[$key] = [ordered]@{
|
|
Name = $key; Type = 'Network'; Unc = $remote; Label = ''
|
|
Mounted = $false; Ready = $null; Persistent = $true
|
|
Source = 'Profil'; FreeBytes = $null; TotalBytes = $null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch { }
|
|
|
|
# 3) PowerShell-only drives rooted on a UNC path.
|
|
try {
|
|
foreach ($pd in (Get-PSDrive -PSProvider FileSystem -ErrorAction Stop)) {
|
|
$root = [string]$pd.Root
|
|
if (-not $root.StartsWith('\\')) { continue }
|
|
$key = ([string]$pd.Name + ':').ToUpperInvariant()
|
|
if ($byKey.Contains($key)) { continue }
|
|
$byKey[$key] = [ordered]@{
|
|
Name = ([string]$pd.Name + ':'); Type = 'Network'; Unc = $root.TrimEnd('\'); Label = ''
|
|
Mounted = $false; Ready = $null; Persistent = $false
|
|
Source = 'PSDrive'; FreeBytes = $null; TotalBytes = $null
|
|
}
|
|
}
|
|
} catch { }
|
|
|
|
$out = New-Object System.Collections.ArrayList
|
|
foreach ($key in $byKey.Keys) {
|
|
$e = $byKey[$key]
|
|
[void]$out.Add([pscustomobject]@{
|
|
Name = [string]$e['Name']
|
|
Root = ([string]$e['Name'] + '\')
|
|
Type = [string]$e['Type']
|
|
Unc = [string]$e['Unc']
|
|
Label = [string]$e['Label']
|
|
Mounted = [bool]$e['Mounted']
|
|
Ready = $e['Ready']
|
|
Persistent = [bool]$e['Persistent']
|
|
Source = [string]$e['Source']
|
|
FreeBytes = $e['FreeBytes']
|
|
TotalBytes = $e['TotalBytes']
|
|
IsNetwork = ([string]$e['Type'] -eq 'Network')
|
|
})
|
|
}
|
|
# Local media first, then network, each alphabetically.
|
|
return @($out | Sort-Object @{E = { [int][bool]$_.IsNetwork } }, Name)
|
|
}
|
|
|
|
function Get-FilerUncRoot {
|
|
# '\\srv\share\a\b' -> '\\srv\share' ; '\\srv' -> '\\srv' ; $null otherwise.
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
|
|
$p = $Path.Trim()
|
|
if (-not $p.StartsWith('\\')) { return $null }
|
|
$parts = @($p.Substring(2) -split '\\' | Where-Object { $_ })
|
|
if ($parts.Count -eq 0) { return $null }
|
|
if ($parts.Count -eq 1) { return ('\\' + $parts[0]) }
|
|
return ('\\' + $parts[0] + '\' + $parts[1])
|
|
}
|
|
|
|
function Resolve-FilerScanPath {
|
|
<# Normalises a user-supplied path and works out the form that is actually
|
|
scannable from this process.
|
|
|
|
A drive letter is rewritten to its UNC target when the mapping is not
|
|
mounted in the current token (the usual case for an elevated run of a
|
|
drive mapped by the interactive user), or when -PreferUnc is set. Pass
|
|
-Inventory to reuse a Get-FilerDriveInventory result across many paths. #>
|
|
param(
|
|
[string]$Path,
|
|
[switch]$PreferUnc,
|
|
$Inventory
|
|
)
|
|
|
|
$res = [pscustomobject]@{
|
|
Input = $Path; Path = $null; Unc = $null; Letter = $null
|
|
IsNetwork = $false; Mounted = $true; Rewritten = $false; Note = $null
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $res }
|
|
|
|
# Tidy up: quotes, environment variables, forward slashes, duplicate separators.
|
|
$p = $Path.Trim().Trim('"').Trim("'").Trim()
|
|
try { $p = [System.Environment]::ExpandEnvironmentVariables($p) } catch { }
|
|
if ($p.StartsWith('//')) { $p = '\\' + $p.Substring(2) }
|
|
$p = $p.Replace('/', '\')
|
|
$prefix = ''
|
|
if ($p.StartsWith('\\')) { $prefix = '\\'; $p = $p.Substring(2) }
|
|
while ($p.Contains('\\')) { $p = $p.Replace('\\', '\') }
|
|
$p = $prefix + $p
|
|
# Trailing separator is dropped, except on a drive root where it is required.
|
|
if ($p.Length -gt 3 -and $p.EndsWith('\')) { $p = $p.TrimEnd('\') }
|
|
|
|
# Extended-length syntax is passed through untouched.
|
|
if ($p.StartsWith('\\?\')) {
|
|
$res.Path = $p
|
|
$res.IsNetwork = $p.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase)
|
|
return $res
|
|
}
|
|
|
|
if ($p -match '^([A-Za-z]):($|\\)') {
|
|
$letter = $Matches[1].ToUpperInvariant() + ':'
|
|
$rest = $p.Substring(2).TrimEnd('\')
|
|
$res.Letter = $letter
|
|
if (-not $Inventory) { $Inventory = Get-FilerDriveInventory }
|
|
$drv = @($Inventory | Where-Object { $_.Name -eq $letter }) | Select-Object -First 1
|
|
if ($drv) {
|
|
$res.IsNetwork = [bool]$drv.IsNetwork
|
|
$res.Mounted = [bool]$drv.Mounted
|
|
if ($drv.Unc) { $res.Unc = ($drv.Unc.TrimEnd('\') + $rest) }
|
|
}
|
|
else {
|
|
$res.Mounted = $false
|
|
}
|
|
|
|
if ($res.Unc -and ($PreferUnc -or -not $res.Mounted)) {
|
|
$res.Path = $res.Unc
|
|
$res.Rewritten = $true
|
|
if (-not $res.Mounted) {
|
|
$res.Note = "$letter n'est pas monté dans cette session (élévation ?) ; chemin UNC utilisé : $($res.Unc)"
|
|
}
|
|
}
|
|
else {
|
|
$res.Path = $p
|
|
if ($res.IsNetwork -and -not $res.Mounted) {
|
|
$res.Note = "$letter n'est pas monté dans cette session et aucune cible UNC n'est connue."
|
|
}
|
|
}
|
|
return $res
|
|
}
|
|
|
|
if ($p.StartsWith('\\')) {
|
|
$res.IsNetwork = $true
|
|
$res.Unc = $p
|
|
$res.Path = $p
|
|
return $res
|
|
}
|
|
|
|
$res.Path = $p
|
|
return $res
|
|
}
|
|
|
|
function Test-FilerPathReachable {
|
|
<# Timeout-guarded Test-Path. Returns Exists / TimedOut / Error so the caller
|
|
can tell "not there" from "the server never answered". #>
|
|
param([string]$Path, [int]$TimeoutSeconds = 8)
|
|
|
|
$r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($Path) -Script {
|
|
param($p)
|
|
[bool](Test-Path -LiteralPath $p -PathType Container)
|
|
}
|
|
$exists = $false
|
|
if (-not $r.TimedOut -and -not $r.Error) { $exists = [bool](@($r.Result) | Select-Object -First 1) }
|
|
return [pscustomobject]@{ Exists = $exists; TimedOut = [bool]$r.TimedOut; Error = $r.Error }
|
|
}
|
|
|
|
function Get-FilerShare {
|
|
<# Disk shares published by a server, via NetShareEnum (locale-independent,
|
|
no Administrator rights needed). Falls back to WMI when the RAP call is
|
|
refused, which happens on some hardened hosts. #>
|
|
param(
|
|
[string]$Server,
|
|
[switch]$IncludeSpecial,
|
|
[int]$TimeoutSeconds = 10
|
|
)
|
|
Initialize-FilerNetApi
|
|
|
|
$srv = ([string]$Server).Trim().TrimStart('\')
|
|
$srv = @($srv -split '\\' | Where-Object { $_ })[0]
|
|
if (-not $srv) { throw 'Nom de serveur vide.' }
|
|
|
|
$r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($srv) -Script {
|
|
param($s)
|
|
[FilerManager.NetApi]::EnumShares('\\' + $s)
|
|
}
|
|
if ($r.TimedOut) { throw "Délai dépassé en interrogeant \\$srv." }
|
|
|
|
$entries = @()
|
|
if ($r.Error) {
|
|
# WMI fallback (needs rights on the target, but works where RAP is blocked).
|
|
$w = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($srv) -Script {
|
|
param($s)
|
|
Get-CimInstance -ClassName Win32_Share -ComputerName $s -ErrorAction Stop |
|
|
ForEach-Object { [pscustomobject]@{ Name = $_.Name; Remark = $_.Description; IsSpecial = ([string]$_.Name).EndsWith('$'); IsDisk = ($_.Type -eq 0) } }
|
|
}
|
|
if ($w.TimedOut -or $w.Error) { throw ("Énumération des partages impossible sur \\{0} : {1}" -f $srv, $r.Error) }
|
|
$entries = @($w.Result)
|
|
}
|
|
else {
|
|
$entries = @($r.Result)
|
|
}
|
|
|
|
$shares = @($entries | Where-Object { $_ -and $_.Name -and $_.IsDisk })
|
|
if (-not $IncludeSpecial) { $shares = @($shares | Where-Object { -not $_.IsSpecial }) }
|
|
return @($shares | ForEach-Object {
|
|
[pscustomobject]@{
|
|
Name = [string]$_.Name
|
|
Remark = [string]$_.Remark
|
|
IsSpecial = [bool]$_.IsSpecial
|
|
Path = ('\\' + $srv + '\' + $_.Name)
|
|
}
|
|
} | Sort-Object Name)
|
|
}
|
|
|
|
function Get-FilerChildFolder {
|
|
<# Timeout-guarded subfolder listing, usable on local and UNC paths alike.
|
|
Returns Folders / TimedOut / Error. #>
|
|
param([string]$Path, [int]$TimeoutSeconds = 15)
|
|
|
|
$r = Invoke-FilerTimed -TimeoutSeconds $TimeoutSeconds -Arguments @($Path) -Script {
|
|
param($p)
|
|
[System.IO.Directory]::GetDirectories($p)
|
|
}
|
|
$folders = @()
|
|
if (-not $r.TimedOut -and -not $r.Error) {
|
|
$folders = @($r.Result | Where-Object { $_ } | Sort-Object)
|
|
}
|
|
return [pscustomobject]@{ Folders = $folders; TimedOut = [bool]$r.TimedOut; Error = $r.Error }
|
|
}
|
|
|
|
function Connect-FilerShare {
|
|
<# Opens an authenticated connection to a UNC path, optionally on a drive
|
|
letter. Without a letter the credentials are simply registered for the
|
|
session, which is what a scan needs and costs no letter.
|
|
Note: WNetAddConnection2 takes the password as plain text, so it is
|
|
materialised from the PSCredential for the duration of the call only. #>
|
|
param(
|
|
[Parameter(Mandatory)] [string]$RemotePath,
|
|
[string]$DriveLetter,
|
|
[System.Management.Automation.PSCredential]$Credential,
|
|
[switch]$Persistent
|
|
)
|
|
Initialize-FilerNetApi
|
|
|
|
$remote = ([string]$RemotePath).Trim().TrimEnd('\')
|
|
if (-not $remote.StartsWith('\\')) { throw "Chemin réseau attendu (\\serveur\partage) : $RemotePath" }
|
|
|
|
$user = $null; $pass = $null
|
|
if ($Credential) {
|
|
$user = $Credential.UserName
|
|
$pass = $Credential.GetNetworkCredential().Password
|
|
}
|
|
$local = if ($DriveLetter) { ([string]$DriveLetter).Trim().TrimEnd('\') } else { $null }
|
|
|
|
$rc = [FilerManager.NetApi]::Connect($remote, $local, $user, $pass, [bool]$Persistent)
|
|
$pass = $null
|
|
if ($rc -eq 0) { return }
|
|
|
|
$msg = switch ($rc) {
|
|
5 { "Accès refusé." }
|
|
53 { "Serveur ou partage introuvable." }
|
|
67 { "Nom de partage introuvable." }
|
|
86 { "Mot de passe incorrect." }
|
|
1219 { "Une autre connexion à ce serveur existe déjà avec d'autres identifiants (fermez-la d'abord : net use /delete)." }
|
|
1326 { "Nom d'utilisateur ou mot de passe incorrect." }
|
|
default { (New-Object System.ComponentModel.Win32Exception($rc)).Message }
|
|
}
|
|
throw ("Connexion à {0} échouée : {1} (code {2})" -f $remote, $msg, $rc)
|
|
}
|
|
'@
|
|
|
|
# Dot-source core functions into the current scope (for headless + GUI export).
|
|
. ([scriptblock]::Create($CoreFunctions))
|
|
|
|
# ============================================================================
|
|
# RECOVERY MODE (-RevertPendingGrants)
|
|
# ============================================================================
|
|
if ($RevertPendingGrants) {
|
|
$journals = @(Get-FilerPendingGrantJournal)
|
|
if ($journals.Count -eq 0) {
|
|
Write-Host "Aucune modification d'accès en attente." -ForegroundColor Green
|
|
return
|
|
}
|
|
foreach ($j in $journals) {
|
|
Write-Host ("Journal {0} - {1} élément(s) ..." -f $j.File, @($j.Grants).Count) -ForegroundColor Cyan
|
|
$r = Restore-FilerPendingGrants -Journal $j
|
|
$col = if ($r.Failed -gt 0) { 'Yellow' } else { 'Green' }
|
|
Write-Host (" {0} restauré(s), {1} échec(s)" -f $r.Reverted, $r.Failed) -ForegroundColor $col
|
|
foreach ($e in $r.Errors) { Write-Host " ! $($e.Path) : $($e.Error)" -ForegroundColor Yellow }
|
|
}
|
|
return
|
|
}
|
|
|
|
# ============================================================================
|
|
# 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."
|
|
}
|
|
|
|
$stale = @(Get-FilerPendingGrantJournal)
|
|
if ($stale.Count -gt 0) {
|
|
$nStale = (@($stale | ForEach-Object { @($_.Grants).Count }) | Measure-Object -Sum).Sum
|
|
Write-Warning ("Une exécution précédente n'a pas annulé ses modifications d'accès ({0} élément(s)). Lancez : .\filer-manager.ps1 -RevertPendingGrants" -f $nStale)
|
|
}
|
|
|
|
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 `
|
|
-ExcludeFolder $ExcludeFolder -ExcludeHidden:$ExcludeHidden `
|
|
-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)
|
|
|
|
# ---- exclusions row ----
|
|
$lblExclude = New-Object System.Windows.Forms.Label
|
|
$lblExclude.Text = 'Exclure dossiers :'; $lblExclude.Location = '12,160'; $lblExclude.AutoSize = $true
|
|
$form.Controls.Add($lblExclude)
|
|
|
|
$txtExclude = New-Object System.Windows.Forms.TextBox
|
|
$txtExclude.Location = '118,157'; $txtExclude.Size = '230,24'
|
|
# Patterns are separated by ';' or ','. Wildcards (* ?) and casing are honoured.
|
|
$txtExclude.Text = ($ExcludeFolder -join '; ')
|
|
$tip = New-Object System.Windows.Forms.ToolTip
|
|
$tip.SetToolTip($txtExclude, "Noms de dossiers à ignorer, séparés par ';'. Jokers acceptés (ex. #recycle; @eaDir; Thumbs*).")
|
|
$form.Controls.Add($txtExclude)
|
|
|
|
$chkHidden = New-Object System.Windows.Forms.CheckBox
|
|
$chkHidden.Text = 'Dossiers masqués'; $chkHidden.Location = '362,159'; $chkHidden.AutoSize = $true
|
|
$chkHidden.Checked = [bool]$ExcludeHidden
|
|
$tip.SetToolTip($chkHidden, "Exclure aussi les dossiers portant l'attribut « masqué ».")
|
|
$form.Controls.Add($chkHidden)
|
|
|
|
# ---- 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,188'; $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,188'; $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,220'; $tabs.Size = '922,424'
|
|
$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é'
|
|
$tabRename = New-Object System.Windows.Forms.TabPage; $tabRename.Text = 'Renommer'
|
|
$tabs.TabPages.AddRange(@($tabTree, $tabLong, $tabPerm, $tabGrant, $tabRename))
|
|
|
|
$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 })
|
|
|
|
# ---- Renommer tab: batch rename with find/replace, regex, case, affixes, numbering ----
|
|
$rnW = 914 # baseline width used to anchor the right-aligned controls
|
|
|
|
# Preview grid (added first so the docked panels below claim their edges and the
|
|
# grid fills the remaining space - same ordering trick used by the perm filter bar).
|
|
$lvRename = New-Object System.Windows.Forms.ListView
|
|
$lvRename.Dock = 'Fill'; $lvRename.View = 'Details'; $lvRename.FullRowSelect = $true
|
|
$lvRename.GridLines = $true; $lvRename.CheckBoxes = $true
|
|
[void]$lvRename.Columns.Add('Type', 60); [void]$lvRename.Columns.Add('Ancien nom', 250)
|
|
[void]$lvRename.Columns.Add('Nouveau nom', 250); [void]$lvRename.Columns.Add('État', 130)
|
|
[void]$lvRename.Columns.Add('Dossier', 320)
|
|
$tabRename.Controls.Add($lvRename)
|
|
|
|
# Top options panel.
|
|
$rnTop = New-Object System.Windows.Forms.Panel
|
|
$rnTop.Dock = 'Top'; $rnTop.Height = 200; $rnTop.Width = $rnW
|
|
|
|
# Row 1: source folder + browse + list.
|
|
$lblRnPath = New-Object System.Windows.Forms.Label
|
|
$lblRnPath.Text = 'Dossier :'; $lblRnPath.Location = '6,13'; $lblRnPath.AutoSize = $true
|
|
$rnPath = New-Object System.Windows.Forms.TextBox
|
|
$rnPath.Location = '70,10'; $rnPath.Width = ($rnW - 70 - 188); $rnPath.Anchor = 'Top,Left,Right'
|
|
$rnBrowse = New-Object System.Windows.Forms.Button
|
|
$rnBrowse.Text = 'Parcourir...'; $rnBrowse.Size = '88,24'; $rnBrowse.Location = "$($rnW - 184),9"; $rnBrowse.Anchor = 'Top,Right'
|
|
$rnList = New-Object System.Windows.Forms.Button
|
|
$rnList.Text = 'Lister'; $rnList.Size = '88,24'; $rnList.Location = "$($rnW - 92),9"; $rnList.Anchor = 'Top,Right'
|
|
$rnTop.Controls.AddRange(@($lblRnPath, $rnPath, $rnBrowse, $rnList))
|
|
|
|
# Row 2: scope options.
|
|
$chkRnRecurse = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnRecurse.Text = 'Inclure les sous-dossiers'; $chkRnRecurse.Location = '70,40'; $chkRnRecurse.AutoSize = $true
|
|
$chkRnFiles = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnFiles.Text = 'Fichiers'; $chkRnFiles.Location = '250,40'; $chkRnFiles.AutoSize = $true; $chkRnFiles.Checked = $true
|
|
$chkRnFolders = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnFolders.Text = 'Dossiers'; $chkRnFolders.Location = '345,40'; $chkRnFolders.AutoSize = $true; $chkRnFolders.Checked = $true
|
|
$chkRnHidden = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnHidden.Text = 'Exclure les dossiers masqués'; $chkRnHidden.Location = '450,40'; $chkRnHidden.AutoSize = $true; $chkRnHidden.Checked = $true
|
|
$rnTop.Controls.AddRange(@($chkRnRecurse, $chkRnFiles, $chkRnFolders, $chkRnHidden))
|
|
|
|
# Row 3: find.
|
|
$lblRnFind = New-Object System.Windows.Forms.Label
|
|
$lblRnFind.Text = 'Rechercher :'; $lblRnFind.Location = '6,75'; $lblRnFind.AutoSize = $true
|
|
$rnFind = New-Object System.Windows.Forms.TextBox
|
|
$rnFind.Location = '90,72'; $rnFind.Width = ($rnW - 90 - 270); $rnFind.Anchor = 'Top,Left,Right'
|
|
$chkRnRegex = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnRegex.Text = 'Regex'; $chkRnRegex.Size = '70,22'; $chkRnRegex.Location = "$($rnW - 172),74"; $chkRnRegex.Anchor = 'Top,Right'
|
|
$chkRnIgnoreCase = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnIgnoreCase.Text = 'Ignorer la casse'; $chkRnIgnoreCase.Size = '130,22'; $chkRnIgnoreCase.Location = "$($rnW - 100),74"; $chkRnIgnoreCase.Anchor = 'Top,Right'
|
|
$rnTop.Controls.AddRange(@($lblRnFind, $rnFind, $chkRnRegex, $chkRnIgnoreCase))
|
|
|
|
# Row 4: replace.
|
|
$lblRnReplace = New-Object System.Windows.Forms.Label
|
|
$lblRnReplace.Text = 'Remplacer :'; $lblRnReplace.Location = '6,105'; $lblRnReplace.AutoSize = $true
|
|
$rnReplace = New-Object System.Windows.Forms.TextBox
|
|
$rnReplace.Location = '90,102'; $rnReplace.Width = ($rnW - 90 - 270); $rnReplace.Anchor = 'Top,Left,Right'
|
|
$chkRnIgnoreExt = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnIgnoreExt.Text = "Ignorer l'extension"; $chkRnIgnoreExt.Size = '160,22'; $chkRnIgnoreExt.Location = "$($rnW - 172),104"; $chkRnIgnoreExt.Anchor = 'Top,Right'; $chkRnIgnoreExt.Checked = $true
|
|
$rnTop.Controls.AddRange(@($lblRnReplace, $rnReplace, $chkRnIgnoreExt))
|
|
|
|
# Row 5: case + affixes.
|
|
$lblRnCase = New-Object System.Windows.Forms.Label
|
|
$lblRnCase.Text = 'Casse :'; $lblRnCase.Location = '6,137'; $lblRnCase.AutoSize = $true
|
|
$rnCase = New-Object System.Windows.Forms.ComboBox
|
|
$rnCase.DropDownStyle = 'DropDownList'; $rnCase.Location = '62,134'; $rnCase.Width = 150
|
|
[void]$rnCase.Items.AddRange(@('Conserver', 'MAJUSCULES', 'minuscules', 'Première Lettre Des Mots', 'Première lettre'))
|
|
$rnCase.SelectedIndex = 0
|
|
$lblRnPrefix = New-Object System.Windows.Forms.Label
|
|
$lblRnPrefix.Text = 'Préfixe :'; $lblRnPrefix.Location = '228,137'; $lblRnPrefix.AutoSize = $true
|
|
$rnPrefix = New-Object System.Windows.Forms.TextBox
|
|
$rnPrefix.Location = '282,134'; $rnPrefix.Width = 150
|
|
$lblRnSuffix = New-Object System.Windows.Forms.Label
|
|
$lblRnSuffix.Text = 'Suffixe :'; $lblRnSuffix.Location = '444,137'; $lblRnSuffix.AutoSize = $true
|
|
$rnSuffix = New-Object System.Windows.Forms.TextBox
|
|
$rnSuffix.Location = '498,134'; $rnSuffix.Width = 150
|
|
$rnTop.Controls.AddRange(@($lblRnCase, $rnCase, $lblRnPrefix, $rnPrefix, $lblRnSuffix, $rnSuffix))
|
|
|
|
# Row 6: sequential numbering + preview.
|
|
$chkRnNumber = New-Object System.Windows.Forms.CheckBox
|
|
$chkRnNumber.Text = 'Numéroter ({n})'; $chkRnNumber.Location = '6,170'; $chkRnNumber.AutoSize = $true
|
|
$lblRnStart = New-Object System.Windows.Forms.Label
|
|
$lblRnStart.Text = 'Début'; $lblRnStart.Location = '150,172'; $lblRnStart.AutoSize = $true
|
|
$numRnStart = New-Object System.Windows.Forms.NumericUpDown
|
|
$numRnStart.Location = '196,168'; $numRnStart.Width = 60; $numRnStart.Minimum = 0; $numRnStart.Maximum = 1000000; $numRnStart.Value = 1
|
|
$lblRnStep = New-Object System.Windows.Forms.Label
|
|
$lblRnStep.Text = 'Pas'; $lblRnStep.Location = '266,172'; $lblRnStep.AutoSize = $true
|
|
$numRnStep = New-Object System.Windows.Forms.NumericUpDown
|
|
$numRnStep.Location = '298,168'; $numRnStep.Width = 55; $numRnStep.Minimum = 1; $numRnStep.Maximum = 100000; $numRnStep.Value = 1
|
|
$lblRnPad = New-Object System.Windows.Forms.Label
|
|
$lblRnPad.Text = 'Chiffres'; $lblRnPad.Location = '364,172'; $lblRnPad.AutoSize = $true
|
|
$numRnPad = New-Object System.Windows.Forms.NumericUpDown
|
|
$numRnPad.Location = '418,168'; $numRnPad.Width = 50; $numRnPad.Minimum = 1; $numRnPad.Maximum = 10; $numRnPad.Value = 2
|
|
$rnPreview = New-Object System.Windows.Forms.Button
|
|
$rnPreview.Text = "Rafraîchir l'aperçu"; $rnPreview.Size = '160,26'; $rnPreview.Location = "$($rnW - 166),167"; $rnPreview.Anchor = 'Top,Right'
|
|
$rnTop.Controls.AddRange(@($chkRnNumber, $lblRnStart, $numRnStart, $lblRnStep, $numRnStep, $lblRnPad, $numRnPad, $rnPreview))
|
|
|
|
$tabRename.Controls.Add($rnTop)
|
|
|
|
# Bottom action panel.
|
|
$rnBottom = New-Object System.Windows.Forms.Panel
|
|
$rnBottom.Dock = 'Bottom'; $rnBottom.Height = 44
|
|
$rnApply = New-Object System.Windows.Forms.Button
|
|
$rnApply.Text = 'Renommer la sélection'; $rnApply.Size = '180,30'; $rnApply.Location = '6,7'; $rnApply.Enabled = $false
|
|
$rnCheckAll = New-Object System.Windows.Forms.Button
|
|
$rnCheckAll.Text = 'Tout cocher'; $rnCheckAll.Size = '110,30'; $rnCheckAll.Location = '194,7'
|
|
$rnCheckNone = New-Object System.Windows.Forms.Button
|
|
$rnCheckNone.Text = 'Tout décocher'; $rnCheckNone.Size = '110,30'; $rnCheckNone.Location = '310,7'
|
|
$lblRnStatus = New-Object System.Windows.Forms.Label
|
|
$lblRnStatus.Text = 'Choisissez un dossier puis cliquez sur Lister.'; $lblRnStatus.AutoSize = $true; $lblRnStatus.Location = '432,14'
|
|
$lblRnStatus.ForeColor = [System.Drawing.Color]::DimGray
|
|
$rnBottom.Controls.AddRange(@($rnApply, $rnCheckAll, $rnCheckNone, $lblRnStatus))
|
|
$tabRename.Controls.Add($rnBottom)
|
|
|
|
# Wire the Renommer controls (handlers reference functions defined further below).
|
|
$rnBrowse.Add_Click({
|
|
$picked = @(Show-FilerFolderPicker -Owner $form -SingleSelection `
|
|
-Description 'Sélectionnez le dossier contenant les éléments à renommer.' `
|
|
-InitialPath $rnPath.Text)
|
|
if ($picked.Count -gt 0) { $rnPath.Text = [string]$picked[0] }
|
|
})
|
|
$rnList.Add_Click({ Invoke-RenameListing })
|
|
$rnPreview.Add_Click({ Update-RenamePreview })
|
|
# Recompute the preview live as the rename options change.
|
|
$rnOptionRecalc = { Update-RenamePreview }
|
|
$rnFind.Add_TextChanged($rnOptionRecalc); $rnReplace.Add_TextChanged($rnOptionRecalc)
|
|
$rnPrefix.Add_TextChanged($rnOptionRecalc); $rnSuffix.Add_TextChanged($rnOptionRecalc)
|
|
$chkRnRegex.Add_CheckedChanged($rnOptionRecalc); $chkRnIgnoreCase.Add_CheckedChanged($rnOptionRecalc)
|
|
$chkRnIgnoreExt.Add_CheckedChanged($rnOptionRecalc); $chkRnNumber.Add_CheckedChanged($rnOptionRecalc)
|
|
$rnCase.Add_SelectedIndexChanged($rnOptionRecalc)
|
|
$numRnStart.Add_ValueChanged($rnOptionRecalc); $numRnStep.Add_ValueChanged($rnOptionRecalc); $numRnPad.Add_ValueChanged($rnOptionRecalc)
|
|
$rnCheckAll.Add_Click({ foreach ($it in $lvRename.Items) { if ($it.Tag -and $it.Tag.Changed) { $it.Checked = $true } } })
|
|
$rnCheckNone.Add_Click({ foreach ($it in $lvRename.Items) { $it.Checked = $false } })
|
|
# Only renameable rows (changed + no conflict/error) may be ticked.
|
|
$lvRename.Add_ItemCheck({
|
|
param($s, $e)
|
|
if ($e.NewValue -eq [System.Windows.Forms.CheckState]::Checked) {
|
|
$it = $lvRename.Items[$e.Index]
|
|
if (-not ($it.Tag -and $it.Tag.Changed)) { $e.NewValue = [System.Windows.Forms.CheckState]::Unchecked }
|
|
}
|
|
})
|
|
$rnApply.Add_Click({ Invoke-RenameApply })
|
|
|
|
# ---- 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
|
|
# True while a running scan may already have granted access that still needs
|
|
# reverting - checked by the FormClosing guard below.
|
|
$script:ScanGrantsPending = $false
|
|
|
|
# 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)
|
|
|
|
# Rename view state: items listed from the chosen folder and the computed plan.
|
|
$script:RenameItems = @() # source items: Name, FullPath, IsFile, Dir, Depth
|
|
$script:RenameRows = @() # computed rows: Item, New, Status, Changed
|
|
$script:RnCaseModes = @('none', 'upper', 'lower', 'title', 'first') # combobox index -> mode
|
|
|
|
# ---- 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)
|
|
$isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink)
|
|
if ($isLink) {
|
|
$text = "$($Node.Name) - lien (non parcouru)"
|
|
} elseif ($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 }
|
|
}
|
|
|
|
function Get-FilerNewName {
|
|
<# Computes the new leaf name for one item by applying, in order:
|
|
find/replace (literal or regex), a case transform, prefix/suffix and an
|
|
optional sequence number ({n}). Returns the candidate name plus a status.
|
|
The file extension is preserved untouched when $O.IgnoreExt is set. #>
|
|
param([string]$Name, [bool]$IsFile, [int]$Counter, [hashtable]$O)
|
|
|
|
$ext = ''
|
|
$base = $Name
|
|
if ($IsFile -and $O.IgnoreExt) {
|
|
$ext = [System.IO.Path]::GetExtension($Name)
|
|
$base = [System.IO.Path]::GetFileNameWithoutExtension($Name)
|
|
}
|
|
$work = $base
|
|
|
|
if ($O.Find -ne '') {
|
|
try {
|
|
if ($O.Regex) {
|
|
$opt = if ($O.IgnoreCase) { [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } else { [System.Text.RegularExpressions.RegexOptions]::None }
|
|
$work = [regex]::Replace($work, $O.Find, $O.Replace, $opt)
|
|
}
|
|
elseif ($O.IgnoreCase) {
|
|
# Literal, case-insensitive: escape the pattern, and escape any '$'
|
|
# in the replacement so it stays literal for the regex engine.
|
|
$rep = $O.Replace -replace '\$', '$$$$'
|
|
$work = [regex]::Replace($work, [regex]::Escape($O.Find), $rep, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
|
|
}
|
|
else {
|
|
$work = $work.Replace($O.Find, $O.Replace)
|
|
}
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{ NewName = $Name; Status = 'erreur regex'; Changed = $false }
|
|
}
|
|
}
|
|
|
|
switch ($O.Case) {
|
|
'upper' { $work = $work.ToUpper() }
|
|
'lower' { $work = $work.ToLower() }
|
|
'title' { $work = (Get-Culture).TextInfo.ToTitleCase($work.ToLower()) }
|
|
'first' { if ($work.Length -gt 0) { $work = $work.Substring(0, 1).ToUpper() + $work.Substring(1) } }
|
|
}
|
|
|
|
$combined = $O.Prefix + $work + $O.Suffix
|
|
if ($O.Number) {
|
|
$num = ([string]$Counter).PadLeft($O.Pad, '0')
|
|
if ($combined -match '\{n\}') { $combined = $combined -replace '\{n\}', $num }
|
|
else { $combined += $num }
|
|
}
|
|
|
|
$newName = $combined + $ext
|
|
|
|
if ([string]::IsNullOrWhiteSpace($newName)) {
|
|
return [pscustomobject]@{ NewName = $newName; Status = 'nom vide'; Changed = $false }
|
|
}
|
|
if ($newName.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars()) -ge 0) {
|
|
return [pscustomobject]@{ NewName = $newName; Status = 'caractères invalides'; Changed = $false }
|
|
}
|
|
|
|
$changed = -not $newName.Equals($Name, [System.StringComparison]::Ordinal)
|
|
return [pscustomobject]@{ NewName = $newName; Status = $(if ($changed) { 'modifié' } else { 'inchangé' }); Changed = $changed }
|
|
}
|
|
|
|
function Invoke-RenameListing {
|
|
# Walks the chosen folder (optionally recursively, honouring the same folder
|
|
# exclusions as the scan) and loads its files/folders into $script:RenameItems.
|
|
$root = $rnPath.Text.Trim()
|
|
if (-not $root) {
|
|
[System.Windows.Forms.MessageBox]::Show('Indiquez un dossier à parcourir.', 'Filer Manager', 'OK', 'Information') | Out-Null
|
|
return
|
|
}
|
|
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
|
|
[System.Windows.Forms.MessageBox]::Show("Dossier introuvable :`n$root", 'Filer Manager', 'OK', 'Warning') | Out-Null
|
|
return
|
|
}
|
|
|
|
$recurse = $chkRnRecurse.Checked
|
|
$wantFile = $chkRnFiles.Checked
|
|
$wantDir = $chkRnFolders.Checked
|
|
$exclHide = $chkRnHidden.Checked
|
|
$patterns = @($txtExclude.Text -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
|
|
$items = New-Object System.Collections.ArrayList
|
|
$form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
|
|
try {
|
|
$stack = New-Object System.Collections.Stack
|
|
$stack.Push([pscustomobject]@{ Path = $root; Depth = 0 })
|
|
while ($stack.Count -gt 0) {
|
|
$cur = $stack.Pop()
|
|
$children = @()
|
|
try { $children = Get-ChildItem -LiteralPath $cur.Path -Force -ErrorAction Stop } catch { continue }
|
|
foreach ($c in $children) {
|
|
if ($c.PSIsContainer) {
|
|
if (Test-IsExcludedFolder -Entry $c -Patterns $patterns -ExcludeHidden $exclHide) { continue }
|
|
if ($wantDir) {
|
|
[void]$items.Add([pscustomobject]@{
|
|
Name = $c.Name; FullPath = $c.FullName; IsFile = $false
|
|
Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1)
|
|
})
|
|
}
|
|
# A junction / symlink can be renamed, but descending into one
|
|
# risks an endless walk when it points back up the tree.
|
|
if ($recurse -and -not (Test-IsReparsePoint -Entry $c)) {
|
|
$stack.Push([pscustomobject]@{ Path = $c.FullName; Depth = ($cur.Depth + 1) })
|
|
}
|
|
}
|
|
elseif ($wantFile) {
|
|
[void]$items.Add([pscustomobject]@{
|
|
Name = $c.Name; FullPath = $c.FullName; IsFile = $true
|
|
Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default }
|
|
|
|
$script:RenameItems = @($items | Sort-Object @{ E = { $_.FullPath } })
|
|
Update-RenamePreview
|
|
}
|
|
|
|
function Update-RenamePreview {
|
|
# Recomputes every item's target name from the current options, flags
|
|
# duplicate / pre-existing collisions, and refreshes the preview grid.
|
|
if ($null -eq $script:RenameItems -or $script:RenameItems.Count -eq 0) {
|
|
$lvRename.Items.Clear(); $script:RenameRows = @(); $rnApply.Enabled = $false
|
|
$lblRnStatus.Text = 'Aucun élément listé. Choisissez un dossier puis cliquez sur Lister.'
|
|
return
|
|
}
|
|
|
|
$o = @{
|
|
Find = $rnFind.Text
|
|
Replace = $rnReplace.Text
|
|
Regex = $chkRnRegex.Checked
|
|
IgnoreCase = $chkRnIgnoreCase.Checked
|
|
IgnoreExt = $chkRnIgnoreExt.Checked
|
|
Case = $script:RnCaseModes[$rnCase.SelectedIndex]
|
|
Prefix = $rnPrefix.Text
|
|
Suffix = $rnSuffix.Text
|
|
Number = $chkRnNumber.Checked
|
|
Pad = [int]$numRnPad.Value
|
|
}
|
|
$start = [int]$numRnStart.Value
|
|
$step = [int]$numRnStep.Value
|
|
|
|
$rows = New-Object System.Collections.ArrayList
|
|
$i = 0
|
|
foreach ($item in $script:RenameItems) {
|
|
$r = Get-FilerNewName -Name $item.Name -IsFile $item.IsFile -Counter ($start + $i * $step) -O $o
|
|
[void]$rows.Add([pscustomobject]@{ Item = $item; New = $r.NewName; Status = $r.Status; Changed = $r.Changed })
|
|
$i++
|
|
}
|
|
|
|
# Per-directory collision checks (only matter for items that actually change).
|
|
foreach ($grp in ($rows | Group-Object { $_.Item.Dir })) {
|
|
$seen = @{}
|
|
foreach ($row in $grp.Group) {
|
|
if (-not $row.Changed) { continue }
|
|
$key = $row.New.ToLowerInvariant()
|
|
if ($seen.ContainsKey($key)) {
|
|
$row.Status = 'conflit (doublon)'; $row.Changed = $false
|
|
$seen[$key].Status = 'conflit (doublon)'; $seen[$key].Changed = $false
|
|
}
|
|
else { $seen[$key] = $row }
|
|
}
|
|
foreach ($row in $grp.Group) {
|
|
if (-not $row.Changed) { continue }
|
|
$target = Join-Path $row.Item.Dir $row.New
|
|
# Skip a case-only rename of the item onto itself (-ne is case-insensitive).
|
|
if ($target -ne $row.Item.FullPath -and (Test-Path -LiteralPath $target)) {
|
|
# OK only if the current occupant is another listed item that will move away.
|
|
$occupantMoves = $grp.Group | Where-Object {
|
|
$_.Changed -and $_.Item.Name.ToLowerInvariant() -eq $row.New.ToLowerInvariant()
|
|
}
|
|
if (-not $occupantMoves) { $row.Status = 'conflit (existe déjà)'; $row.Changed = $false }
|
|
}
|
|
}
|
|
}
|
|
|
|
$script:RenameRows = @($rows)
|
|
|
|
$lvRename.BeginUpdate(); $lvRename.Items.Clear()
|
|
foreach ($row in $rows) {
|
|
$it = New-Object System.Windows.Forms.ListViewItem($(if ($row.Item.IsFile) { 'Fichier' } else { 'Dossier' }))
|
|
[void]$it.SubItems.Add($row.Item.Name)
|
|
[void]$it.SubItems.Add($row.New)
|
|
[void]$it.SubItems.Add($row.Status)
|
|
[void]$it.SubItems.Add($row.Item.Dir)
|
|
$it.Tag = $row
|
|
if ($row.Changed) { $it.Checked = $true }
|
|
elseif ($row.Status -like 'conflit*' -or $row.Status -like 'erreur*' -or $row.Status -eq 'caractères invalides' -or $row.Status -eq 'nom vide') {
|
|
$it.ForeColor = [System.Drawing.Color]::Firebrick
|
|
}
|
|
else { $it.ForeColor = [System.Drawing.Color]::Gray } # inchangé
|
|
[void]$lvRename.Items.Add($it)
|
|
}
|
|
$lvRename.EndUpdate()
|
|
|
|
$nChange = @($rows | Where-Object { $_.Changed }).Count
|
|
$nConf = @($rows | Where-Object { $_.Status -like 'conflit*' }).Count
|
|
$rnApply.Enabled = ($nChange -gt 0)
|
|
$msg = "$($rows.Count) élément(s), $nChange à renommer"
|
|
if ($nConf -gt 0) { $msg += ", $nConf conflit(s) ignoré(s)" }
|
|
$lblRnStatus.Text = $msg
|
|
}
|
|
|
|
function Invoke-RenameApply {
|
|
# Renames every ticked, changed row. Deepest paths first so a folder is only
|
|
# renamed after its contents, keeping the captured child paths valid.
|
|
$todo = @()
|
|
foreach ($it in $lvRename.Items) {
|
|
if ($it.Checked -and $it.Tag -and $it.Tag.Changed) { $todo += $it.Tag }
|
|
}
|
|
if ($todo.Count -eq 0) {
|
|
[System.Windows.Forms.MessageBox]::Show('Aucun élément coché à renommer.', 'Filer Manager', 'OK', 'Information') | Out-Null
|
|
return
|
|
}
|
|
|
|
$confirm = [System.Windows.Forms.MessageBox]::Show(
|
|
"Renommer $($todo.Count) élément(s) ? Cette action modifie les fichiers/dossiers sur le disque.",
|
|
'Filer Manager', 'YesNo', 'Warning')
|
|
if ($confirm -ne 'Yes') { return }
|
|
|
|
$todo = @($todo | Sort-Object @{ E = { $_.Item.Depth }; Descending = $true })
|
|
$ok = 0; $failures = New-Object System.Collections.ArrayList
|
|
$form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
|
|
try {
|
|
foreach ($row in $todo) {
|
|
try {
|
|
Rename-Item -LiteralPath $row.Item.FullPath -NewName $row.New -ErrorAction Stop
|
|
$ok++
|
|
}
|
|
catch {
|
|
[void]$failures.Add("$($row.Item.Name) -> $($row.New) : $($_.Exception.Message)")
|
|
}
|
|
}
|
|
}
|
|
finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default }
|
|
|
|
if ($failures.Count -gt 0) {
|
|
$detail = (@($failures) | Select-Object -First 15) -join "`n"
|
|
if ($failures.Count -gt 15) { $detail += "`n... (+$($failures.Count - 15))" }
|
|
[System.Windows.Forms.MessageBox]::Show("$ok renommé(s), $($failures.Count) échec(s) :`n`n$detail",
|
|
'Filer Manager', 'OK', 'Warning') | Out-Null
|
|
}
|
|
else {
|
|
[System.Windows.Forms.MessageBox]::Show("$ok élément(s) renommé(s).", 'Filer Manager', 'OK', 'Information') | Out-Null
|
|
}
|
|
|
|
# Re-list from disk so the grid reflects the new names.
|
|
Invoke-RenameListing
|
|
}
|
|
|
|
# ---- 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
|
|
$script:ScanGrantsPending = $false
|
|
$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) }
|
|
}
|
|
})
|
|
|
|
# ============================================================================
|
|
# FOLDER PICKER (local drives, mapped network drives, UNC paths, shares)
|
|
#
|
|
# FolderBrowserDialog is not usable here: on Windows PowerShell 5.1 it is the
|
|
# legacy shell dialog (no path box, no way to type a UNC path) and it only ever
|
|
# shows the drive letters mounted in the current process token - so an elevated
|
|
# Filer Manager sees none of the drives the user mapped. This picker works from
|
|
# Get-FilerDriveInventory instead, browses servers share by share, accepts a
|
|
# pasted UNC path and can open an authenticated connection.
|
|
# ============================================================================
|
|
|
|
# Servers added by hand during this session, kept between two openings of the picker.
|
|
$script:FilerPickerServers = New-Object System.Collections.ArrayList
|
|
|
|
function Show-FilerInputBox {
|
|
# Minimal one-line prompt (avoids a dependency on Microsoft.VisualBasic).
|
|
param(
|
|
[System.Windows.Forms.Form]$Owner,
|
|
[string]$Title = 'Filer Manager',
|
|
[string]$Prompt = '',
|
|
[string]$Default = ''
|
|
)
|
|
$dlg = New-Object System.Windows.Forms.Form
|
|
$dlg.Text = $Title
|
|
$dlg.FormBorderStyle = 'FixedDialog'
|
|
$dlg.StartPosition = 'CenterParent'
|
|
$dlg.MinimizeBox = $false; $dlg.MaximizeBox = $false
|
|
$dlg.ClientSize = New-Object System.Drawing.Size(452, 122)
|
|
|
|
$lbl = New-Object System.Windows.Forms.Label
|
|
$lbl.Text = $Prompt; $lbl.Location = '12,12'; $lbl.Size = '428,34'
|
|
$txt = New-Object System.Windows.Forms.TextBox
|
|
$txt.Location = '12,52'; $txt.Size = '428,24'; $txt.Text = $Default
|
|
$ok = New-Object System.Windows.Forms.Button
|
|
$ok.Text = 'OK'; $ok.Size = '96,28'; $ok.Location = '246,84'
|
|
$ok.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
|
$cancel = New-Object System.Windows.Forms.Button
|
|
$cancel.Text = 'Annuler'; $cancel.Size = '96,28'; $cancel.Location = '346,84'
|
|
$cancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
|
$dlg.Controls.AddRange(@($lbl, $txt, $ok, $cancel))
|
|
$dlg.AcceptButton = $ok; $dlg.CancelButton = $cancel
|
|
|
|
$r = $dlg.ShowDialog($Owner)
|
|
$val = [string]$txt.Text
|
|
$dlg.Dispose()
|
|
if ($r -ne [System.Windows.Forms.DialogResult]::OK) { return $null }
|
|
if ([string]::IsNullOrWhiteSpace($val)) { return $null }
|
|
return $val.Trim()
|
|
}
|
|
|
|
function Show-FilerConnectDialog {
|
|
<# Opens a connection to a share, with optional credentials and an optional
|
|
drive letter. Returns the UNC path on success, $null when cancelled.
|
|
Connecting without a letter is enough for a scan and is the default: it
|
|
registers the credentials for the session without consuming a letter. #>
|
|
param(
|
|
[System.Windows.Forms.Form]$Owner,
|
|
[string]$InitialPath = '\\'
|
|
)
|
|
|
|
$dlg = New-Object System.Windows.Forms.Form
|
|
$dlg.Text = 'Connecter un partage réseau'
|
|
$dlg.FormBorderStyle = 'FixedDialog'
|
|
$dlg.StartPosition = 'CenterParent'
|
|
$dlg.MinimizeBox = $false; $dlg.MaximizeBox = $false
|
|
$dlg.ClientSize = New-Object System.Drawing.Size(470, 250)
|
|
|
|
$lblPath = New-Object System.Windows.Forms.Label
|
|
$lblPath.Text = 'Chemin réseau (\\serveur\partage) :'; $lblPath.Location = '12,12'; $lblPath.AutoSize = $true
|
|
$txtPath = New-Object System.Windows.Forms.TextBox
|
|
$txtPath.Location = '12,32'; $txtPath.Size = '446,24'; $txtPath.Text = $InitialPath
|
|
|
|
$lblUser = New-Object System.Windows.Forms.Label
|
|
$lblUser.Text = 'Utilisateur (vide = session en cours) :'; $lblUser.Location = '12,64'; $lblUser.AutoSize = $true
|
|
$txtUser = New-Object System.Windows.Forms.TextBox
|
|
$txtUser.Location = '12,84'; $txtUser.Size = '216,24'
|
|
|
|
$lblPass = New-Object System.Windows.Forms.Label
|
|
$lblPass.Text = 'Mot de passe :'; $lblPass.Location = '242,64'; $lblPass.AutoSize = $true
|
|
$txtPass = New-Object System.Windows.Forms.TextBox
|
|
$txtPass.Location = '242,84'; $txtPass.Size = '216,24'; $txtPass.UseSystemPasswordChar = $true
|
|
|
|
$lblLetter = New-Object System.Windows.Forms.Label
|
|
$lblLetter.Text = 'Lettre de lecteur :'; $lblLetter.Location = '12,118'; $lblLetter.AutoSize = $true
|
|
$cmbLetter = New-Object System.Windows.Forms.ComboBox
|
|
$cmbLetter.Location = '12,138'; $cmbLetter.Size = '216,24'; $cmbLetter.DropDownStyle = 'DropDownList'
|
|
[void]$cmbLetter.Items.Add('(aucune - authentifier seulement)')
|
|
$used = @([System.IO.DriveInfo]::GetDrives() | ForEach-Object { $_.Name.Substring(0, 1).ToUpperInvariant() })
|
|
foreach ($code in 68..90) { # D..Z
|
|
$c = [string][char]$code
|
|
if ($used -notcontains $c) { [void]$cmbLetter.Items.Add("${c}:") }
|
|
}
|
|
$cmbLetter.SelectedIndex = 0
|
|
|
|
$chkPersist = New-Object System.Windows.Forms.CheckBox
|
|
$chkPersist.Text = 'Reconnecter à l''ouverture de session'; $chkPersist.Location = '242,138'; $chkPersist.Size = '216,24'
|
|
|
|
$lblStatus = New-Object System.Windows.Forms.Label
|
|
$lblStatus.Location = '12,172'; $lblStatus.Size = '446,36'
|
|
$lblStatus.ForeColor = [System.Drawing.Color]::Firebrick
|
|
|
|
$btnOk = New-Object System.Windows.Forms.Button
|
|
$btnOk.Text = 'Connecter'; $btnOk.Size = '110,28'; $btnOk.Location = '236,212'
|
|
$btnCancel = New-Object System.Windows.Forms.Button
|
|
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '110,28'; $btnCancel.Location = '350,212'
|
|
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
|
|
|
$dlg.Controls.AddRange(@($lblPath, $txtPath, $lblUser, $txtUser, $lblPass, $txtPass,
|
|
$lblLetter, $cmbLetter, $chkPersist, $lblStatus, $btnOk, $btnCancel))
|
|
$dlg.AcceptButton = $btnOk; $dlg.CancelButton = $btnCancel
|
|
|
|
$script:FilerConnectResult = $null
|
|
$btnOk.Add_Click({
|
|
$remote = ([string]$txtPath.Text).Trim().TrimEnd('\')
|
|
if (-not $remote.StartsWith('\\') -or $remote.Length -lt 5) {
|
|
$lblStatus.Text = 'Chemin attendu sous la forme \\serveur\partage.'
|
|
return
|
|
}
|
|
$letter = $null
|
|
if ($cmbLetter.SelectedIndex -gt 0) { $letter = [string]$cmbLetter.SelectedItem }
|
|
|
|
$cred = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($txtUser.Text)) {
|
|
$sec = New-Object System.Security.SecureString
|
|
foreach ($ch in ([string]$txtPass.Text).ToCharArray()) { $sec.AppendChar($ch) }
|
|
$sec.MakeReadOnly()
|
|
$cred = New-Object System.Management.Automation.PSCredential(([string]$txtUser.Text).Trim(), $sec)
|
|
}
|
|
|
|
$lblStatus.Text = 'Connexion...'; $lblStatus.Refresh()
|
|
try {
|
|
Connect-FilerShare -RemotePath $remote -DriveLetter $letter -Credential $cred -Persistent:([bool]$chkPersist.Checked)
|
|
$script:FilerConnectResult = $remote
|
|
$dlg.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
|
$dlg.Close()
|
|
}
|
|
catch {
|
|
$lblStatus.Text = $_.Exception.Message
|
|
}
|
|
}.GetNewClosure())
|
|
|
|
[void]$dlg.ShowDialog($Owner)
|
|
$dlg.Dispose()
|
|
return $script:FilerConnectResult
|
|
}
|
|
|
|
function New-FilerPickerNode {
|
|
<# Tree node carrying its own metadata: Kind (group / drive / server / share /
|
|
folder / typed), the path to scan, and whether its children were loaded.
|
|
A dummy child makes the node expandable before anything is enumerated. #>
|
|
param(
|
|
[string]$Text,
|
|
[string]$Kind,
|
|
[string]$Path,
|
|
[switch]$Expandable
|
|
)
|
|
$n = New-Object System.Windows.Forms.TreeNode($Text)
|
|
$n.Tag = [pscustomobject]@{ Kind = $Kind; Path = $Path; Loaded = $false }
|
|
if ($Expandable) { [void]$n.Nodes.Add((New-Object System.Windows.Forms.TreeNode('...'))) }
|
|
return $n
|
|
}
|
|
|
|
function Expand-FilerPickerNode {
|
|
<# Fills a node on demand: shares for a server node, subfolders otherwise.
|
|
Every enumeration is timeout-guarded, so an offline share reports itself
|
|
instead of freezing the dialog. #>
|
|
param(
|
|
[System.Windows.Forms.TreeNode]$Node,
|
|
[System.Windows.Forms.Label]$Status
|
|
)
|
|
$tag = $Node.Tag
|
|
if (-not $tag) { return }
|
|
if ($tag.Kind -eq 'group') { return }
|
|
if ($tag.Loaded) { return }
|
|
if (-not $tag.Path) { return }
|
|
|
|
$prev = [System.Windows.Forms.Cursor]::Current
|
|
[System.Windows.Forms.Cursor]::Current = [System.Windows.Forms.Cursors]::WaitCursor
|
|
if ($Status) { $Status.Text = "Lecture de $($tag.Path)..."; $Status.Refresh() }
|
|
try {
|
|
$children = @()
|
|
$err = $null
|
|
|
|
if ($tag.Kind -eq 'server') {
|
|
try {
|
|
$children = @(Get-FilerShare -Server $tag.Path | ForEach-Object {
|
|
[pscustomobject]@{ Text = $_.Name; Path = $_.Path; Kind = 'share' }
|
|
})
|
|
}
|
|
catch { $err = $_.Exception.Message }
|
|
}
|
|
else {
|
|
$r = Get-FilerChildFolder -Path $tag.Path
|
|
if ($r.TimedOut) { $err = 'délai dépassé (serveur injoignable)' }
|
|
elseif ($r.Error) { $err = $r.Error }
|
|
else {
|
|
$children = @($r.Folders | ForEach-Object {
|
|
[pscustomobject]@{ Text = (Split-Path -Path $_ -Leaf); Path = $_; Kind = 'folder' }
|
|
})
|
|
}
|
|
}
|
|
|
|
$Node.Nodes.Clear()
|
|
foreach ($c in $children) {
|
|
[void]$Node.Nodes.Add((New-FilerPickerNode -Text $c.Text -Kind $c.Kind -Path $c.Path -Expandable))
|
|
}
|
|
# Keep the node reloadable after a failure (the share may come back, or the
|
|
# user may connect with credentials and try again).
|
|
$tag.Loaded = (-not $err)
|
|
|
|
if ($err) {
|
|
$bad = New-Object System.Windows.Forms.TreeNode("(inaccessible : $err)")
|
|
$bad.ForeColor = [System.Drawing.Color]::Firebrick
|
|
[void]$Node.Nodes.Add($bad)
|
|
if ($Status) { $Status.Text = "$($tag.Path) : $err" }
|
|
}
|
|
elseif ($Status) {
|
|
$label = if ($tag.Kind -eq 'server') { 'partage(s)' } else { 'sous-dossier(s)' }
|
|
$Status.Text = "$($tag.Path) : $($children.Count) $label."
|
|
}
|
|
}
|
|
finally { [System.Windows.Forms.Cursor]::Current = $prev }
|
|
}
|
|
|
|
function Get-FilerPickerCheckedPath {
|
|
# Paths of every ticked node, depth-first. Group nodes and the placeholder /
|
|
# error nodes (no Tag) are ignored.
|
|
param($Nodes)
|
|
$out = New-Object System.Collections.ArrayList
|
|
foreach ($n in $Nodes) {
|
|
$t = $n.Tag
|
|
if ($n.Checked -and $t -and $t.Kind -ne 'group' -and $t.Path) { [void]$out.Add([string]$t.Path) }
|
|
if ($n.Nodes.Count -gt 0) {
|
|
foreach ($c in (Get-FilerPickerCheckedPath -Nodes $n.Nodes)) { [void]$out.Add($c) }
|
|
}
|
|
}
|
|
return $out.ToArray()
|
|
}
|
|
|
|
function Get-FilerServerName {
|
|
# '\\nas\share\sub' -> '\\nas'
|
|
param([string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
|
|
$p = $Path.Trim()
|
|
if (-not $p.StartsWith('\\')) { return $null }
|
|
$first = @($p.TrimStart('\') -split '\\' | Where-Object { $_ }) | Select-Object -First 1
|
|
if (-not $first) { return $null }
|
|
return ('\\' + $first)
|
|
}
|
|
|
|
function Update-FilerPickerTree {
|
|
<# (Re)builds the two fixed branches: 'Ce PC' from the drive inventory, and
|
|
'Réseau' from the servers behind the known mappings plus those added by
|
|
hand. Network drives are listed even when the letter is missing from this
|
|
token; their UNC target is then used for browsing. #>
|
|
param(
|
|
[System.Windows.Forms.TreeView]$Tree,
|
|
[System.Windows.Forms.TreeNode]$PcNode,
|
|
[System.Windows.Forms.TreeNode]$NetNode,
|
|
[System.Windows.Forms.Label]$Status
|
|
)
|
|
|
|
$typeLabel = @{ Local = 'Local'; Network = 'Réseau'; Removable = 'Amovible'; CDRom = 'CD/DVD'; RAM = 'RAM'; Unknown = 'Inconnu' }
|
|
|
|
$Tree.BeginUpdate()
|
|
try {
|
|
$PcNode.Nodes.Clear()
|
|
$NetNode.Nodes.Clear()
|
|
|
|
$inv = @(Get-FilerDriveInventory)
|
|
$servers = New-Object System.Collections.ArrayList
|
|
|
|
foreach ($d in $inv) {
|
|
if ($d.IsNetwork) {
|
|
$path = if ($d.Mounted) { $d.Root } else { $d.Unc }
|
|
if (-not $path) { continue } # unmounted letter with no known target
|
|
$target = if ($d.Unc) { $d.Unc } else { 'cible inconnue' }
|
|
$state = if ($d.Mounted) { 'Réseau' } else { 'Réseau, non monté dans cette session' }
|
|
$text = "$($d.Name) -> $target [$state]"
|
|
[void]$PcNode.Nodes.Add((New-FilerPickerNode -Text $text -Kind 'drive' -Path $path -Expandable))
|
|
|
|
$srv = Get-FilerServerName -Path $d.Unc
|
|
if ($srv -and ($servers -notcontains $srv)) { [void]$servers.Add($srv) }
|
|
}
|
|
else {
|
|
# Skip empty optical / removable slots: they can only fail.
|
|
if (($d.Type -eq 'CDRom' -or $d.Type -eq 'Removable') -and $d.Ready -eq $false) { continue }
|
|
$bits = New-Object System.Collections.ArrayList
|
|
[void]$bits.Add([string]$typeLabel[$d.Type])
|
|
if ($d.Label) { [void]$bits.Add([string]$d.Label) }
|
|
if ($null -ne $d.FreeBytes) { [void]$bits.Add(((Format-Bytes $d.FreeBytes) + ' libres')) }
|
|
$text = "$($d.Name) [" + (($bits | Where-Object { $_ }) -join ', ') + ']'
|
|
[void]$PcNode.Nodes.Add((New-FilerPickerNode -Text $text -Kind 'drive' -Path $d.Root -Expandable))
|
|
}
|
|
}
|
|
|
|
foreach ($s in @($script:FilerPickerServers)) {
|
|
if ($s -and ($servers -notcontains $s)) { [void]$servers.Add($s) }
|
|
}
|
|
foreach ($s in ($servers | Sort-Object)) {
|
|
[void]$NetNode.Nodes.Add((New-FilerPickerNode -Text $s -Kind 'server' -Path $s -Expandable))
|
|
}
|
|
if ($NetNode.Nodes.Count -eq 0) {
|
|
$hint = New-Object System.Windows.Forms.TreeNode('(aucun serveur connu - utilisez « Ajouter un serveur... »)')
|
|
$hint.ForeColor = [System.Drawing.Color]::DimGray
|
|
[void]$NetNode.Nodes.Add($hint)
|
|
}
|
|
|
|
$PcNode.Expand()
|
|
$NetNode.Expand()
|
|
}
|
|
finally { $Tree.EndUpdate() }
|
|
if ($Status) { $Status.Text = "$($PcNode.Nodes.Count) lecteur(s), $($NetNode.Nodes.Count) serveur(s)." }
|
|
}
|
|
|
|
function Show-FilerFolderPicker {
|
|
<# Folder picker able to reach network storage. Returns the selected paths as
|
|
a string[] (empty when cancelled). With -SingleSelection the tree has no
|
|
check boxes and the highlighted node is returned. #>
|
|
param(
|
|
[System.Windows.Forms.Form]$Owner,
|
|
[string]$Description = 'Cochez le ou les dossiers à analyser, ou collez un chemin \\serveur\partage.',
|
|
[string]$InitialPath,
|
|
[switch]$SingleSelection
|
|
)
|
|
|
|
$dlg = New-Object System.Windows.Forms.Form
|
|
$dlg.Text = if ($SingleSelection) { 'Sélectionner un dossier' } else { 'Sélectionner des dossiers' }
|
|
$dlg.StartPosition = 'CenterParent'
|
|
$dlg.ClientSize = New-Object System.Drawing.Size(760, 592)
|
|
$dlg.MinimumSize = New-Object System.Drawing.Size(640, 500)
|
|
$dlg.ShowInTaskbar = $false
|
|
|
|
$lblDesc = New-Object System.Windows.Forms.Label
|
|
$lblDesc.Text = $Description; $lblDesc.Location = '12,10'; $lblDesc.AutoSize = $true
|
|
|
|
$txtPath = New-Object System.Windows.Forms.TextBox
|
|
$txtPath.Location = '12,34'; $txtPath.Size = '618,24'; $txtPath.Anchor = 'Top,Left,Right'
|
|
if ($InitialPath) { $txtPath.Text = $InitialPath }
|
|
|
|
$btnGo = New-Object System.Windows.Forms.Button
|
|
$btnGo.Text = 'Ouvrir'; $btnGo.Location = '638,33'; $btnGo.Size = '110,26'; $btnGo.Anchor = 'Top,Right'
|
|
|
|
$tree = New-Object System.Windows.Forms.TreeView
|
|
$tree.Location = '12,68'; $tree.Size = '736,436'
|
|
$tree.Anchor = 'Top,Bottom,Left,Right'
|
|
$tree.CheckBoxes = (-not $SingleSelection)
|
|
$tree.HideSelection = $false
|
|
$tree.ShowLines = $true
|
|
|
|
$btnRefresh = New-Object System.Windows.Forms.Button
|
|
$btnRefresh.Text = 'Actualiser'; $btnRefresh.Location = '12,512'; $btnRefresh.Size = '110,28'; $btnRefresh.Anchor = 'Bottom,Left'
|
|
$btnServer = New-Object System.Windows.Forms.Button
|
|
$btnServer.Text = 'Ajouter un serveur...'; $btnServer.Location = '130,512'; $btnServer.Size = '160,28'; $btnServer.Anchor = 'Bottom,Left'
|
|
$btnConnect = New-Object System.Windows.Forms.Button
|
|
$btnConnect.Text = 'Connecter un partage...'; $btnConnect.Location = '298,512'; $btnConnect.Size = '170,28'; $btnConnect.Anchor = 'Bottom,Left'
|
|
|
|
$chkUnc = New-Object System.Windows.Forms.CheckBox
|
|
$chkUnc.Text = 'Utiliser les chemins UNC pour les lecteurs réseau (recommandé en mode Administrateur)'
|
|
$chkUnc.Location = '12,544'; $chkUnc.Size = '560,22'; $chkUnc.Checked = $true; $chkUnc.Anchor = 'Bottom,Left'
|
|
|
|
$lblStatus = New-Object System.Windows.Forms.Label
|
|
$lblStatus.Location = '12,568'; $lblStatus.Size = '520,18'; $lblStatus.Anchor = 'Bottom,Left'
|
|
$lblStatus.ForeColor = [System.Drawing.Color]::DimGray
|
|
|
|
$btnOk = New-Object System.Windows.Forms.Button
|
|
$btnOk.Text = 'Valider'; $btnOk.Size = '104,30'; $btnOk.Location = '542,552'; $btnOk.Anchor = 'Bottom,Right'
|
|
$btnOk.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
|
$btnCancel = New-Object System.Windows.Forms.Button
|
|
$btnCancel.Text = 'Annuler'; $btnCancel.Size = '104,30'; $btnCancel.Location = '654,552'; $btnCancel.Anchor = 'Bottom,Right'
|
|
$btnCancel.DialogResult = [System.Windows.Forms.DialogResult]::Cancel
|
|
|
|
$dlg.Controls.AddRange(@($lblDesc, $txtPath, $btnGo, $tree, $btnRefresh, $btnServer,
|
|
$btnConnect, $chkUnc, $lblStatus, $btnOk, $btnCancel))
|
|
$dlg.CancelButton = $btnCancel # no AcceptButton: Entrée in the path box means "Ouvrir"
|
|
|
|
# ---- fixed branches ----
|
|
$nodePc = New-FilerPickerNode -Text 'Ce PC' -Kind 'group' -Path ''
|
|
$nodeNet = New-FilerPickerNode -Text 'Réseau' -Kind 'group' -Path ''
|
|
$nodeTyped = New-FilerPickerNode -Text 'Chemins saisis' -Kind 'group' -Path ''
|
|
[void]$tree.Nodes.Add($nodePc)
|
|
[void]$tree.Nodes.Add($nodeNet)
|
|
[void]$tree.Nodes.Add($nodeTyped)
|
|
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
|
|
|
|
# ---- handlers ----
|
|
$tree.Add_BeforeExpand({
|
|
param($sender, $e)
|
|
Expand-FilerPickerNode -Node $e.Node -Status $lblStatus
|
|
}.GetNewClosure())
|
|
|
|
$tree.Add_AfterSelect({
|
|
param($sender, $e)
|
|
$t = $e.Node.Tag
|
|
if ($t -and $t.Path) { $txtPath.Text = [string]$t.Path }
|
|
}.GetNewClosure())
|
|
|
|
if (-not $SingleSelection) {
|
|
$tree.Add_AfterCheck({
|
|
param($sender, $e)
|
|
$n = @(Get-FilerPickerCheckedPath -Nodes $tree.Nodes).Count
|
|
$lblStatus.Text = "$n dossier(s) coché(s)."
|
|
}.GetNewClosure())
|
|
}
|
|
|
|
# "Ouvrir": resolve what was typed/pasted, then show it in the tree. A bare
|
|
# \\server is treated as a server to browse; anything else must be an existing
|
|
# folder before it is added.
|
|
$goAction = {
|
|
$raw = [string]$txtPath.Text
|
|
if ([string]::IsNullOrWhiteSpace($raw)) { return }
|
|
|
|
$r = Resolve-FilerScanPath -Path $raw
|
|
$p = [string]$r.Path
|
|
if (-not $p) { $lblStatus.Text = 'Chemin invalide.'; return }
|
|
|
|
# \\server (no share): browse it instead of testing it as a folder.
|
|
if ($p -match '^\\\\[^\\]+\\?$') {
|
|
$srv = Get-FilerServerName -Path $p
|
|
if ($script:FilerPickerServers -notcontains $srv) { [void]$script:FilerPickerServers.Add($srv) }
|
|
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
|
|
foreach ($n in $nodeNet.Nodes) {
|
|
if ($n.Tag -and [string]$n.Tag.Path -eq $srv) { $tree.SelectedNode = $n; $n.Expand(); break }
|
|
}
|
|
return
|
|
}
|
|
|
|
$lblStatus.Text = "Vérification de $p..."; $lblStatus.Refresh()
|
|
$reach = Test-FilerPathReachable -Path $p -TimeoutSeconds 10
|
|
if (-not $reach.Exists) {
|
|
$why = if ($reach.TimedOut) { 'serveur injoignable (délai dépassé)' }
|
|
elseif ($reach.Error) { $reach.Error }
|
|
elseif ($r.Letter -and -not $r.Mounted) { "lecteur $($r.Letter) non monté dans cette session" }
|
|
else { 'introuvable' }
|
|
$lblStatus.Text = "$p : $why"
|
|
return
|
|
}
|
|
|
|
$existing = $null
|
|
foreach ($n in $nodeTyped.Nodes) {
|
|
if ($n.Tag -and ([string]$n.Tag.Path).Equals($p, [System.StringComparison]::OrdinalIgnoreCase)) { $existing = $n; break }
|
|
}
|
|
if (-not $existing) {
|
|
$existing = New-FilerPickerNode -Text $p -Kind 'typed' -Path $p -Expandable
|
|
[void]$nodeTyped.Nodes.Add($existing)
|
|
}
|
|
$nodeTyped.Expand()
|
|
$tree.SelectedNode = $existing
|
|
if ($tree.CheckBoxes) { $existing.Checked = $true }
|
|
$existing.Expand()
|
|
if ($r.Note) { $lblStatus.Text = $r.Note }
|
|
}.GetNewClosure()
|
|
|
|
$btnGo.Add_Click($goAction)
|
|
$txtPath.Add_KeyDown({
|
|
param($sender, $e)
|
|
if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Enter) {
|
|
$e.SuppressKeyPress = $true
|
|
& $goAction
|
|
}
|
|
}.GetNewClosure())
|
|
|
|
$btnRefresh.Add_Click({
|
|
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
|
|
}.GetNewClosure())
|
|
|
|
$btnServer.Add_Click({
|
|
$srvRaw = Show-FilerInputBox -Owner $dlg -Title 'Ajouter un serveur' `
|
|
-Prompt 'Nom ou adresse du serveur de fichiers (nas1, 10.0.0.5, \\nas1) :'
|
|
if (-not $srvRaw) { return }
|
|
$srv = Get-FilerServerName -Path $srvRaw
|
|
if (-not $srv) { $srv = '\\' + $srvRaw.Trim().TrimStart('\') }
|
|
if ($script:FilerPickerServers -notcontains $srv) { [void]$script:FilerPickerServers.Add($srv) }
|
|
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
|
|
foreach ($n in $nodeNet.Nodes) {
|
|
if ($n.Tag -and [string]$n.Tag.Path -eq $srv) { $tree.SelectedNode = $n; $n.Expand(); break }
|
|
}
|
|
}.GetNewClosure())
|
|
|
|
$btnConnect.Add_Click({
|
|
$seed = [string]$txtPath.Text
|
|
if (-not $seed.StartsWith('\\')) { $seed = '\\' }
|
|
$connected = Show-FilerConnectDialog -Owner $dlg -InitialPath $seed
|
|
if (-not $connected) { return }
|
|
$srv = Get-FilerServerName -Path $connected
|
|
if ($srv -and ($script:FilerPickerServers -notcontains $srv)) { [void]$script:FilerPickerServers.Add($srv) }
|
|
Update-FilerPickerTree -Tree $tree -PcNode $nodePc -NetNode $nodeNet -Status $lblStatus
|
|
$txtPath.Text = $connected
|
|
$lblStatus.Text = "Connecté à $connected."
|
|
}.GetNewClosure())
|
|
|
|
# ---- result ----
|
|
$result = $dlg.ShowDialog($Owner)
|
|
$picked = @()
|
|
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
|
|
if ($SingleSelection) {
|
|
$sel = $tree.SelectedNode
|
|
if ($sel -and $sel.Tag -and $sel.Tag.Path) { $picked = @([string]$sel.Tag.Path) }
|
|
}
|
|
else {
|
|
$picked = @(Get-FilerPickerCheckedPath -Nodes $tree.Nodes)
|
|
}
|
|
}
|
|
$preferUnc = [bool]$chkUnc.Checked
|
|
$dlg.Dispose()
|
|
|
|
if ($picked.Count -eq 0) { return @() }
|
|
|
|
# Normalise, optionally forcing the UNC form, and drop duplicates.
|
|
$inv = Get-FilerDriveInventory
|
|
$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
|
$out = New-Object System.Collections.ArrayList
|
|
foreach ($p in $picked) {
|
|
$r = Resolve-FilerScanPath -Path $p -PreferUnc:$preferUnc -Inventory $inv
|
|
if ($r.Path -and $seen.Add($r.Path)) { [void]$out.Add([string]$r.Path) }
|
|
}
|
|
return $out.ToArray()
|
|
}
|
|
|
|
# ---- events ----
|
|
$btnAdd.Add_Click({
|
|
# Custom picker: handles local drives, mapped network drives (even when the
|
|
# letter is missing from an elevated token), pasted UNC paths and shares.
|
|
$paths = @(Show-FilerFolderPicker -Owner $form)
|
|
$added = 0
|
|
foreach ($p in $paths) {
|
|
if (-not $p) { continue }
|
|
$dupe = $false
|
|
foreach ($existing in $lstFolders.Items) {
|
|
if (([string]$existing).Equals([string]$p, [System.StringComparison]::OrdinalIgnoreCase)) { $dupe = $true; break }
|
|
}
|
|
if (-not $dupe) { [void]$lstFolders.Items.Add([string]$p); $added++ }
|
|
}
|
|
if ($added -gt 0) { $statusLbl.Text = "$added dossier(s) ajouté(s)." }
|
|
})
|
|
|
|
$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
|
|
$exclude = @($txtExclude.Text -split '[;,]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
|
$exclHidden = [bool]$chkHidden.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:ScanGrantsPending = ($grant -and $revert)
|
|
$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, $Exclude, $ExclHidden, $Grant, $Revert, $Shared)
|
|
$Shared.Result = Invoke-FilerScan -Paths $Paths -MaxPathLength $MaxLen -PermissionDepth $Depth -IncludeFilesInTree $IncFiles -ExcludeFolder $Exclude -ExcludeHidden $ExclHidden -GrantAccess $Grant -RevertGrants $Revert -Progress $Shared
|
|
'@).AddArgument($paths).AddArgument($maxLen).AddArgument($depth).AddArgument($incFiles).AddArgument($exclude).AddArgument($exclHidden).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()
|
|
})
|
|
|
|
# Closing the window kills the background runspace, so a scan that granted
|
|
# access would never reach Restore-Grants. The journal written by
|
|
# Save-FilerGrantJournal makes that recoverable, but warning first is cheaper
|
|
# than recovering afterwards.
|
|
$form.Add_FormClosing({
|
|
param($s, $e)
|
|
if (-not $script:Handle -or $script:Handle.IsCompleted) { return }
|
|
$msg = if ($script:ScanGrantsPending) {
|
|
"Une analyse est en cours et elle a peut-être déjà accordé l'accès Administrators à des éléments refusés.`n`n" +
|
|
"Fermer maintenant l'interrompt AVANT l'annulation de ces modifications : les droits resteraient en place sur le disque. " +
|
|
"Ils sont journalisés et pourront être restaurés au prochain démarrage, ou avec -RevertPendingGrants.`n`n" +
|
|
"Fermer quand même ?"
|
|
} else {
|
|
"Une analyse est en cours. Fermer maintenant l'interrompt.`n`nFermer quand même ?"
|
|
}
|
|
if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -ne 'Yes') { $e.Cancel = $true }
|
|
})
|
|
|
|
# Offer to undo grants left behind by a run that never got to revert them.
|
|
$pendingJournals = @(Get-FilerPendingGrantJournal)
|
|
if ($pendingJournals.Count -gt 0) {
|
|
$nPending = (@($pendingJournals | ForEach-Object { @($_.Grants).Count }) | Measure-Object -Sum).Sum
|
|
$msg = "Une exécution précédente s'est arrêtée sans annuler ses modifications d'accès.`n`n" +
|
|
"$nPending élément(s) portent encore le propriétaire et/ou le FullControl Administrators accordés par Filer Manager.`n`n" +
|
|
'Les restaurer maintenant ?'
|
|
if ([System.Windows.Forms.MessageBox]::Show($msg, 'Filer Manager', 'YesNo', 'Warning') -eq 'Yes') {
|
|
$form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
|
|
$done = 0; $failed = 0
|
|
try {
|
|
foreach ($j in $pendingJournals) {
|
|
$r = Restore-FilerPendingGrants -Journal $j
|
|
$done += $r.Reverted; $failed += $r.Failed
|
|
}
|
|
}
|
|
finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default }
|
|
$statusLbl.Text = "Restauration : $done annulé(s), $failed échec(s)."
|
|
if ($failed -gt 0) {
|
|
[System.Windows.Forms.MessageBox]::Show(
|
|
"$done élément(s) restauré(s), $failed échec(s). Les échecs restent journalisés et seront réessayés au prochain démarrage.",
|
|
'Filer Manager', 'OK', 'Warning') | Out-Null
|
|
}
|
|
}
|
|
}
|
|
|
|
# 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()
|