Compare commits

...
2 Commits
Author SHA1 Message Date
kawa 08ee699e44 Merge pull request 'fix: jonctions, annulation des ACL, encodage CSV et chemins longs' (#1) from fix/jonctions-acl-csv-chemins-longs into main
Reviewed-on: #1
2026-08-21 12:40:07 +02:00
kawaandClaude Opus 5 8a6657f496 fix: jonctions, annulation des ACL, encodage CSV et chemins longs
Cinq correctifs sur le coeur du scan, plus deux problemes decouverts en les
validant. Verifie sur PowerShell 7.6.5 et Windows PowerShell 5.1 : 43
assertions, aucun echec.

Jonctions et liens symboliques (Test-IsReparsePoint)
    Le parcours descendait dans les points de reparse. Une jonction pointant
    vers un ancetre relancait la recursion jusqu'a la limite de longueur de
    chemin ; une jonction laterale comptait deux fois les memes octets. Sur un
    arbre de test de 3 004 octets reels, le rapport annoncait 310,92 Ko, 320
    dossiers, 191 fichiers et 350 faux « chemins trop longs » -- des donnees
    fausses mais parfaitement plausibles. Les liens sont desormais listes comme
    feuilles de taille nulle (type « Lien ») et jamais parcourus, dans le calcul
    des tailles, la lecture des permissions et l'onglet Renommer.

Annulation des acces accordes (journal + garde-fous)
    Fermer la fenetre pendant un scan -GrantAccess tuait le runspace avant
    Restore-Grants : Administrators restait proprietaire avec FullControl, sans
    aucune trace. Chaque octroi est maintenant journalise sous
    %LOCALAPPDATA%\FilerManager avant d'etre applique, FormClosing avertit
    quand un scan est en cours, et les journaux orphelins sont proposes a la
    restauration au demarrage ou rejoues par -RevertPendingGrants. Un journal
    dont le process est encore vivant est ignore.

Encodage CSV sous PowerShell 7
    -Encoding UTF8 ecrit un BOM en 5.1 mais pas en 7, et Excel lit un fichier
    sans BOM comme du CP1252, ce qui transforme chaque accent des en-tetes en
    mojibake. Utilise utf8BOM la ou le nom existe.

Enumeration independante de LongPathsEnabled (FilerManager.LongPath)
    Le parcours passe par FindFirstFileW au lieu de Get-ChildItem, dont la
    limite a 260 caracteres depend du reglage registre LongPathsEnabled, absent
    par defaut sous Windows -- alors que signaler les chemins longs est l'objet
    meme de l'outil. Non reproduit sur la machine de developpement, ou ce
    reglage est actif : durcissement, pas correctif observe. Les erreurs sont
    renvoyees en codes Win32 plutot que levees, ce qui supprime le depaquetage
    d'exceptions dans le chemin chaud.

Noms courts 8.3 (Expand-FilerShortPath)
    La racine etait rapportee telle que saisie alors que Get-ChildItem
    canonisait les enfants. Sur une racine 8.3, les longueurs etaient donc
    mesurees sur un alias plus court que le chemin reel, sous-estimant le
    critere « chemin trop long ». La racine est canonisee une fois via
    GetLongPathNameW.

Detection des acces refuses (Test-IsAccessDenied)
    Inspecte aussi l'exception de base : un appel .NET arrive enveloppe dans
    MethodInvocationException, et 5.1 remonte parfois un Win32Exception nu
    portant ERROR_ACCESS_DENIED.

Resolution des SID (Resolve-SidName)
    Lit proprietaire et ACE en SID bruts puis traduit chaque nom separement,
    avec cache, en remplacement d'un aller-retour LSA/AD non mis en cache par
    ACE. Sortie identique ligne pour ligne a la precedente sur un arbre sans
    jonction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 12:37:46 +02:00
+526 -33
View File
@@ -83,6 +83,13 @@
.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
@@ -121,6 +128,7 @@ param(
[switch]$GrantAccess,
[switch]$KeepGrants,
[switch]$NoGui,
[switch]$RevertPendingGrants,
[ValidateSet('All', 'Tree', 'LongPaths', 'Permissions', 'Grants', 'Errors')]
[string[]]$Category = @('All')
)
@@ -154,6 +162,14 @@ function Test-IsAccessDenied {
$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
}
@@ -171,6 +187,29 @@ function Resolve-PrincipalSid {
}
}
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-
@@ -267,6 +306,10 @@ function Grant-AdminAccess {
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" })
}
@@ -314,6 +357,113 @@ function Restore-Grants {
$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 {
@@ -337,6 +487,71 @@ function Test-IsExcludedFolder {
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. #>
@@ -371,27 +586,37 @@ function Get-FolderNode {
$entries = $null
$granted = $false
while ($true) {
try {
$entries = Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop
break
}
catch {
if ($GrantAccessFlag -and -not $granted -and (Test-IsAccessDenied $_)) {
$granted = $true
if ($Progress) { $Progress.Status = "Octroi de l'accès : $Path" }
if (Grant-AdminAccess -Path $Path -Reason 'énumérer le dossier' -Grants $GrantList -ScanErrors $ScanErrors) {
continue # retry once, now that access has been granted
}
$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 = $_.Exception.Message })
return [pscustomobject]$node
}
[void]$ScanErrors.Add([pscustomobject]@{ Path = $Path; Error = [string]$listing.ErrorMessage })
return [pscustomobject]$node
}
foreach ($entry in $entries) {
if ($entry.PSIsContainer) {
if ($entry.IsDirectory) {
if (Test-IsExcludedFolder -Entry $entry -Patterns $ExcludeFolderFlag -ExcludeHidden $ExcludeHiddenFlag) { continue }
$child = Get-FolderNode -Path $entry.FullName -MaxLen $MaxLen `
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
@@ -400,16 +625,16 @@ function Get-FolderNode {
[void]$node.Children.Add($child)
}
else {
$len = 0
try { $len = [long]$entry.Length } catch { $len = 0 }
$len = [long]0
try { $len = [long]$entry.Length } catch { $len = [long]0 }
$node.Size += $len
$node.FileCount += 1
if ($entry.FullName.Length -ge $MaxLen) {
[void]$LongPaths.Add([pscustomobject]@{ Type = 'Fichier'; Length = $entry.FullName.Length; Path = $entry.FullName })
if ($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.FullName; Size = $len
Name = $entry.Name; FullPath = $entry.FullPath; Size = $len
FileCount = 0; FolderCount = 0; Depth = $Depth + 1; IsFile = $true
Children = (New-Object System.Collections.ArrayList)
})
@@ -432,12 +657,19 @@ function Get-FolderPermissions {
while ($true) {
try {
$acl = Get-Acl -LiteralPath $Path -ErrorAction Stop
foreach ($ace in $acl.Access) {
# 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 = $acl.Owner
Identity = [string]$ace.IdentityReference
Sid = Resolve-PrincipalSid $ace.IdentityReference
Owner = $owner
Identity = (Resolve-SidName $ace.IdentityReference)
Sid = [string]$ace.IdentityReference
Rights = [string]$ace.FileSystemRights
Type = [string]$ace.AccessControlType
Inherited = $ace.IsInherited
@@ -458,11 +690,13 @@ function Get-FolderPermissions {
}
if ($Current -lt $Depth) {
$subDirs = $null
try { $subDirs = Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction Stop } catch { $subDirs = @() }
$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 }
$child = Get-FolderPermissions -Path $d.FullName -Depth $Depth -ScanErrors $ScanErrors -Current ($Current + 1)
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) }
}
}
@@ -488,6 +722,19 @@ function Invoke-FilerScan {
$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
@@ -532,8 +779,12 @@ function Invoke-FilerScan {
[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.
$fullPath = $p
try { $fullPath = (Resolve-Path -LiteralPath $p).Path } catch { }
$fullPath = Expand-FilerShortPath -Path $fullPath
$node = Get-FolderNode -Path $fullPath -MaxLen $MaxPathLength `
-LongPaths $longPaths -ScanErrors $scanErrors -Progress $Progress
[void]$roots.Add($node)
@@ -640,8 +891,11 @@ function ConvertTo-FilerHtmlReport {
$pct = if ($ParentSize -gt 0) { [math]::Round(($Node.Size / $ParentSize) * 100, 1) } else { 100 }
$sizeStr = Format-Bytes $Node.Size
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
$icon = if ($isFile) { '&#128196;' } else { '&#128193;' }
$meta = if ($isFile) { '' } else { " <span class='meta'>$($Node.FolderCount) dossiers, $($Node.FileCount) fichiers</span>" }
$isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink)
$icon = if ($isLink) { '&#128279;' } elseif ($isFile) { '&#128196;' } else { '&#128193;' }
$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>" +
@@ -967,6 +1221,12 @@ function ConvertTo-FilerCsvReport {
$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)
@@ -974,7 +1234,7 @@ function ConvertTo-FilerCsvReport {
if ($rows.Count -eq 0) { return } # don't emit empty files
$name = "$base-$Suffix.csv"
$p = if ([string]::IsNullOrEmpty($dir)) { $name } else { Join-Path $dir $name }
$rows | Export-Csv -LiteralPath $p -NoTypeInformation -Encoding UTF8 -Delimiter $delim
$rows | Export-Csv -LiteralPath $p -NoTypeInformation -Encoding $csvEncoding -Delimiter $delim
[void]$written.Add($p)
}
@@ -984,11 +1244,12 @@ function ConvertTo-FilerCsvReport {
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 ($isFile) { 'Fichier' } else { 'Dossier' }
Type = if ($isLink) { 'Lien' } elseif ($isFile) { 'Fichier' } else { 'Dossier' }
Profondeur = $Node.Depth
TailleOctets = [long]$Node.Size
Taille = Format-Bytes $Node.Size
@@ -1087,6 +1348,157 @@ namespace FilerManager {
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;
@@ -1553,6 +1965,25 @@ function Connect-FilerShare {
# 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
# ============================================================================
@@ -1565,6 +1996,12 @@ if ($runHeadless) {
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 `
@@ -1983,6 +2420,9 @@ $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
@@ -2018,7 +2458,10 @@ function New-FilerTreeNode {
param($Node)
$sizeStr = Format-Bytes $Node.Size
$isFile = ($Node.PSObject.Properties.Name -contains 'IsFile' -and $Node.IsFile)
if ($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)"
@@ -2413,7 +2856,11 @@ function Invoke-RenameListing {
Dir = (Split-Path -LiteralPath $c.FullName -Parent); Depth = ($cur.Depth + 1)
})
}
if ($recurse) { $stack.Push([pscustomobject]@{ Path = $c.FullName; 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]@{
@@ -2585,6 +3032,7 @@ $timer.Add_Tick({
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
}
@@ -3204,6 +3652,7 @@ $btnScan.Add_Click({
$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'
@@ -3292,6 +3741,50 @@ catch { $Shared.Error = $_.Exception.Message }
$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) } }