Utils/stat.ps1

Register-GnuUtil @{
    Name         = 'stat'
    Category     = 'File info and disk usage'
    Synopsis     = 'Display file or file system status.'
    Usage        = @('stat [OPTION]... FILE...')
    Description  = @('Display file or file system status.')
    FileOperands = $true
    Options      = @(
        @{ Short = 'L'; Long = 'dereference'; Help = 'follow links' }
        @{ Short = 'f'; Long = 'file-system'; Help = 'display file system status instead of file status' }
        @{ Short = 'c'; Long = 'format'; Arg = 'FORMAT'; Help = 'use the specified FORMAT instead of the default; output a newline after each use of FORMAT' }
        @{ Long = 'printf'; Arg = 'FORMAT'; Help = 'like --format, but interpret backslash escapes, and do not output a mandatory trailing newline; if you want a newline, include \n in FORMAT' }
        @{ Short = 't'; Long = 'terse'; Help = 'print the information in terse form' }
    )
    Notes        = @(
        'The valid format sequences for files (without --file-system):',
        '',
        ' %a access rights in octal',
        ' %A access rights in human readable form',
        ' %b number of blocks allocated (see %B)',
        ' %B the size in bytes of each block reported by %b',
        ' %d device number in decimal',
        ' %D device number in hex',
        ' %f raw mode in hex',
        ' %F file type',
        ' %g group ID of owner',
        ' %G group name of owner',
        ' %h number of hard links',
        ' %i inode number',
        ' %n file name',
        ' %N quoted file name with dereference if symbolic link',
        ' %o optimal I/O transfer size hint',
        ' %s total size, in bytes',
        ' %t major device type in hex, for character/block device special files',
        ' %T minor device type in hex, for character/block device special files',
        ' %u user ID of owner',
        ' %U user name of owner',
        ' %w time of file birth, human-readable; - if unknown',
        ' %W time of file birth, seconds since Epoch; 0 if unknown',
        ' %x time of last access, human-readable',
        ' %X time of last access, seconds since Epoch',
        ' %y time of last data modification, human-readable',
        ' %Y time of last data modification, seconds since Epoch',
        ' %z time of last status change, human-readable',
        ' %Z time of last status change, seconds since Epoch',
        '',
        'Valid format sequences for file systems:',
        '',
        ' %a free blocks available to non-superuser',
        ' %b total data blocks in file system',
        ' %c total file nodes in file system',
        ' %d free file nodes in file system',
        ' %f free blocks in file system',
        ' %i file system ID in hex',
        ' %l maximum length of filenames',
        ' %n file name',
        ' %s block size (for faster transfers)',
        ' %S fundamental block size (for block counts)',
        ' %t file system type in hex',
        ' %T file system type in human readable form',
        '',
        'PowerShell note: .NET does not expose inode, device or link counts (shown as 0, 0',
        'and 1) nor the status change time (the modification time is used). Windows has no',
        'Unix modes: they are derived from the attributes, and the ids of owner and group',
        'follow Cygwin''s mapping (local accounts 0x30000 + RID). File systems: 4096-byte',
        'blocks, no inode counts.'
    )
}

$script:GnuStatFileSystems = @{
    ntfs = @(0x5346544e, 'ntfs'); fat32 = @(0x4d44, 'msdos'); fat = @(0x4d44, 'msdos'); exfat = @(0x2011bab0, 'exfat')
    ext2 = @(0xef53, 'ext2/ext3'); ext3 = @(0xef53, 'ext2/ext3'); ext4 = @(0xef53, 'ext2/ext3'); xfs = @(0x58465342, 'xfs')
    btrfs = @(0x9123683e, 'btrfs'); tmpfs = @(0x01021994, 'tmpfs'); apfs = @(0x1a, 'apfs'); refs = @(0x5265, 'refs')
}



