diff --git a/filer-manager.ps1 b/filer-manager.ps1
index cb94741..05e5e20 100644
--- a/filer-manager.ps1
+++ b/filer-manager.ps1
@@ -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) { '📄' } else { '📁' }
- $meta = if ($isFile) { '' } else { " $($Node.FolderCount) dossiers, $($Node.FileCount) fichiers" }
+ $isLink = ($Node.PSObject.Properties.Name -contains 'IsLink' -and $Node.IsLink)
+ $icon = if ($isLink) { '🔗' } elseif ($isFile) { '📄' } else { '📁' }
+ $meta = if ($isLink) { " lien (non parcouru)" }
+ elseif ($isFile) { '' }
+ else { " $($Node.FolderCount) dossiers, $($Node.FileCount) fichiers" }
$kids = @($Node.Children | Where-Object { $_ } )
$summary = "$icon $(_enc $Node.Name) $sizeStr" +
@@ -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 list = new List();
+ 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) } }