Thursday, May 15, 2008

PowerShell FindFirstFileW bypassing MAX_PATH

By default it seems PowerShell uses the ANSI versions of FindFirstFile and FindNextFile, and is therefore limited to MAX_PATH - 260 characters in total. This PowerShell script uses in-line compiled VB.Net to call the wide unicode versions - FindFirstFileW and FindNextFileW - to bypass the ANSI MAX_PATH limitations. The results are essentially the same as a 'dir /s/b/a-d' that won't return with 'The filename or extension is too long.' or 'The directory name x is too long' errors.

If you use '/l', the script will only return deep paths, and also provide the 8.3 equivalent to the deep path - a useful method to access these files. For example, using this method, you can access a file with UNC (eg \\server\share) over 18 levels deep ((260-16)/13).

Note that these wide calls only bypass MAX_PATH when using a mapped drive with the \\?\ prefix to disable path parsing. Just specify a mapped drive or local path normally, eg c:\temp, the script will automatically prepend \\?\

I've been experimenting with using PowerShell to dynamically compile VB.Net or C# code, and within that managed code, calling unmanaged platform invoke operations to get to APIs. I like the flexibility of using a scripting language rather than compiled code, and while this certainly isn't as functional as 'dir', it was useful to me when at least trying to get a list of deep files.


## FindFiles.ps1 ##
param(
   [string] $dirRoot = $pwd,
   [string] $Spec = "*.*",
   [bool] $longOnly = $false
   )

# Changes:
#  23/05/2008, Wayne Martin, Added the option to only report +max_path entries, and report the short path of those directories (which makes it easier to access them)
#
#
# Description:
#  Use the wide unicode versions to report a directory listing of all files, including those that exceed the MAX_PATH ANSI limitations
#
# Assumptions, this script works on the assumption that:
#  There's a console to write the output from the compiled VB.Net
#
# Author:
#  Wayne Martin, 15/05/2008
#
# Usage
#  PowerShell . .\FindFiles.ps1 -d c:\temp -s *.*
#
#  PowerShell . .\FindFiles.ps1 -d c:\temp
#
#  PowerShell . .\FindFiles.ps1 -d g: -l $true
#
# References:
#  http://msdn.microsoft.com/en-us/library/aa364418(VS.85).aspx
#  http://blogs.msdn.com/jaredpar/archive/2008/03/14/making-pinvoke-easy.aspx 

$provider = new-object Microsoft.VisualBasic.VBCodeProvider
$params = new-object System.CodeDom.Compiler.CompilerParameters
$params.GenerateInMemory = $True
$refs = "System.dll","Microsoft.VisualBasic.dll"
$params.ReferencedAssemblies.AddRange($refs)

$txtCode = @'
Imports System
Imports System.Runtime.InteropServices
Class FindFiles

Const ERROR_SUCCESS As Long = 0
Private Const MAX_PREFERRED_LENGTH As Long = -1

  _
Public Structure WIN32_FIND_DATAW
    '''DWORD->unsigned int
    Public dwFileAttributes As UInteger
    '''FILETIME->_FILETIME
    Public ftCreationTime As FILETIME
    '''FILETIME->_FILETIME
    Public ftLastAccessTime As FILETIME
    '''FILETIME->_FILETIME
    Public ftLastWriteTime As FILETIME
    '''DWORD->unsigned int
    Public nFileSizeHigh As UInteger
    '''DWORD->unsigned int
    Public nFileSizeLow As UInteger
    '''DWORD->unsigned int
    Public dwReserved0 As UInteger
    '''DWORD->unsigned int
    Public dwReserved1 As UInteger
    '''WCHAR[260]
      _
    Public cFileName As String
    '''WCHAR[14]
      _
    Public cAlternateFileName As String
End Structure

  _
Public Structure FILETIME
    '''DWORD->unsigned int
    Public dwLowDateTime As UInteger
    '''DWORD->unsigned int
    Public dwHighDateTime As UInteger
End Structure

Partial Public Class NativeMethods
   
    '''Return Type: HANDLE->void*
    '''lpFileName: LPCWSTR->WCHAR*
    '''lpFindFileData: LPWIN32_FIND_DATAW->_WIN32_FIND_DATAW*
      _
    Public Shared Function FindFirstFileW( ByVal lpFileName As String,  ByRef lpFindFileData As WIN32_FIND_DATAW) As System.IntPtr
    End Function
  
    '''Return Type: BOOL->int
    '''hFindFile: HANDLE->void*
    '''lpFindFileData: LPWIN32_FIND_DATAW->_WIN32_FIND_DATAW*
      _
    Public Shared Function FindNextFileW( ByVal hFindFile As System.IntPtr,  ByRef lpFindFileData As WIN32_FIND_DATAW) As  Boolean
    End Function

    '''Return Type: BOOL->int
    '''hFindFile: HANDLE->void*
      _
    Public Shared Function FindClose(ByVal hFindFile As System.IntPtr) As  Boolean
    End Function

    '''Return Type: DWORD->unsigned int
    '''lpszLongPath: LPCWSTR->WCHAR*
    '''lpszShortPath: LPWSTR->WCHAR*
    '''cchBuffer: DWORD->unsigned int
      _
    Public Shared Function GetShortPathNameW( ByVal lpszLongPath As String,  ByVal lpszShortPath As System.Text.StringBuilder, ByVal cchBuffer As UInteger) As UInteger
    End Function