function script:Get-GnuStatId {
    <#
    .SYNOPSIS
    A numeric user/group id: Unix ids, or Cygwin's mapping of a Windows SID (local accounts 0x30000 + RID).
    #>

    param([AllowEmptyString()][string]$Name, [switch]$Group)

    if ([string]::IsNullOrEmpty($Name) -or $Name -eq '-') { return '0' }
    if (-not (Test-GnuWindows)) {
        $identity = Get-GnuIdentity -User $Name
        if ($null -eq $identity) { return '0' }
        if ($Group) { return [string]$identity.GroupId }
        return [string]$identity.UserId
    }
    try { $sid = (New-Object System.Security.Principal.NTAccount -ArgumentList $Name).Translate([System.Security.Principal.SecurityIdentifier]).Value }
    catch { return '0' }
    $parts = $sid.Split('-')
    $rid = [long]$parts[$parts.Length - 1]
    if ($sid.StartsWith('S-1-5-21-', [System.StringComparison]::Ordinal)) { return [string](0x30000 + $rid) }
    [string]$rid
}

function script:New-GnuStatTable {
    <#
    .SYNOPSIS
    A case-sensitive letter -> value table from alternating keys and values.
    #>

    param([Parameter(Mandatory)][AllowEmptyString()][string[]]$Pairs)
    $table = New-Object 'System.Collections.Generic.Dictionary[string,string]' -ArgumentList ([System.StringComparer]::Ordinal)
    for ($i = 0; $i -lt $Pairs.Length; $i += 2) { $table[$Pairs[$i]] = $Pairs[$i + 1] }
    , $table
}

function script:Get-GnuStatInfo {
    <#
    .SYNOPSIS
    The status fields of one file as a hashtable of format letter -> value.
    #>

    param([Parameter(Mandatory)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory)][string]$Name)

    $isLink = $null -ne (Get-GnuLinkTarget -Path $Item.FullName)
    $isDirectory = $Item -is [System.IO.DirectoryInfo]
    $size = [long]0
    if (-not $isDirectory) { $size = [long]$Item.Length }
    $modeString = Get-GnuLsMode -Item $Item
    $bits = Get-GnuUnixFileMode -Path $Item.FullName
    if ($null -eq $bits) {
        $bits = 0
        $permissions = $modeString.Substring(1)
        for ($i = 0; $i -lt 9; $i++) { if ($permissions[$i] -ne '-') { $bits = $bits -bor (1 -shl (8 - $i)) } }
    }
    $typeBits = 0x8000
    $type = 'regular file'
    if ($size -eq 0) { $type = 'regular empty file' }
    $unixMode = Get-GnuUnixModeString -Path $Item.FullName
    if ($isLink) { $type = 'symbolic link'; $typeBits = 0xA000; $modeString = 'l' + $modeString.Substring(1) }
    elseif ($isDirectory) { $type = 'directory'; $typeBits = 0x4000 }
    elseif ($null -ne $unixMode -and $unixMode.Length -gt 0) {
        switch -CaseSensitive ([string]$unixMode[0]) {
            'c' { $type = 'character special file'; $typeBits = 0x2000 }
            'b' { $type = 'block special file'; $typeBits = 0x6000 }
            'p' { $type = 'fifo'; $typeBits = 0x1000 }
            's' { $type = 'socket'; $typeBits = 0xC000 }
        }
        $modeString = [string]$unixMode[0] + $modeString.Substring(1)
    }
    $owner = Get-GnuLsOwner -Item $Item
    $quoted = "'" + $Name.Replace("'", "'\''") + "'"
    if ($isLink) { $quoted += " -> '" + (Get-GnuLinkTarget -Path $Item.FullName).Replace("'", "'\''") + "'" }
    $times = New-Object 'System.Collections.Generic.Dictionary[string,string]' -ArgumentList ([System.StringComparer]::Ordinal)
    foreach ($entry in @(@('x', $Item.LastAccessTimeUtc), @('y', $Item.LastWriteTimeUtc), @('z', $Item.LastWriteTimeUtc), @('w', $Item.CreationTimeUtc))) {
        $time = [System.DateTimeOffset]::new([datetime]::SpecifyKind($entry[1], [System.DateTimeKind]::Utc)).ToLocalTime()
        $times[$entry[0]] = Format-GnuDate -Time $time -Format '%Y-%m-%d %H:%M:%S.%N %z'
        $times[$entry[0].ToUpperInvariant()] = [string]$time.ToUnixTimeSeconds()
    }
    $blocks = [long]0
    if ($size -gt 0) { $blocks = [long][Math]::Ceiling($size / 4096) * 8 }
    # Format letters are case-sensitive (%a / %A), so no hashtable literal.
    $values = New-GnuStatTable -Pairs @(
        'a', [System.Convert]::ToString($bits, 8), 'A', $modeString, 'b', [string]$blocks, 'B', '512', 'd', '0', 'D', '0',
        'f', [System.Convert]::ToString($typeBits -bor $bits, 16), 'F', $type, 'g', (Get-GnuStatId -Name $owner.Group -Group), 'G', $owner.Group,
        'h', '1', 'i', '0', 'n', $Name, 'N', $quoted, 'o', '4096', 's', [string]$size, 't', '0', 'T', '0',
        'u', (Get-GnuStatId -Name $owner.Owner), 'U', $owner.Owner)
    foreach ($key in @($times.Keys)) { $values[[string]$key] = $times[$key] }
    , $values
}

