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
This commit was merged in pull request #1.
This commit is contained in:
+526
-33
@@ -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 { " <span class='meta'>$($Node.FolderCount) dossiers, $($Node.FileCount) fichiers</span>" }
|
||||
$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>" +
|
||||
@@ -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) } }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user