End Class


Private Const FILE_ATTRIBUTE_DIRECTORY As Long = &H10
    Dim FFW as New NativeMethods

Function Main(ByVal dirRoot As String, ByVal sFileSpec As String, Byval longOnly As Boolean) As Long
    Dim result As Long

    result = FindFiles(dirRoot, sFileSpec, longOnly)

    main = result          ' Return the result
End Function

Function FindFiles(ByRef sDir As String, ByVal sFileSpec as String, Byval longOnly As Boolean) As Long
    Const MAX_PATH As Integer = 260
    Dim FindFileData as WIN32_FIND_DATAW
    Dim hFile As Long
    Dim sFullPath As String
    Dim sFullFile As String
    Dim length as UInteger
    Dim sShortPath As New System.Text.StringBuilder()


    sFullPath = "\\?\" & sDir

    'console.writeline(sFullPath & "\" & sFileSpec)

    hFile = FFW.FindFirstFileW(sFullPath & "\" & sFileSpec, FindFileData)     ' Find the first object
    if hFile > 0 Then            ' Has something been found?
      If (FindFileData.dwFileAttributes AND FILE_ATTRIBUTE_DIRECTORY)  <> FILE_ATTRIBUTE_DIRECTORY Then  ' Is this a file?
        sFullFile = sFullPath & "\" & FindFileData.cFileName
        If (longOnly AND sFullFile.Length >= MAX_PATH) Then
          length = FFW.GetShortPathNameW(sFullPath, sShortPath, sFullPath.Length) ' GEt the 8.3 path
          console.writeline(sFullFile & " " & sshortpath.ToString())  ' Yes, report the full path and filename
        ElseIf (NOT longOnly)
          console.writeline(sFullFile)
        End If
      End If

      While FFW.FindNextFileW(hFile, FindFileData)        ' For all the items in this directory
        If (FindFileData.dwFileAttributes AND FILE_ATTRIBUTE_DIRECTORY) <> FILE_ATTRIBUTE_DIRECTORY Then ' Is this a file?
          sFullFile = sFullPath & "\" & FindFileData.cFileName
          If (longOnly AND sFullFile.Length >= MAX_PATH) Then
            length = FFW.GetShortPathNameW(sFullPath, sShortPath, sFullPath.Length) ' GEt the 8.3 path
            console.writeline(sFullFile & " " & sshortpath.ToString())  ' Yes, report the full path and filename
          ElseIf (NOT longOnly)
            console.writeline(sFullFile)
          End If
        End If
      End While
      FFW.FindClose(hFile)           ' Close the handle
      FindFileData = Nothing
    End If

    hFile = FFW.FindFirstFileW(sFullPath & "\" & "*.*", FindFileData)      ' Repeat the process looking for sub-directories using *.*
    if hFile > 0 Then
      If (FindFileData.dwFileAttributes AND FILE_ATTRIBUTE_DIRECTORY) AND _
          (FindFileData.cFileName <> ".") AND (FindFileData.cFileName <> "..") Then
        Call FindFiles(sDir & "\" & FindFileData.cFileName, sFileSpec, longOnly)      ' Recurse
      End If

      While FFW.FindNextFileW(hFile, FindFileData)
        If (FindFileData.dwFileAttributes AND FILE_ATTRIBUTE_DIRECTORY) AND _
            (FindFileData.cFileName <> ".") AND (FindFileData.cFileName <> "..") Then
          Call FindFiles(sDir & "\" & FindFileData.cFileName, sFileSpec, longOnly)     ' Recurse
        End If
      End While
      FFW.FindClose(hFile)           ' Close the handle
      FindFileData = Nothing
    End If

End Function

end class

'@


$cr = $provider.CompileAssemblyFromSource($params, $txtCode)
if ($cr.Errors.Count) {
    $codeLines = $txtCode.Split("`n");
    foreach ($ce in $cr.Errors)
    {
        write-host "Error: $($codeLines[$($ce.Line - 1)])"
        write-host $ce
        #$ce out-default
    }
    Throw "INVALID DATA: Errors encountered while compiling code"
 }
$mAssembly = $cr.CompiledAssembly
$instance = $mAssembly.CreateInstance("FindFiles")

$result = $instance.main($dirRoot, $Spec, $longOnly)


Wayne's World of IT (WWoIT), Copyright 2008 Wayne Martin.

1 comment:

ewall said...

Hullo, Wayne-- Lately I've been using PowerShell to help cleanup permissions issues on a big file server, but am running into MAX_PATH restrictions. I was excited to find your post here and try it out, but sadly it's not working. The VB code is compiling as expected, it's just not looping or returning anything (if I uncomment the line to print the current path to the console, it outputs just once "\\?\F:\\*.*"). Any tips or suggestions?

Post a Comment