function script:Get-GnuStatDrive {
    <#
    .SYNOPSIS
    The mounted drives (mockable in tests).
    #>

    [System.IO.DriveInfo]::GetDrives()
}

function script:Get-GnuStatFileSystemInfo {
    <#
    .SYNOPSIS
    The file system fields (--file-system) of the drive that holds a path.
    #>

    param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Name)

    $root = [System.IO.Path]::GetPathRoot($Path)
    $best = $null
    foreach ($drive in @(Get-GnuStatDrive)) {
        if (-not $drive.IsReady) { continue }
        $mount = $drive.RootDirectory.FullName
        if ($Path.StartsWith($mount, [System.StringComparison]::OrdinalIgnoreCase) -and ($null -eq $best -or $mount.Length -gt $best.RootDirectory.FullName.Length)) { $best = $drive }
    }
    if ($null -eq $best) { $best = New-Object System.IO.DriveInfo -ArgumentList $root }
    $format = [string]$best.DriveFormat
    $known = $script:GnuStatFileSystems[$format.ToLowerInvariant()]
    $hex = '0'
    $typeName = $format.ToLowerInvariant()
    if ($null -ne $known) { $hex = [System.Convert]::ToString([long]$known[0], 16); $typeName = $known[1] }
    $id = [System.Convert]::ToString([long]($best.RootDirectory.FullName.GetHashCode() -band 0x7fffffff), 16)
    , (New-GnuStatTable -Pairs @(
            'n', $Name, 't', $hex, 'T', $typeName, 's', '4096', 'S', '4096', 'b', [string][long][Math]::Floor($best.TotalSize / 4096),
            'f', [string][long][Math]::Floor($best.TotalFreeSpace / 4096), 'a', [string][long][Math]::Floor($best.AvailableFreeSpace / 4096),
            'c', '?', 'd', '?', 'i', $id, 'l', '255'))
}

function script:Format-GnuStatText {
    <#
    .SYNOPSIS
    Expands %-sequences (with printf flags, width and precision) from a value table; unknown ones print '?'.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Values', Justification = 'Used inside the MatchEvaluator script block.')]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Format, [Parameter(Mandatory)][System.Collections.Generic.Dictionary[string, string]]$Values)

    $numeric = 'abBdDfghioOstTuXYZW'
    [regex]::Replace($Format, "%([-+ #0']*)(\d*)(?:\.(\d*))?(.?)", [System.Text.RegularExpressions.MatchEvaluator] {
            param($m)
            $letter = $m.Groups[4].Value
            if ($letter -eq '%') { return '%' }
            if ($letter.Length -eq 0) { return '%' }
            $value = '?'
            if ($Values.ContainsKey($letter)) { $value = $Values[$letter] }
            $flags = $m.Groups[1].Value
            $width = 0
            if ($m.Groups[2].Value.Length -gt 0) { $width = [int]$m.Groups[2].Value }
            if ($m.Groups[3].Success -and $numeric.IndexOf($letter, [System.StringComparison]::Ordinal) -lt 0) {
                $precision = 0
                if ($m.Groups[3].Value.Length -gt 0) { $precision = [int]$m.Groups[3].Value }
                if ($value.Length -gt $precision) { $value = $value.Substring(0, $precision) }
            }
            if ($value.Length -ge $width) { return $value }
            if ($flags.Contains('-')) { return $value.PadRight($width) }
            if ($flags.Contains('0') -and $value -match '^-?\d+$') { return $value.PadLeft($width, '0') }
            $value.PadLeft($width)
        })
}

