Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Powershell in a batch file - How do you escape metacharacters?

Running Windows 7, when I copy a file to an external disk, during a routine file backup, I use Powershell v2 (run from a batch file) to re-create on the copy file all the timestamps of the original file.

The following code works successfully in most cases, but not always:-

SET file=%1
SET dest=E:\

COPY /V /Y  %file% "%dest%"

SetLocal EnableDelayedExpansion
FOR /F "usebackq delims==" %%A IN ('%file%') DO (
      SET fpath=%%~dpA
      SET fname=%%~nxA
)

PowerShell.exe (Get-Item \"%dest%\%fname%\").CreationTime=$(Get-Item \"%fpath%%fname%\" ^| Select-Object -ExpandProperty CreationTime ^| Get-Date -f \"MM-dd-yyyy HH:mm:ss\")

The above code copies the file, then sets the creation date/time on the copy (destination) file to that of the source file, when I drag-and-drop the source file onto my batch file.

But there are some cases where the code fails. If the filename contains a 'poison' character, such as (for example) square brackets [...], it gives the error "Property 'CreationTime' cannot be found on this object". Parsing of the filename clearly fails at the 'poison' character.

The code does not give an error with symbols such as &.

I have tried a whole load of variations of escaping the Powershell command using both single and double quotes, but without success. Please can someone tell me how to escape those characters which Powershell objects to.

This is only a small section of a much longer batch routine, on which I rely in doing regular system backups. I don't have an option to switch to a .ps1 file instead, so I need a solution which works within a batch file, not in a .ps1 file.

Thanks for all suggestions.

ADDENDUM: I found a solution, by adopting one suggestion kindly supplied by mklement0. My problem with square brackets was overcome by substituting the following command for my original Powershell command -

PowerShell.exe (Get-Item -LiteralPath \"%dest%\%fname%\").CreationTime=$(Get-Item -LiteralPath \"%fpath%%fname%\" ^| Select-Object -ExpandProperty CreationTime ^| Get-Date -f \"MM-dd-yyyy HH:mm:ss\")

For future reference, please note that (on Windows 7):

  1. The use of this revised command succeeds in preserving any extra whitespace characters. It is not necessary to include an extra pair of double-quote characters to achieve that.

    • Editor's note: It's an edge case, but worth pointing out: without extra enclosing double quotes, any runs of more than one space are folded into one space; e.g.,
      powershell.exe -command echo \"a b\" yields a b.
      Enclosing the entire command in "..." helps in principle -
      powershell.exe -command "echo \"a b\"" - but since cmd.exe then doesn't recognize the overall string as a single, double-quoted string, metacharacters can break the command; e.g.,
      powershell.exe -command "echo \"a & b\""
  2. It is not possible for the file path to include any " (double quote) character, so no code is required to escape that character. The double quote character is an illegal character in the FAT and NTFS filesystems, so can never be encountered in a file's path.

  3. It is bad in principle to use ' (single quote) in the Powershell command, because that character is NOT illegal in the NTFS file system, so could be found in an actual path to a file. Use of double-quotes must be preferred, because the double-quote character, being illegal, can NEVER be encountered in an actual NTFS path.

  4. With ROBOCOPY, the following wildcard solution succeeds even with most poison characters - all except ! (i.e. it can cope with = & ` ^). This command is pretty robust EVEN if there is more than one poison character (though not foolproof):

    ROBOCOPY "%fpath% " "%dest%" "*%name%*%ext%*" /B /COPY:DAT /XJ /SL /R:0 /W:0 /V

    a. The whitespace in "%fpath% " is ESSENTIAL, it is NOT an error.

    b. The only poison character fatal in all circumstances is the EXCLAMATION MARK (!).

    c. Poison characters only seem to be a problem in the FILENAME, not in the Path.

like image 259
Ed999 Avatar asked Oct 17 '22 07:10

Ed999


1 Answers

First things first:

In Windows 7, you can use robocopy.exe to copy files, which preserves timestamps by default (and optionally gives you detailed control about what attributes get copied):

@echo off
:: Do NOT use setlocal ENABLEDELAYEDEXPANSION, because it would cause
:: misinterpretation of  "!" chars. in filenames.    
setlocal

:: Parse the file path given as %1 (the first argument) into its folder path and filename.
:: Be sure to pass the %1 argument *double-quoted* to prevent up-front interpretation 
:: by cmd.exe; e.g.:
::   someBatchFile "c:\tmp\foo.txt" or someBatchFile "%file%"
:: Note that %~dp1 always returns a path with a trailing "\".
set "fpath=%~dp1"
set "fname=%~nx1"

:: Determine the destination folder
set "dest=E:\"

:: Use robocopy to copy the file to the destination dir. with timestamps preserved.
:: Syntax is: <source-dir> <dest-dir> <filename-or-wildcard> ...
:: IMPORTANT: To avoid problems with paths that end in "\", always follow
::            a variable reference inside "..." with a *space* (a trick discovered by
::            Ed999 himself).
robocopy "%fpath% " "%dest% " "%fname%"

Note:

  • While robocopy is primarily used to copy entire directories, it does allow you to copy individual files, via wildcard expressions specified starting with the 3rd positional argument, as "%fname%" above.
    Given that robocopy - unlike PowerShell - doesn't consider [ and ] wildcard metacharacters, this approach should work (you'd only have a problem if your filenames contained embedded * or ? characters, which is unlikely).

  • The trailing-space trick (e.g., "%fpath% ") is necessary, because robocopy - as most command-line utilities do - treats a \" at the end of an argument as an escaped ", which breaks the command. Strictly speaking, the proper fix is to double the trailing \ (e.g., "E:\\"), but you can get away with appending a space, because any trailing whitespace in paths is ignored. Thus, a simple way to make such calls robust is to always use "%var% " (trailing space before the closing double quote) when passing folder paths.

  • robocopy has no switch analogous to /V that causes it to verify that the file was copied correctly, but - at least according to this blog post - running verify on beforehand should have the same effect.


If you still need to copy the creation timestamp via PowerShell:

powershell -command "(Get-Item -LiteralPath '%dest%%fname%').CreationTime=(Get-Item -LiteralPath '%fpath%%fname%').CreationTime"

Caveat: If there's a chance that your filenames have embedded ' chars. (single quotes / apostrophes), you must escape them first, by doubling them (for instance, %fname:'=''% returns the value of %fname% with all ' instances doubled):

powershell -command "(Get-Item -LiteralPath '%dest:'=''%%fname:'=''%').CreationTime=(Get-Item -LiteralPath '%fpath:'=''%%fname:'=''%').CreationTime"
  • Note that the command as a whole is enclosed in "...", so as to prevent cmd.exe metacharacters (such as &) that may be contained in the variable values from breaking the command.[1]

  • Inside the command string, '...' is used to ensure that the variable values are treated as literals by PowerShell (if you used "..." and a filename contained $, for instance, the result would be unexpected).

  • -LiteralPath ensures that Get-Item interprets the file path as a literal, whereas it is the
    -Path parameter that is implied when you pass the path positionally, and paths passed to
    -Path are interpreted as wildcard expressions, which can cause problems with PowerShell wildcard metacharacters such as [ and ].

  • There is no need to convert the .CreationTime property value of the source file to a date+time string first; you can simply assign it directly to the target file's .CreationTime property, which is of type [System.DateTime].


[1]Quoting headaches:

  • Enclosing only the variable references in \-escaped double quotes, as in the question (\"%dest%\%fname%\"), is not enough, because doing so subjects the value to whitespace normalization, meaning that runs of more than one space are normalized to a single space.

  • While additionally enclosing the command as a whole in "..." helps in principle, cmd.exe then doesn't recognize the overall string as a single, double-quoted string, in which case metacharacters such as & in the variable values can break the command; e.g.,
    powershell.exe -command "echo \"a b\"" works fine, faithfully preserving whitespace, but
    powershell.exe -command "echo \"a & b\"" breaks, due to the &.

    • Using \"" instead of \" solves the metacharacter problem, but reintroduces whitespace normalization:
      powershell.exe -command "echo \""a & b\""" yields a & b
  • Therefore, using '...' PowerShell strings inside the overall "..." string is the simplest way to make the command robust: you only need to escape ' instances in the variable values.

like image 68
mklement0 Avatar answered Oct 27 '22 22:10

mklement0