function script:Invoke-GnuStatFile {
    <#
    .SYNOPSIS
    Implementation of stat.
    #>

    param([Parameter(Mandatory)][hashtable]$Context)

    $operands = @($Context.Operands)
    if ($operands.Count -eq 0) { Write-GnuUsageError -Context $Context -Message 'missing operand'; return }
    $fileSystem = Test-GnuOption -Context $Context -Key 'file-system'
    $terse = Test-GnuOption -Context $Context -Key 'terse'
    $format = $null
    $newline = $true
    if (Test-GnuOption -Context $Context -Key 'format') { $format = [string](Get-GnuOptionValue -Context $Context -Key 'format') }
    if (Test-GnuOption -Context $Context -Key 'printf') {
        $escaped = ConvertFrom-GnuPrintfEscape -Context $Context -Text ([string](Get-GnuOptionValue -Context $Context -Key 'printf')) -Start 0 -All
        $format = $escaped.Text
        $newline = $false
    }
    $builder = New-Object System.Text.StringBuilder
    foreach ($name in $operands) {
        $path = Resolve-GnuPath -Path $name
        if ($fileSystem) {
            if (-not (Test-Path -LiteralPath $path)) { Write-GnuError -Context $Context -Message "cannot read file system information for '$name': No such file or directory"; continue }
            $values = Get-GnuStatFileSystemInfo -Path ([System.IO.Path]::GetFullPath($path)) -Name $name
            $current = $format
            if ($null -eq $current) {
                if ($terse) { $current = '%n %i %l %t %s %S %b %f %a %c %d' }
                else { $current = " File: `"%n`"`n ID: %-8i Namelen: %-7l Type: %T`nBlock size: %-10s Fundamental block size: %S`nBlocks: Total: %-10b Free: %-10f Available: %a`nInodes: Total: %-10c Free: %d" }
            }
        }
        else {
            $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
            if ($null -eq $item) { Write-GnuError -Context $Context -Message "cannot stat '$name': No such file or directory"; continue }
            if ((Test-GnuOption -Context $Context -Key 'dereference') -and $null -ne (Get-GnuLinkTarget -Path $item.FullName)) {
                $target = Resolve-GnuPhysicalPath -Path $item.FullName
                $item = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue
                if ($null -eq $item) { Write-GnuError -Context $Context -Message "cannot stat '$name': No such file or directory"; continue }
            }
            $values = Get-GnuStatInfo -Item $item -Name $name
            $current = $format
            if ($null -eq $current) {
                if ($terse) { $current = '%n %s %b %f %u %g %D %i %h %t %T %X %Y %Z %W %o' }
                else {
                    $file = $name
                    if ($name -match "[\s'`"\\$]") { $file = "'" + $name.Replace("'", "'\''") + "'" }
                    if ($values['F'] -eq 'symbolic link') { $file = $values['N'] }
                    $current = " File: $($file.Replace('%', '%%'))`n Size: %-10s`tBlocks: %-10b IO Block: %-6o %F`nDevice: %Dh/%dd`tInode: %-10i Links: %h`nAccess: (%04a/%10.10A) Uid: (%5u/%8U) Gid: (%5g/%8G)`nAccess: %x`nModify: %y`nChange: %z`n Birth: %w"
                }
            }
        }
        [void]$builder.Append((Format-GnuStatText -Format $current -Values $values))
        if ($newline) { [void]$builder.Append("`n") }
    }
    if ($builder.Length -gt 0) { ConvertTo-GnuOutputLine -Text $builder.ToString() }
}