From 6640b8030385c5b717df1d5ada2dfd6656d8c1f9 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Wed, 10 Sep 2025 15:04:11 +1000 Subject: [PATCH] update multishell scriptsapps --- .gitignore | 2 + bin/getzig.cmd | 446 ++++++++++++++----- bin/runtime.cmd | 544 ++++++++++++++++++------ bin/tclargs.cmd | 708 +++++++++++++++++++++---------- getpunk.cmd | 547 ++++++++++++++++++------ src/scriptapps/getpunk.bash | 3 +- src/scriptapps/getpunk_wrap.toml | 7 +- src/scriptapps/runtime_wrap.toml | 9 +- src/scriptapps/tclargs.tcl | 16 +- src/scriptapps/tclargs_wrap.toml | 28 +- 10 files changed, 1668 insertions(+), 642 deletions(-) diff --git a/.gitignore b/.gitignore index bb48c0c9..8ecbb93a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +*.lastrun + #/bin/ /bin/* !/bin/*.cmd diff --git a/bin/getzig.cmd b/bin/getzig.cmd index 2cb8900b..aa1f80a3 100644 --- a/bin/getzig.cmd +++ b/bin/getzig.cmd @@ -95,19 +95,19 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @REM If more than 64 chars needed for a target, it can still be done but overall script padding may need checking/adjusting @REM Supporting more explicit oses than those listed may also require script padding adjustment : <> -@SET "nextshellpath[win32___________]=pwsh -nop -nol -ExecutionPolicy bypass -c_______________________" +@SET "nextshellpath[win32___________]=pwsh -nop -nol -ExecutionPolicy bypass -c_______________________________________________________________________________________" @SET "nextshelltype[win32___________]=pwsh____________" -@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[dragonflybsd____]=bash____________" -@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[freebsd_________]=bash____________" -@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[netbsd__________]=bash____________" -@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[linux___________]=bash____________" -@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[macosx__________]=bash____________" -@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[other___________]=bash____________" : <> @rem asadmin is for automatic elevation to administrator. Separate window will be created (seems unavoidable with current elevation mechanism) and user will still get security prompt (probably reasonable). @@ -119,7 +119,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringTrimTrailingUnderscores %selected_shelltype% selected_shelltype_trimmed @REM @ECHO selected_shelltype_trimmed %selected_shelltype_trimmed% @SET "selected_shellpath=%nextshellpath[win32___________]%" -@CALL :stringTrimTrailingUnderscores %selected_shellpath% selected_shellpath_trimmed +@CALL :stringTrimTrailingUnderscores "%selected_shellpath%" selected_shellpath_trimmed @CALL SET "keyRemoved=%%validshelltypes:!selected_shelltype!=%%" @REM @ECHO keyremoved %keyRemoved% @REM Note that 'powershell' e.g v5 is just a fallback for when pwsh is not available @@ -151,6 +151,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET "winpath=%~dp0" %= e.g c:\punkshell\bin\ %= @SET "fname=%~nx0" @SET "scriptrootname=%~dp0%~n0" %= e.g c:\punkshell\bin\runtime (full path without extension) unavailable after shift, so store it =% +@SET "fullscriptname=%~dp0%~n0%~x0" @REM @ECHO fname %fname% @REM @ECHO winpath %winpath% @REM @ECHO commandlineascalled %0 @@ -162,6 +163,62 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringToUpper %~nx0 capscripttail @REM @ECHO capscriptname: %capscripttail% +@goto skip_parameter_wrangling +@set argCount=30 +@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe +@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon +@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. +@set tmpfile_base=%TEMP%\punkbatch_params +@call :getUniqueFile %tmpfile_base% ".txt" paramfile +@echo %paramfile% + +%= NOTE when we loop like this using the percent-n args and shift, we lose unquoted separators such as comma and semicolon %= +@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 +@rem outer loop required to redirect all rem lines at once to file +@for %%x in (1) do @( + @for /L %%f in (1,1,%argCount%) do @( + @set "argnum=%%~nf" + @set "a1=%%1" + @rem @set "argname=%%!argnum!" + @rem @echo argname: !argname! + @call :rem_output !argnum! !a1! + @shift + ) +) > %paramfile% +@echo off + +@set "newcommandline= " + +@(set target=cmd_pwsh) +@if "%target%"=="cmd_pwsh" ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + @REM @echo ######### %%L + @rem call :buildcmdline newcommandline param "{" "}" + @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= + call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= + @rem @echo . + ) +) ELSE ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + call :buildcmdline newcommandline param + ) +) +@REM padding +SETLOCAL EnableDelayedExpansion +@echo off +@IF EXIST %paramfile% ( + @DEL /F /Q %paramfile% +) +@IF EXIST %paramfile% ( + echo failed to delete %paramfile% + cat %paramfile% +) +:skip_parameter_wrangling + @IF "%nftail%"=="%capscripttail%" ( @ECHO forcing asadmin=1 due to file name on filesystem being uppercase @SET "asadmin=1" @@ -189,31 +246,112 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% :getPrivileges @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" (echo PUNK-ELEVATED & shift /1 & goto :gotPrivileges ) @ECHO Set UAC = CreateObject^("Shell.Application"^) > "%vbsGetPrivileges%" -@ECHO args = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@ECHO pre = "/c %fullscriptname% PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@REM @echo pre = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@echo args = pre >> "%vbsGetPrivileges%" @ECHO For Each strArg in WScript.Arguments >> "%vbsGetPrivileges%" -@ECHO args = args ^& strArg ^& " " >> "%vbsGetPrivileges%" +@ECHO args = args ^& Chr(34) ^& strArg ^& Chr(34) ^& " " >> "%vbsGetPrivileges%" @ECHO Next >> "%vbsGetPrivileges%" -@ECHO UAC.ShellExecute "%~dp0%~n0%~x0", args, "", "runas", 1 >> "%vbsGetPrivileges%" -@ECHO Launching script in new window due to administrator elevation +@GOTO skiptest + +%= Option Explicit =% +%= We need a child process to locate the current script. =% +@ECHO Const FLAG_PROCESS = "winver.exe" >> "%vbsGetPrivileges%" + +%= ' WMI constants %= +@ECHO Const wbemFlagForwardOnly = 32 >> "%vbsGetPrivileges%" + +%=' Generate a unique value to be used as a flag =% +@ECHO Dim guid >> "%vbsGetPrivileges% +@ECHO guid = Left(CreateObject("Scriptlet.TypeLib").GUID,38) >> "%vbsGetPrivileges%" + +%= ' Start a process using the indicated flag inside its command line =% +@ECHO WScript.CreateObject("WScript.Shell").Run """" ^& FLAG_PROCESS ^& """ " ^& guid, 0, False >> "%vbsGetPrivileges%" + +%= ' To retrieve process information a WMI reference is needed =% +@ECHO Dim wmi >> "%vbsGetPrivileges%" +@ECHO Set wmi = GetObject("winmgmts:{impersonationLevel=impersonate}^!\\.\root\cimv2") >> "%vbsGetPrivileges%" + +%= ' Query the list of processes with the flag in its command line, retrieve the =% +%= ' process ID of its parent process ( our script! ) and terminate the process =% +@ECHO Dim colProcess, process, myProcessID >> "%vbsGetPrivileges%" +@ECHO Set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT ParentProcessID From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE Name='" ^& FLAG_PROCESS ^& "' " ^& _>> "%vbsGetPrivileges%" +@ECHO "AND CommandLine LIKE '%%" ^& guid ^& "%%'" _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO myProcessID = process.ParentProcessID >> "%vbsGetPrivileges%" +@ECHO process.Terminate >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" + +%= ' Knowing the process id of our script we can query the process list =% +%= ' and retrieve its command line =% +@ECHO Dim commandLine >> "%vbsGetPrivileges%" +@ECHO set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT CommandLine From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE ProcessID=" ^& myProcessID _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO commandLine = process.CommandLine >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "raw commandline: " ^& commandLine >>"%vbsGetPrivileges%" + +%= ' Done =% +@ECHO intpos = 0 >> "%vbsGetPrivileges%" +@ECHO intCount = 0 >> "%vbsGetPrivileges%" +@ECHO intstartsearch = 1 >> "%vbsGetPrivileges%" +@ECHO intmax = 100 >> "%vbsGetPrivileges%" +@ECHO do While intCount ^< 4 and intmax ^> 0 >> "%vbsGetPrivileges%" +@ECHO intpos = InStr(intstartsearch, commandline, """") >> "%vbsGetPrivileges%" +@ECHO if intpos ^<^> 0 then >> "%vbsGetPrivileges%" +@ECHO intCount = intCount + 1 >> "%vbsGetPrivileges%" +@ECHO if intcount = 4 then >> "%vbsGetPrivileges%" +@ECHO ' wscript.echo "position: " ^& intpos >> "%vbsGetPrivileges%" +@ECHO commandline = Mid(commandline,intpos+1) >> "%vbsGetPrivileges%" +@ECHO exit do >> "%vbsGetPrivileges%" +@ECHO else >> "%vbsGetPrivileges%" +@ECHO intstartsearch = intpos + 1 >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO intmax = intmax -1 >> "%vbsGetPrivileges%" +@ECHO Loop >> "%vbsGetPrivileges%" +@ECHO if intcount ^< 4 then >> "%vbsGetPrivileges%" +@ECHO err.raise vbObjectError + 1001, "vbsGetPrivileges", "failed to parse commandline" >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO commandline = pre ^& commandline >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "commandline: " ^& commandLine >>"%vbsGetPrivileges%" +@ECHO WScript.Echo "args: " ^& args >>"%vbsGetPrivileges%" +:skiptest + +@ECHO UAC.ShellExecute "cmd.exe", args, "", "runas", 1 >> "%vbsGetPrivileges%" +@REM @ECHO UAC.ShellExecute "%fullscriptname%", commandline, "", "runas", 1 >> "%vbsGetPrivileges%" +@ECHO Launching script "%fullscriptname%" in new window due to administrator elevation with args: "%*" @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" %* +@REM @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" !newcommandline! @EXIT /B +@REM buffer +@REM buffer :gotPrivileges @REM setlocal & pushd . @PUSHD . -@cd /d %~dp0 +@cd /d %winpath% @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" ( @DEL "%vbsGetPrivileges%" 1>nul 2>nul @SET arglist=%arglist:~14% + @SHIFT ) :skip_privileges @SET need_ps1=0 @REM we want the ps1 to exist even if the nextshell isn't powershell -@if not exist "%~dp0%~n0.ps1" ( +@if not exist "%scriptrootname%.ps1" ( @SET need_ps1=1 ) ELSE ( - fc "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >nul || goto different + fc "%fullscriptname%" "%scriptrootname%.ps1" >nul || goto different @REM @ECHO "files same" @SET need_ps1=0 ) @@ -223,74 +361,13 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET need_ps1=1 :pscontinue @IF !need_ps1!==1 ( - COPY "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >NUL + COPY "%fullscriptname%" "%scriptrootname%.ps1" >NUL ) @REM avoid using CALL to launch pwsh,tclsh etc - it will intercept some args such as /? @IF "!selected_shelltype_trimmed!"=="none" ( SET selected_shelltype_trimmed=pwsh ) - - - -@set argCount=30 -@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe -@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon -@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. - -@set tmpfile_base=%TEMP%\punkbatch_params -@call :getUniqueFile %tmpfile_base% ".txt" paramfile -@echo %paramfile% - -%= NOTE when we loop like this using the percent-n args, we lose unquoted separators such as comma and semicolon %= -@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 -@rem outer loop required to redirect all rem lines at once to file -@for %%x in (1) do @( - @for /L %%f in (1,1,%argCount%) do @( - @set "argnum=%%~nf" - @set "a1=%%1" - @rem @set "argname=%%!argnum!" - @rem @echo argname: !argname! - @call :rem_output !argnum! !a1! - @shift - ) -) > %paramfile% -@echo off - -@set "newcommandline= " - -@(set target=cmd_pwsh) -@if "%target%"=="cmd_pwsh" ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - @REM @echo ######### %%L - @rem call :buildcmdline newcommandline param "{" "}" - @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= - call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= - @rem @echo . - ) -) ELSE ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - call :buildcmdline newcommandline param - ) -) -@REM padding -SETLOCAL EnableDelayedExpansion - -@echo off -@IF EXIST %paramfile% ( - @DEL /F /Q %paramfile% -) -@IF EXIST %paramfile% ( - echo failed to delete %paramfile% - cat %paramfile% -) - - - @REM @SET "squoted_args=" @REM @for %%a in (%*) do @( @REM set "v=%%a" @@ -311,19 +388,24 @@ SETLOCAL EnableDelayedExpansion REM fallback to powershell if pwsh failed IF !pwshtest_exitcode!==0 ( @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; "%scriptrootname%.ps1" %arglist% - @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted - cmd /c pwsh -nop -nol -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + @rem pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" %arglist% %= ok =% + @rem cmd /c pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% SET task_exitcode=!errorlevel! ) ELSE ( REM TODO prompt user with option to call script to install pwsh using winget - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ) ELSE ( IF "!selected_shelltype_trimmed!"=="powershell" ( - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + @rem @echo powershell - !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% %= ok - this works =% + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ELSE ( IF "!selected_shelltype_trimmed!"=="wslbash" ( @@ -337,24 +419,23 @@ SETLOCAL EnableDelayedExpansion REM sh on windows uses /c/ instead of /mnt/c - at least if using msys. Todo, review what is the norm on windows with and without msys2,cygwin,wsl REM and what logic if any may be needed. For now sh with /c/xxx seems to work the same as sh with c:/xxx REM The compound statement with trailing call is required to stop batch termination confirmation, whilst still capturing exitcode - @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" - %selected_shellpath_trimmed% "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; + @REM @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" + !selected_shellpath_trimmed! "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; ) ELSE ( ECHO %fname% has invalid nextshelltype value %selected_shelltype% valid options are %validshelltypes% SET task_exitcode=66 @REM boundary padding - GOTO :exit_multishell ) ) ) ) @REM batch file library functions - @GOTO :endlib @REM padding @REM padding @REM padding +@REM padding %= ---------------------------------------------------------------------- =% @rem courtesy of dbenham @@ -458,6 +539,7 @@ do if not defined param1 set %%~"param1=%2%%~" rem %1 #%2# @exit /b +@rem padding @REM courtesy of: https://stackoverflow.com/users/463115/jeb :strlen stringVar returnVar @( @@ -506,6 +588,8 @@ do if not defined param1 set %%~"param1=%2%%~" ) @EXIT /B +@REM padding +@REM padding :getFileTail @REM return tail of file without any normalization e.g c:/punkshell/bin/Punk.cmd returns Punk.cmd even if file is punk.cmd @REM we can't use things such as %~nx1 as it can change capitalisation @@ -545,6 +629,7 @@ do if not defined param1 set %%~"param1=%2%%~" @EXIT /B @REM boundary padding @REM boundary padding +@REM boundary padding :getNormalizedScriptTail @SETLOCAL @SET "result=%~nx0" @@ -654,14 +739,14 @@ do if not defined param1 set %%~"param1=%2%%~" @REM boundary padding @REM boundary padding @REM boundary padding -@REM boundary padding :stringTrimTrailingUnderscores @SETLOCAL @SET "rtrn=%~2" @SET "string=%~1" @SET "trimstring=%~1" - @REM trim up to 63 underscores from the end of a string using string substitution + @REM trim up to 127 underscores from the end of a string using string substitution @SET "trimstring=%trimstring%###" + @SET "trimstring=%trimstring:________________________________________________________________###=###%" @SET "trimstring=%trimstring:________________________________###=###%" @SET "trimstring=%trimstring:________________###=###%" @SET "trimstring=%trimstring:________###=###%" @@ -707,10 +792,11 @@ do if not defined param1 set %%~"param1=%2%%~" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### # -- tcl script section # -- This is a punk multishell file -# -- Primary payload target is Tcl, with sh,bash,powershell as helpers -# -- but it may equally be used with any of these being the primary script. -# -- It is tuned to run when called as a batch file, a tcl script a sh/bash script or a pwsh/powershell script +# -- It is tuned to run (and possibly divert to different payload shell) when called from cmd.exe as a batch file, tclsh,sh,zsh,bash,perl or pwsh/powershell script # -- i.e it is a polyglot file. +# -- The payload target (by os) is defined in the nextshell block at the top which is constructed when generating the polyglot +# -- using the tcl 'dev scriptwrap.multishell' command in a tcl punk shell +# -- The payload can be tcl,perl,powershell/pwsh or zsh/bash. # -- The specific layout including some lines that appear just as comments is quite sensitive to change. # -- It can be called on unix or windows platforms with or without the interpreter being specified on the commandline. # -- e.g ./scriptname.cmd in sh or zsh or bash @@ -760,15 +846,65 @@ foreach ln [split $scriptdata \n] { } } if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { + set script_as_called [info script] + set script_rootname [file rootname $script_as_called] if {$nextshelltype in "pwsh powershell"} { - set scrname [file rootname [info script]].ps1 - set arglist [list] - foreach a $::argv { - set a "'$a'" - lappend arglist $a + # experimental + set script_ps1 $script_rootname.ps1 + set arglist $::argv + + if {[file extension $script_as_called] ne ".ps1"} { + #we need to ensure .ps1 is up to date + set needs_updating 0 + if {![file exists $script_ps1]} { + set needs_updating 1 + } else { + #both exist + if {[file size $script_as_called] != [file size $script_ps1]} { + set needs_updating 1 + } else { + #both exist with same size - do full check that they're identical + catch {package require sha256} + if {[package provide sha256] ne ""} { + set h1 [sha2::sha256 -hex -file $script_as_called] + set h2 [sha2::sha256 -hex -file $script_ps1] + if {[string length $h1] != 64 || [string length $h2] != 64} { + set needs_updating 1 + } elseif {$h1 ne $h2} { + set needs_updating 1 + } + } else { + #manually compare - scripts aren't too big, so slurp and string compare is fine + set fd [open $script_as_called] + chan configure $fd -translation binary + set data1 [read $fd] + close $fd + set fd [open $script_ps1] + chan configure $fd -translation binary + set data2 [read $fd] + close $fd + if {![string equal $data1 $data2]} { + set needs_updating 1 + } + } + } + } + + if {$needs_updating} { + file copy -force $script_as_called $script_ps1 + } + } else { + #when called on the .ps1 - we assume it's up to date - review } + set scrname $script_ps1 + + #set arglist [list] + #foreach a $::argv { + # set a "'$a'" + # lappend arglist $a + #} } else { - set scrname [info script] + set scrname $script_as_called set arglist $::argv } puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" @@ -867,8 +1003,11 @@ if {[::punk::multishell::is_main]} { HEREDOC1B_HIDE_FROM_BASH_AND_SH # Be wary of any non-trivial sed/awk etc - can be brittle to maintain across linux,freebsd,macosx due to differing implementations \ echo "var0: $0 @: $@" -# use oldschool backticks and sed - lowest common denominator \ -ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` +# use oldschool backticks and sed (posix - lowest common denominator) \ +# ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` \ +# some ps impls will return arguments - so last field not always appropriate \ +# some ps impls don't have -o (e.g gnu cygwin) so ps_shellname may remain empty and emit an error \ +ps_shellname=`ps -o pid,comm -p $$ | awk '$1 != "PID" {print $2}'` # \ echo "shell from ps: $ps_shellname" # \ @@ -900,8 +1039,11 @@ pop() { } # ------------------------------------------------------------------------------ -# non-bash-like posix diversion \ -if [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ]; then +# non-bash-like posix diversion +# we don't use $BASH_VERSION/$ZSH_VERSION as these can still be set when for example +# sh is a symlink to bash (posix-mode bash - reduced bashism capabilities?) +# if our ps_shellname didn't contain a result, don't divert and risk looping +if [ -n "$ps_shellname" ] && [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ] ; then shift pop $# eval "$POP_EXPR" @@ -952,18 +1094,32 @@ elif [[ "$plat" == "MINGW64"* ]]; then elif [[ "$plat" == "CYGWIN_NT"* ]]; then os="win32" elif [[ "$plat" == "MSYS_NT"* ]]; then - #review.. echo MSYS - #win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' + #review.. + + #Need to consider the difference between when msys2 was launched (which strips some paths and sets up the environment) + # vs if the msys2 sh was called - (actually bash) in which case paths will be different + + #wsl and cygwin or msys2 can commonly be problematic combinations - primarily due to path issues + #e.g "c:/windows/system32/" is quite likely in the path ahead of msys,git etc. + #e.g It means a /usr/bin/env bash call may launch the (linux elf) bash for wsl rather than the msys bash + # + + #msys provides win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' #bash reports $OSTYPE msys + + #there are statements around the web that cmd /c .. will work under msys2 + # - but from experience, it can be required to use cmd //c ... + # or MSYS2_ARG_CONV_ECL='*' cmd /c .. + # This seems to be because process arguments that look like unix paths are converted to windows paths :/ + #review! + os="win32" #review - need ps/sed/awk to determine shell? - interp = `ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` + interp=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` #use 'command -v' (shell builtin preferred over external which) shellpath=`command -v $interp` shellfolder="${shellpath%/*}" #avoid dependency on basename or dirname - #"c:/windows/system32/" is quite likely in the path ahead of msys,git etc. - #This breaks calls to various unix utils such as sed etc (wsl related?) export PATH="$shellfolder${PATH:+:${PATH}}" elif [[ "$OSTYPE" == "win32" ]]; then os="win32" @@ -1008,8 +1164,46 @@ exitcode=0 #-- sh/bash launches nextscript here instead of shebang line at top if [[ "$nextshelltype" != "bash" && "$nextshelltype" != "none" ]]; then echo zsh/bash launching subshell of type: $nextshelltype shellpath: $nextshellpath on "$0" with args "$@" - #e.g /usr/bin/env tclsh "$0" "$@" - ${nextshellpath} "$0" "$@" + + script="$0" + if [[ "$nextshelltype" == "pwsh" || "$nextshelltype" == "powershell" ]]; then + #powershell requires the file extension to be .ps1 (always - on windows) + #on other platforms it's not required if a shebang line is used - but this script must be shebangless for portability and to maintain polyglot capabilities. + cmdpattern="[.]cmd$" + if [[ "$script" =~ $cmdpattern ]]; then + ps1script="${script%????}.ps1" + if ! cmp -s "$script" "$ps1script" ; then + #ps1script either different or missing + #on windows - batch script copies .cmd -> .ps1 if not identical + cp -f "$script" "$ps1script" + fi + script=$ps1script + fi + fi + if [[ "$plat" == "MSYS_NT"* ]]; then + + #we need to deal with MSYS argument mangling + cmdpattern="^cmd.exe |^cmd " + #do not double quote cmdpattern - or it will be treated as literal string + if [[ "$nextshellpath" =~ $cmdpattern ]]; then + #for now - tell the user what's going on + echo "cmd call via msys detected. performing translation of /c to //c and escaping backslashes in script path" + #flags to cmd.exe such as /c are interpreted by msys as looking like a unix path + #review - for nextshellpath targets specified in the block for win32 - we don't expect unix paths (?) + #what about other flags? - can we just double up all forward slashes? + nextshellpath="${nextshellpath// \/c / \/\/c }" + echo "new nextshellpath: ${nextshellpath}" + #don't double quote this + script=${script//\\/\\\\} + fi + echo "calling ${nextshellpath} $script $@" + # eval is required here - but why? + eval ${nextshellpath} "$script" "$@" + else + #e.g /usr/bin/env tclsh "$0" "$@" + ${nextshellpath} "$script" "$@" + fi + exitcode=$? #echo "zsh/bash reporting exitcode: ${exitcode}" @@ -1204,18 +1398,44 @@ if ($match.Success) { $admininfo = $match.Groups[1].Value $asadmin = $admininfo.Contains("asadmin=1") if ($asadmin) { + if ($args[0] -eq "PUNK-ELEVATED") { + # May be present if launch and elevation was done via cmd.exe script + # shift away first arg + $newargs = $args | Select-Object -Skip 1 + } else { + $newargs = $args + } + # -Wait e.g for starting a service or other operations which remainder of script may depend on + $arguments = @("-NoProfile","-NoLogo", "-NoExit", "-ExecutionPolicy", "Bypass") + $arguments += @("-File", $($MyInvocation.MyCommand.Path)) + foreach ($a in $newargs) { + if ($a -match '\s') { + $arguments += "`"$a`"" + } else { + $arguments += $a + } + } if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { # If not elevated, relaunch with elevated privileges - # -Wait e.g for starting a service or other operations which remainder of script may depend on - $arguments = @("-NoProfile", "-NoExit", "-ExecutionPolicy", "Bypass") - $arguments += @("-File", $($MyInvocation.MyCommand.Path)) - $arguments += $args + Write-Host "Powershell elevating using start-process with -Verb RunAs" if ($PSVersionTable.PSEdition -eq 'Core') { Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -Wait -Verb RunAs } else { Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Wait -Verb RunAs } Exit # Exit the current non-elevated process + } else { + if ($args[0] -eq "PUNK-ELEVATED") { + #Already elevated (by cmd.exe) + #.. but it is impossible to modify or reassign the automatic $args variable + # so let's start yet another whole new process just to remove one leading argument so the custom script can operate on parameters cleanly - thanks powershell :/ + if ($PSVersionTable.PSEdition -eq 'Core') { + Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -NoNewWindow -Wait + } else { + Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -NoNewWindow -Wait + } + Exit + } } } } diff --git a/bin/runtime.cmd b/bin/runtime.cmd index be23cbb8..6bac2995 100755 --- a/bin/runtime.cmd +++ b/bin/runtime.cmd @@ -95,19 +95,19 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @REM If more than 64 chars needed for a target, it can still be done but overall script padding may need checking/adjusting @REM Supporting more explicit oses than those listed may also require script padding adjustment : <> -@SET "nextshellpath[win32___________]=powershell -nop -nol -ExecutionPolicy bypass -File______________" +@SET "nextshellpath[win32___________]=cmd.exe /c powershell -nop -nol -ExecutionPolicy bypass -File___________________________________________________________________" @SET "nextshelltype[win32___________]=powershell______" -@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[dragonflybsd____]=bash____________" -@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[freebsd_________]=bash____________" -@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[netbsd__________]=bash____________" -@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[linux___________]=bash____________" -@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[macosx__________]=bash____________" -@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[other___________]=bash____________" : <> @rem asadmin is for automatic elevation to administrator. Separate window will be created (seems unavoidable with current elevation mechanism) and user will still get security prompt (probably reasonable). @@ -119,7 +119,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringTrimTrailingUnderscores %selected_shelltype% selected_shelltype_trimmed @REM @ECHO selected_shelltype_trimmed %selected_shelltype_trimmed% @SET "selected_shellpath=%nextshellpath[win32___________]%" -@CALL :stringTrimTrailingUnderscores %selected_shellpath% selected_shellpath_trimmed +@CALL :stringTrimTrailingUnderscores "%selected_shellpath%" selected_shellpath_trimmed @CALL SET "keyRemoved=%%validshelltypes:!selected_shelltype!=%%" @REM @ECHO keyremoved %keyRemoved% @REM Note that 'powershell' e.g v5 is just a fallback for when pwsh is not available @@ -151,6 +151,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET "winpath=%~dp0" %= e.g c:\punkshell\bin\ %= @SET "fname=%~nx0" @SET "scriptrootname=%~dp0%~n0" %= e.g c:\punkshell\bin\runtime (full path without extension) unavailable after shift, so store it =% +@SET "fullscriptname=%~dp0%~n0%~x0" @REM @ECHO fname %fname% @REM @ECHO winpath %winpath% @REM @ECHO commandlineascalled %0 @@ -162,6 +163,62 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringToUpper %~nx0 capscripttail @REM @ECHO capscriptname: %capscripttail% +@goto skip_parameter_wrangling +@set argCount=30 +@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe +@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon +@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. +@set tmpfile_base=%TEMP%\punkbatch_params +@call :getUniqueFile %tmpfile_base% ".txt" paramfile +@echo %paramfile% + +%= NOTE when we loop like this using the percent-n args and shift, we lose unquoted separators such as comma and semicolon %= +@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 +@rem outer loop required to redirect all rem lines at once to file +@for %%x in (1) do @( + @for /L %%f in (1,1,%argCount%) do @( + @set "argnum=%%~nf" + @set "a1=%%1" + @rem @set "argname=%%!argnum!" + @rem @echo argname: !argname! + @call :rem_output !argnum! !a1! + @shift + ) +) > %paramfile% +@echo off + +@set "newcommandline= " + +@(set target=cmd_pwsh) +@if "%target%"=="cmd_pwsh" ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + @REM @echo ######### %%L + @rem call :buildcmdline newcommandline param "{" "}" + @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= + call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= + @rem @echo . + ) +) ELSE ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + call :buildcmdline newcommandline param + ) +) +@REM padding +SETLOCAL EnableDelayedExpansion +@echo off +@IF EXIST %paramfile% ( + @DEL /F /Q %paramfile% +) +@IF EXIST %paramfile% ( + echo failed to delete %paramfile% + cat %paramfile% +) +:skip_parameter_wrangling + @IF "%nftail%"=="%capscripttail%" ( @ECHO forcing asadmin=1 due to file name on filesystem being uppercase @SET "asadmin=1" @@ -189,31 +246,112 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% :getPrivileges @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" (echo PUNK-ELEVATED & shift /1 & goto :gotPrivileges ) @ECHO Set UAC = CreateObject^("Shell.Application"^) > "%vbsGetPrivileges%" -@ECHO args = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@ECHO pre = "/c %fullscriptname% PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@REM @echo pre = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@echo args = pre >> "%vbsGetPrivileges%" @ECHO For Each strArg in WScript.Arguments >> "%vbsGetPrivileges%" -@ECHO args = args ^& strArg ^& " " >> "%vbsGetPrivileges%" +@ECHO args = args ^& Chr(34) ^& strArg ^& Chr(34) ^& " " >> "%vbsGetPrivileges%" @ECHO Next >> "%vbsGetPrivileges%" -@ECHO UAC.ShellExecute "%~dp0%~n0%~x0", args, "", "runas", 1 >> "%vbsGetPrivileges%" -@ECHO Launching script in new window due to administrator elevation +@GOTO skiptest + +%= Option Explicit =% +%= We need a child process to locate the current script. =% +@ECHO Const FLAG_PROCESS = "winver.exe" >> "%vbsGetPrivileges%" + +%= ' WMI constants %= +@ECHO Const wbemFlagForwardOnly = 32 >> "%vbsGetPrivileges%" + +%=' Generate a unique value to be used as a flag =% +@ECHO Dim guid >> "%vbsGetPrivileges% +@ECHO guid = Left(CreateObject("Scriptlet.TypeLib").GUID,38) >> "%vbsGetPrivileges%" + +%= ' Start a process using the indicated flag inside its command line =% +@ECHO WScript.CreateObject("WScript.Shell").Run """" ^& FLAG_PROCESS ^& """ " ^& guid, 0, False >> "%vbsGetPrivileges%" + +%= ' To retrieve process information a WMI reference is needed =% +@ECHO Dim wmi >> "%vbsGetPrivileges%" +@ECHO Set wmi = GetObject("winmgmts:{impersonationLevel=impersonate}^!\\.\root\cimv2") >> "%vbsGetPrivileges%" + +%= ' Query the list of processes with the flag in its command line, retrieve the =% +%= ' process ID of its parent process ( our script! ) and terminate the process =% +@ECHO Dim colProcess, process, myProcessID >> "%vbsGetPrivileges%" +@ECHO Set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT ParentProcessID From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE Name='" ^& FLAG_PROCESS ^& "' " ^& _>> "%vbsGetPrivileges%" +@ECHO "AND CommandLine LIKE '%%" ^& guid ^& "%%'" _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO myProcessID = process.ParentProcessID >> "%vbsGetPrivileges%" +@ECHO process.Terminate >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" + +%= ' Knowing the process id of our script we can query the process list =% +%= ' and retrieve its command line =% +@ECHO Dim commandLine >> "%vbsGetPrivileges%" +@ECHO set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT CommandLine From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE ProcessID=" ^& myProcessID _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO commandLine = process.CommandLine >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "raw commandline: " ^& commandLine >>"%vbsGetPrivileges%" + +%= ' Done =% +@ECHO intpos = 0 >> "%vbsGetPrivileges%" +@ECHO intCount = 0 >> "%vbsGetPrivileges%" +@ECHO intstartsearch = 1 >> "%vbsGetPrivileges%" +@ECHO intmax = 100 >> "%vbsGetPrivileges%" +@ECHO do While intCount ^< 4 and intmax ^> 0 >> "%vbsGetPrivileges%" +@ECHO intpos = InStr(intstartsearch, commandline, """") >> "%vbsGetPrivileges%" +@ECHO if intpos ^<^> 0 then >> "%vbsGetPrivileges%" +@ECHO intCount = intCount + 1 >> "%vbsGetPrivileges%" +@ECHO if intcount = 4 then >> "%vbsGetPrivileges%" +@ECHO ' wscript.echo "position: " ^& intpos >> "%vbsGetPrivileges%" +@ECHO commandline = Mid(commandline,intpos+1) >> "%vbsGetPrivileges%" +@ECHO exit do >> "%vbsGetPrivileges%" +@ECHO else >> "%vbsGetPrivileges%" +@ECHO intstartsearch = intpos + 1 >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO intmax = intmax -1 >> "%vbsGetPrivileges%" +@ECHO Loop >> "%vbsGetPrivileges%" +@ECHO if intcount ^< 4 then >> "%vbsGetPrivileges%" +@ECHO err.raise vbObjectError + 1001, "vbsGetPrivileges", "failed to parse commandline" >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO commandline = pre ^& commandline >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "commandline: " ^& commandLine >>"%vbsGetPrivileges%" +@ECHO WScript.Echo "args: " ^& args >>"%vbsGetPrivileges%" +:skiptest + +@ECHO UAC.ShellExecute "cmd.exe", args, "", "runas", 1 >> "%vbsGetPrivileges%" +@REM @ECHO UAC.ShellExecute "%fullscriptname%", commandline, "", "runas", 1 >> "%vbsGetPrivileges%" +@ECHO Launching script "%fullscriptname%" in new window due to administrator elevation with args: "%*" @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" %* +@REM @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" !newcommandline! @EXIT /B +@REM buffer +@REM buffer :gotPrivileges @REM setlocal & pushd . @PUSHD . -@cd /d %~dp0 +@cd /d %winpath% @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" ( @DEL "%vbsGetPrivileges%" 1>nul 2>nul @SET arglist=%arglist:~14% + @SHIFT ) :skip_privileges @SET need_ps1=0 @REM we want the ps1 to exist even if the nextshell isn't powershell -@if not exist "%~dp0%~n0.ps1" ( +@if not exist "%scriptrootname%.ps1" ( @SET need_ps1=1 ) ELSE ( - fc "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >nul || goto different + fc "%fullscriptname%" "%scriptrootname%.ps1" >nul || goto different @REM @ECHO "files same" @SET need_ps1=0 ) @@ -223,74 +361,13 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET need_ps1=1 :pscontinue @IF !need_ps1!==1 ( - COPY "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >NUL + COPY "%fullscriptname%" "%scriptrootname%.ps1" >NUL ) @REM avoid using CALL to launch pwsh,tclsh etc - it will intercept some args such as /? @IF "!selected_shelltype_trimmed!"=="none" ( SET selected_shelltype_trimmed=pwsh ) - - - -@set argCount=30 -@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe -@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon -@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. - -@set tmpfile_base=%TEMP%\punkbatch_params -@call :getUniqueFile %tmpfile_base% ".txt" paramfile -@echo %paramfile% - -%= NOTE when we loop like this using the percent-n args, we lose unquoted separators such as comma and semicolon %= -@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 -@rem outer loop required to redirect all rem lines at once to file -@for %%x in (1) do @( - @for /L %%f in (1,1,%argCount%) do @( - @set "argnum=%%~nf" - @set "a1=%%1" - @rem @set "argname=%%!argnum!" - @rem @echo argname: !argname! - @call :rem_output !argnum! !a1! - @shift - ) -) > %paramfile% -@echo off - -@set "newcommandline= " - -@(set target=cmd_pwsh) -@if "%target%"=="cmd_pwsh" ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - @REM @echo ######### %%L - @rem call :buildcmdline newcommandline param "{" "}" - @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= - call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= - @rem @echo . - ) -) ELSE ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - call :buildcmdline newcommandline param - ) -) -@REM padding -SETLOCAL EnableDelayedExpansion - -@echo off -@IF EXIST %paramfile% ( - @DEL /F /Q %paramfile% -) -@IF EXIST %paramfile% ( - echo failed to delete %paramfile% - cat %paramfile% -) - - - @REM @SET "squoted_args=" @REM @for %%a in (%*) do @( @REM set "v=%%a" @@ -311,19 +388,24 @@ SETLOCAL EnableDelayedExpansion REM fallback to powershell if pwsh failed IF !pwshtest_exitcode!==0 ( @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; "%scriptrootname%.ps1" %arglist% - @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted - cmd /c pwsh -nop -nol -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + @rem pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" %arglist% %= ok =% + @rem cmd /c pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% SET task_exitcode=!errorlevel! ) ELSE ( REM TODO prompt user with option to call script to install pwsh using winget - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ) ELSE ( IF "!selected_shelltype_trimmed!"=="powershell" ( - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + @rem @echo powershell - !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% %= ok - this works =% + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ELSE ( IF "!selected_shelltype_trimmed!"=="wslbash" ( @@ -337,24 +419,23 @@ SETLOCAL EnableDelayedExpansion REM sh on windows uses /c/ instead of /mnt/c - at least if using msys. Todo, review what is the norm on windows with and without msys2,cygwin,wsl REM and what logic if any may be needed. For now sh with /c/xxx seems to work the same as sh with c:/xxx REM The compound statement with trailing call is required to stop batch termination confirmation, whilst still capturing exitcode - @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" - %selected_shellpath_trimmed% "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; + @REM @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" + !selected_shellpath_trimmed! "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; ) ELSE ( ECHO %fname% has invalid nextshelltype value %selected_shelltype% valid options are %validshelltypes% SET task_exitcode=66 @REM boundary padding - GOTO :exit_multishell ) ) ) ) @REM batch file library functions - @GOTO :endlib @REM padding @REM padding @REM padding +@REM padding %= ---------------------------------------------------------------------- =% @rem courtesy of dbenham @@ -458,6 +539,7 @@ do if not defined param1 set %%~"param1=%2%%~" rem %1 #%2# @exit /b +@rem padding @REM courtesy of: https://stackoverflow.com/users/463115/jeb :strlen stringVar returnVar @( @@ -506,6 +588,8 @@ do if not defined param1 set %%~"param1=%2%%~" ) @EXIT /B +@REM padding +@REM padding :getFileTail @REM return tail of file without any normalization e.g c:/punkshell/bin/Punk.cmd returns Punk.cmd even if file is punk.cmd @REM we can't use things such as %~nx1 as it can change capitalisation @@ -545,6 +629,7 @@ do if not defined param1 set %%~"param1=%2%%~" @EXIT /B @REM boundary padding @REM boundary padding +@REM boundary padding :getNormalizedScriptTail @SETLOCAL @SET "result=%~nx0" @@ -654,14 +739,14 @@ do if not defined param1 set %%~"param1=%2%%~" @REM boundary padding @REM boundary padding @REM boundary padding -@REM boundary padding :stringTrimTrailingUnderscores @SETLOCAL @SET "rtrn=%~2" @SET "string=%~1" @SET "trimstring=%~1" - @REM trim up to 63 underscores from the end of a string using string substitution + @REM trim up to 127 underscores from the end of a string using string substitution @SET "trimstring=%trimstring%###" + @SET "trimstring=%trimstring:________________________________________________________________###=###%" @SET "trimstring=%trimstring:________________________________###=###%" @SET "trimstring=%trimstring:________________###=###%" @SET "trimstring=%trimstring:________###=###%" @@ -707,10 +792,11 @@ do if not defined param1 set %%~"param1=%2%%~" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### # -- tcl script section # -- This is a punk multishell file -# -- Primary payload target is Tcl, with sh,bash,powershell as helpers -# -- but it may equally be used with any of these being the primary script. -# -- It is tuned to run when called as a batch file, a tcl script a sh/bash script or a pwsh/powershell script +# -- It is tuned to run (and possibly divert to different payload shell) when called from cmd.exe as a batch file, tclsh,sh,zsh,bash,perl or pwsh/powershell script # -- i.e it is a polyglot file. +# -- The payload target (by os) is defined in the nextshell block at the top which is constructed when generating the polyglot +# -- using the tcl 'dev scriptwrap.multishell' command in a tcl punk shell +# -- The payload can be tcl,perl,powershell/pwsh or zsh/bash. # -- The specific layout including some lines that appear just as comments is quite sensitive to change. # -- It can be called on unix or windows platforms with or without the interpreter being specified on the commandline. # -- e.g ./scriptname.cmd in sh or zsh or bash @@ -720,7 +806,13 @@ do if not defined param1 set %%~"param1=%2%%~" rename set ""; rename S set; set k {-- "$@" "a}; if {[info exists ::env($k)]} {unset ::env($k)} ;# tidyup and restore Hide :exit_multishell;Hide {<#};Hide '@ #--------------------------------------------------------------------- +puts "info script : [info script]" +#puts "argcount : $::argc" +#puts "argvalues: $::argv" +#puts "argv0 : $::argv0" +# -- --- --- --- --- --- --- --- --- --- --- --- #divert to configured nextshell +set script_as_called [info script] package require platform set plat_full [platform::generic] set plat [lindex [split $plat_full -] 0] @@ -733,6 +825,14 @@ set in_data 0 set nextshellpath "" set nextshelltype "" puts stderr "PLAT: $plat" +switch -glob -- $plat { + "msys" - "mingw*" { + set os "win32" + } + default { + set os $plat + } +} foreach ln [split $scriptdata \n] { if {[string trim $ln] eq ""} {continue} if {!$in_data} { @@ -740,14 +840,14 @@ foreach ln [split $scriptdata \n] { set in_data 1 } } else { - if {[string match "*@SET*nextshellpath?${plat}_*" $ln]} { + if {[string match "*@SET*nextshellpath?${os}_*" $ln]} { set lineparts [split $ln =] set tail [lindex $lineparts 1] set nextshellpath [string trimright $tail {_"}] if {$nextshellpath ne "" && $nextshelltype ne ""} { break } - } elseif {[string match "*@SET*nextshelltype?${plat}_*" $ln]} { + } elseif {[string match "*@SET*nextshelltype?${os}_*" $ln]} { set lineparts [split $ln =] set tail [lindex $lineparts 1] set nextshelltype [string trimright $tail {_"}] @@ -760,31 +860,111 @@ foreach ln [split $scriptdata \n] { } } if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { + set script_rootname [file rootname $script_as_called] if {$nextshelltype in "pwsh powershell"} { - set scrname [file rootname [info script]].ps1 - set arglist [list] - foreach a $::argv { - set a "'$a'" - lappend arglist $a + # experimental + set script_ps1 $script_rootname.ps1 + set arglist $::argv + + if {[file extension $script_as_called] ne ".ps1"} { + #we need to ensure .ps1 is up to date + set needs_updating 0 + if {![file exists $script_ps1]} { + set needs_updating 1 + } else { + #both exist + if {[file size $script_as_called] != [file size $script_ps1]} { + set needs_updating 1 + } else { + #both exist with same size - do full check that they're identical + catch {package require sha256} + if {[package provide sha256] ne ""} { + set h1 [sha2::sha256 -hex -file $script_as_called] + set h2 [sha2::sha256 -hex -file $script_ps1] + if {[string length $h1] != 64 || [string length $h2] != 64} { + set needs_updating 1 + } elseif {$h1 ne $h2} { + set needs_updating 1 + } + } else { + #manually compare - scripts aren't too big, so slurp and string compare is fine + set fd [open $script_as_called] + chan configure $fd -translation binary + set data1 [read $fd] + close $fd + set fd [open $script_ps1] + chan configure $fd -translation binary + set data2 [read $fd] + close $fd + if {![string equal $data1 $data2]} { + set needs_updating 1 + } + } + } + } + + if {$needs_updating} { + file copy -force $script_as_called $script_ps1 + } + } else { + #when called on the .ps1 - we assume it's up to date - review } + set scrname $script_ps1 + + #set arglist [list] + #foreach a $::argv { + # set a "'$a'" + # lappend arglist $a + #} } else { - set scrname [info script] + set scrname $script_as_called set arglist $::argv } - puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" #todo - handle /usr/bin/env #todo - exitcode - if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { - set nextshell_words [list $nextshellpath] - } else { - set nextshell_words $nextshellpath + #review - test spaced quoted words in nextshellpath? + # + #if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { + # set nextshell_words [list $nextshellpath] + #} else { + # set nextshell_words $nextshellpath + #} + + #perform any msys argument munging on a cmd/cmd.exe based nextshellpath before we convert the first word to an auto_exec path + switch -glob -- $plat { + "msys" - "mingw*" { + set cmdword [lindex $nextshellpath 0] + #we only act on cmd or cmd.exe - not a full path such as c:/WINDOWS/system32/cmd.exe + #the nextshellpath should generally be configured as cmd /c ... or cmd.exe ... but specifying it as a path could allow bypassing this un-munging. + #The un-munging only applies to msys/mingw, so such bypassing should be unnecessary - review + #maint: keep this munging in sync with zsh/bash and perl blocks which must also do msys mangling + if {[regexp {^cmd$|^cmd[.]exe$} $cmdword]} { + #need to deal with msys argument munging + #for now we only deal with /C or /c - todo - other cmd.exe flags? + #In this context we would usually only be using cmd.exe /c to launch older 'desktop' powershell to avoid spaced-argument problems - so we aren't expecting other flags + set new_nextshellpath [list $cmdword] + #for now - just do what zsh munging does - bash regex/string/array processing is tedious and footgunny for the unfamiliar (me), + #so determine the minimum viable case for code there, then port behaviour to perl/tcl msys munging sections. + foreach w [lrange $nextshellpath 1 end] { + if {[regexp {^/[Cc]$} $w]} { + lappend new_nextshellpath {//C} + } else { + lappend new_nextshellpath $w + } + } + set nextshellpath $new_nextshellpath + } + } } + set ns_firstword [lindex $nextshellpath 0] - if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { - set ns_firstword [string range $ns_firstword 1 end-1] - } + #review - is this test for extra layer of double quoting on first word really necessary? + #if we are treaing $nextshellpath as a tcl list - the first layer of double quotes will already have disappeared + ##if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { + ## set ns_firstword [string range $ns_firstword 1 end-1] + ##} - if {[string match {/*/env} $ns_firstword] && $::tcl_platform(platform) ne "windows"} { + if {$::tcl_platform(platform) ne "windows" && [string match {/*/env} $ns_firstword]} { set exec_part $nextshellpath } else { set epath [auto_execok $ns_firstword] @@ -794,6 +974,10 @@ if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { set exec_part [list {*}$epath {*}[lrange $nextshellpath 1 end]] } } + + + puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" + puts stdout "exec: $exec_part $scrname $arglist" catch {exec {*}$exec_part $scrname {*}$arglist <@stdin >@stdout 2>@stderr} emsg eopts if {[dict exists $eopts -errorcode]} { @@ -837,11 +1021,6 @@ namespace eval ::punk::multishell { } } # -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin Tcl Payload -#puts "script : [info script]" -#puts "argcount : $::argc" -#puts "argvalues: $::argv" -#puts "argv0 : $::argv0" -# -- --- --- --- --- --- --- --- --- --- --- --- # puts stderr "No tcl code for this script. Try another program such as zsh or bash or perl" @@ -867,8 +1046,11 @@ if {[::punk::multishell::is_main]} { HEREDOC1B_HIDE_FROM_BASH_AND_SH # Be wary of any non-trivial sed/awk etc - can be brittle to maintain across linux,freebsd,macosx due to differing implementations \ echo "var0: $0 @: $@" -# use oldschool backticks and sed - lowest common denominator \ -ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` +# use oldschool backticks and sed (posix - lowest common denominator) \ +# ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` \ +# some ps impls will return arguments - so last field not always appropriate \ +# some ps impls don't have -o (e.g cygwin) so ps_shellname may remain empty and emit an error \ +ps_shellname=`ps -o pid,comm -p $$ | awk '$1 != "PID" {print $2}'` # \ echo "shell from ps: $ps_shellname" # \ @@ -900,8 +1082,11 @@ pop() { } # ------------------------------------------------------------------------------ -# non-bash-like posix diversion \ -if [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ]; then +# non-bash-like posix diversion +# we don't use $BASH_VERSION/$ZSH_VERSION as these can still be set when for example +# sh is a symlink to bash (posix-mode bash - reduced bashism capabilities?) +# if our ps_shellname didn't contain a result, don't divert and risk looping +if [ -n "$ps_shellname" ] && [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ] ; then shift pop $# eval "$POP_EXPR" @@ -919,17 +1104,14 @@ if false==false # else { then : # - # zsh/bash \ shift && set -- "${@:1:$((${#@}-1))}" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### -# -- sh/bash script section -# -- leave as is if all that is required is launching the Tcl payload" -# -- -# -- Note that sh/bash script isn't called when running a .bat/.cmd from cmd.exe on windows by default -# -- adjust the %nextshell% value above -# -- if sh/bash scripting needs to run on windows too. +# -- zsh/bash script section # -- +# -- review - for zsh do we want to use: setopt KSH_ARRAYS ? +# -- arrays in bash 0-based vs 1-based in zsh +# -- stick to the @:i:len syntax which is same for both # ## ### ### ### ### ### ### ### ### ### ### ### ### ### plat=$(uname -s) #platform/system @@ -952,18 +1134,31 @@ elif [[ "$plat" == "MINGW64"* ]]; then elif [[ "$plat" == "CYGWIN_NT"* ]]; then os="win32" elif [[ "$plat" == "MSYS_NT"* ]]; then - #review.. - echo MSYS - #win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' + #review.. + + #Need to consider the difference between when msys2 was launched (which strips some paths and sets up the environment) + # vs if the msys2 sh was called - (actually bash) in which case paths will be different + + #wsl and cygwin or msys2 can commonly be problematic combinations - primarily due to path issues + #e.g "c:/windows/system32/" is quite likely in the path ahead of msys,git etc. + #e.g It means a /usr/bin/env bash call may launch the (linux elf) bash for wsl rather than the msys bash + # + + #msys provides win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' #bash reports $OSTYPE msys + + #there are statements around the web that cmd /c .. will work under msys2 + # - but from experience, it can be required to use cmd //c ... + # or MSYS2_ARG_CONV_ECL='*' cmd /c .. + # This seems to be because process arguments that look like unix paths are converted to windows paths :/ + #review! + os="win32" #review - need ps/sed/awk to determine shell? - interp = `ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` + interp=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` #use 'command -v' (shell builtin preferred over external which) shellpath=`command -v $interp` shellfolder="${shellpath%/*}" #avoid dependency on basename or dirname - #"c:/windows/system32/" is quite likely in the path ahead of msys,git etc. - #This breaks calls to various unix utils such as sed etc (wsl related?) export PATH="$shellfolder${PATH:+:${PATH}}" elif [[ "$OSTYPE" == "win32" ]]; then os="win32" @@ -985,6 +1180,8 @@ elif [[ "$ps_shellname" == "zsh" ]]; then else #fallback - doesn't seem to work in zsh - untested in early bash IFS=$'\n' arr_oslines=($shellconfiglines) + IFS=$' \t\n' + # review fi nextshellpath="" nextshelltype="" @@ -1008,8 +1205,55 @@ exitcode=0 #-- sh/bash launches nextscript here instead of shebang line at top if [[ "$nextshelltype" != "bash" && "$nextshelltype" != "none" ]]; then echo zsh/bash launching subshell of type: $nextshelltype shellpath: $nextshellpath on "$0" with args "$@" - #e.g /usr/bin/env tclsh "$0" "$@" - ${nextshellpath} "$0" "$@" + + script="$0" + if [[ "$nextshelltype" == "pwsh" || "$nextshelltype" == "powershell" ]]; then + #powershell requires the file extension to be .ps1 (always - on windows) + #on other platforms it's not required if a shebang line is used - but this script must be shebangless for portability and to maintain polyglot capabilities. + cmdpattern="[.]cmd$" + if [[ "$script" =~ $cmdpattern ]]; then + ps1script="${script%????}.ps1" + if ! cmp -s "$script" "$ps1script" ; then + #ps1script either different or missing + #on windows - batch script copies .cmd -> .ps1 if not identical + cp -f "$script" "$ps1script" + fi + script=$ps1script + fi + fi + if [[ "$plat" == "MSYS_NT"* ]]; then + + #we need to deal with MSYS argument munging + cmdpattern="^cmd.exe |^cmd " + #do not double quote cmdpattern - or it will be treated as literal string + if [[ "$nextshellpath" =~ $cmdpattern ]]; then + #for now - tell the user what's going on + echo "cmd call via msys detected. performing translation of /c to //c and escaping backslashes in script path" + #flags to cmd.exe such as /c are interpreted by msys as looking like a unix path + #review - for nextshellpath targets specified in the block for win32 - we don't expect unix paths (?) + #what about other flags? - can we just double up all forward slashes? + #maint: keep this munging in sync with the tcl block and perl block which must also do msys munging + nextshellpath="${nextshellpath// \/[cC] / \/\/c }" + # echo "new nextshellpath: ${nextshellpath}" + #don't double quote this + script=${script//\\/\\\\} + fi + echo "calling ${nextshellpath} $script $@" + + #load into array + cmd_array=($nextshellpath) + cmd_array+=("$script") #add script, which may contain spaces as a single entry ? + cmd_array+=( "$@" ) #add each element of args to array as a separate entry (equiv ? "${arr[@]}") + # printf "%s\n" "${cmd_array[@]}" + "${cmd_array[@]}" + + # this works to make nextshellpath run - but joins $@ members incorrectly + #eval ${nextshellpath} "$script" "$@" + else + #e.g /usr/bin/env tclsh "$0" "$@" + ${nextshellpath} "$script" "$@" + fi + exitcode=$? #echo "zsh/bash reporting exitcode: ${exitcode}" @@ -1328,18 +1572,44 @@ if ($match.Success) { $admininfo = $match.Groups[1].Value $asadmin = $admininfo.Contains("asadmin=1") if ($asadmin) { + if ($args[0] -eq "PUNK-ELEVATED") { + # May be present if launch and elevation was done via cmd.exe script + # shift away first arg + $newargs = $args | Select-Object -Skip 1 + } else { + $newargs = $args + } + # -Wait e.g for starting a service or other operations which remainder of script may depend on + $arguments = @("-NoProfile","-NoLogo", "-NoExit", "-ExecutionPolicy", "Bypass") + $arguments += @("-File", $($MyInvocation.MyCommand.Path)) + foreach ($a in $newargs) { + if ($a -match '\s') { + $arguments += "`"$a`"" + } else { + $arguments += $a + } + } if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { # If not elevated, relaunch with elevated privileges - # -Wait e.g for starting a service or other operations which remainder of script may depend on - $arguments = @("-NoProfile", "-NoExit", "-ExecutionPolicy", "Bypass") - $arguments += @("-File", $($MyInvocation.MyCommand.Path)) - $arguments += $args + Write-Host "Powershell elevating using start-process with -Verb RunAs" if ($PSVersionTable.PSEdition -eq 'Core') { Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -Wait -Verb RunAs } else { Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Wait -Verb RunAs } Exit # Exit the current non-elevated process + } else { + if ($args[0] -eq "PUNK-ELEVATED") { + #Already elevated (by cmd.exe) + #.. but it is impossible to modify or reassign the automatic $args variable + # so let's start yet another whole new process just to remove one leading argument so the custom script can operate on parameters cleanly - thanks powershell :/ + if ($PSVersionTable.PSEdition -eq 'Core') { + Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -NoNewWindow -Wait + } else { + Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -NoNewWindow -Wait + } + Exit + } } } } diff --git a/bin/tclargs.cmd b/bin/tclargs.cmd index 61bfbf51..080ab8b2 100644 --- a/bin/tclargs.cmd +++ b/bin/tclargs.cmd @@ -1,4 +1,4 @@ -: "punk MULTISHELL - shebangless polyglot for Tcl Perl sh bash cmd pwsh powershell" + "[rename set S;proc Hide shell_not_supported {proc $shell_not_supported args {}};Hide :]" + "\$(function : {<#pwsh#>})" + "perlhide" + qw^ +: "punk MULTISHELL - shebangless polyglot for Tcl Perl sh zsh/bash cmd pwsh powershell" + "[rename set S;proc Hide shell_not_supported {proc $shell_not_supported args {}};Hide :]" + "\$(function : {<#pwsh#>})" + "perlhide" + qw^ set -- "$@" "a=[Hide <#;Hide set;S 1 list]"; set -- : "$@";$1 = @' : heredoc1 - hide from powershell using @ and squote above. close sqote for unix shells + ' \ : .bat/.cmd launch section, leading colon hides from cmd, trailing slash hides next line from tcl + \ @@ -13,7 +13,7 @@ set -- "$@" "a=[Hide <#;Hide set;S 1 list]"; set -- : "$@";$1 = @' : Continuation char at end of this line and rem with curly-braces used to exlude Tcl from the whole cmd block \ : { @REM ############################################################################################################################ -@REM THIS IS A POLYGLOT SCRIPT - supporting payloads in Tcl, bash, (some sh) and/or powershelll (powershell.exe or pwsh.exe) +@REM THIS IS A POLYGLOT SCRIPT - supporting payloads in Tcl, zsh, bash, (sh diversion) and/or powershelll (powershell.exe or pwsh.exe) @REM It should remain portable between unix-like OSes & windows if the proper structure is maintained. @REM ############################################################################################################################ @rem ------------------------------------------------------------------------------------------------------------------------------- @@ -95,19 +95,19 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @REM If more than 64 chars needed for a target, it can still be done but overall script padding may need checking/adjusting @REM Supporting more explicit oses than those listed may also require script padding adjustment : <> -@SET "nextshellpath[win32___________]=tclsh___________________________________________________________" +@SET "nextshellpath[win32___________]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[win32___________]=tcl_____________" -@SET "nextshellpath[dragonflybsd____]=tclsh___________________________________________________________" +@SET "nextshellpath[dragonflybsd____]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[dragonflybsd____]=tcl_____________" -@SET "nextshellpath[freebsd_________]=tclsh___________________________________________________________" +@SET "nextshellpath[freebsd_________]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[freebsd_________]=tcl_____________" -@SET "nextshellpath[netbsd__________]=tclsh___________________________________________________________" +@SET "nextshellpath[netbsd__________]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[netbsd__________]=tcl_____________" -@SET "nextshellpath[linux___________]=tclsh___________________________________________________________" +@SET "nextshellpath[linux___________]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[linux___________]=tcl_____________" -@SET "nextshellpath[macosx__________]=tclsh___________________________________________________________" +@SET "nextshellpath[macosx__________]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[macosx__________]=tcl_____________" -@SET "nextshellpath[other___________]=tclsh___________________________________________________________" +@SET "nextshellpath[other___________]=tclsh___________________________________________________________________________________________________________________________" @SET "nextshelltype[other___________]=tcl_____________" : <> @rem asadmin is for automatic elevation to administrator. Separate window will be created (seems unavoidable with current elevation mechanism) and user will still get security prompt (probably reasonable). @@ -119,7 +119,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringTrimTrailingUnderscores %selected_shelltype% selected_shelltype_trimmed @REM @ECHO selected_shelltype_trimmed %selected_shelltype_trimmed% @SET "selected_shellpath=%nextshellpath[win32___________]%" -@CALL :stringTrimTrailingUnderscores %selected_shellpath% selected_shellpath_trimmed +@CALL :stringTrimTrailingUnderscores "%selected_shellpath%" selected_shellpath_trimmed @CALL SET "keyRemoved=%%validshelltypes:!selected_shelltype!=%%" @REM @ECHO keyremoved %keyRemoved% @REM Note that 'powershell' e.g v5 is just a fallback for when pwsh is not available @@ -151,6 +151,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET "winpath=%~dp0" %= e.g c:\punkshell\bin\ %= @SET "fname=%~nx0" @SET "scriptrootname=%~dp0%~n0" %= e.g c:\punkshell\bin\runtime (full path without extension) unavailable after shift, so store it =% +@SET "fullscriptname=%~dp0%~n0%~x0" @REM @ECHO fname %fname% @REM @ECHO winpath %winpath% @REM @ECHO commandlineascalled %0 @@ -162,6 +163,62 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringToUpper %~nx0 capscripttail @REM @ECHO capscriptname: %capscripttail% +@goto skip_parameter_wrangling +@set argCount=30 +@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe +@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon +@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. +@set tmpfile_base=%TEMP%\punkbatch_params +@call :getUniqueFile %tmpfile_base% ".txt" paramfile +@echo %paramfile% + +%= NOTE when we loop like this using the percent-n args and shift, we lose unquoted separators such as comma and semicolon %= +@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 +@rem outer loop required to redirect all rem lines at once to file +@for %%x in (1) do @( + @for /L %%f in (1,1,%argCount%) do @( + @set "argnum=%%~nf" + @set "a1=%%1" + @rem @set "argname=%%!argnum!" + @rem @echo argname: !argname! + @call :rem_output !argnum! !a1! + @shift + ) +) > %paramfile% +@echo off + +@set "newcommandline= " + +@(set target=cmd_pwsh) +@if "%target%"=="cmd_pwsh" ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + @REM @echo ######### %%L + @rem call :buildcmdline newcommandline param "{" "}" + @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= + call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= + @rem @echo . + ) +) ELSE ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + call :buildcmdline newcommandline param + ) +) +@REM padding +SETLOCAL EnableDelayedExpansion +@echo off +@IF EXIST %paramfile% ( + @DEL /F /Q %paramfile% +) +@IF EXIST %paramfile% ( + echo failed to delete %paramfile% + cat %paramfile% +) +:skip_parameter_wrangling + @IF "%nftail%"=="%capscripttail%" ( @ECHO forcing asadmin=1 due to file name on filesystem being uppercase @SET "asadmin=1" @@ -189,31 +246,112 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% :getPrivileges @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" (echo PUNK-ELEVATED & shift /1 & goto :gotPrivileges ) @ECHO Set UAC = CreateObject^("Shell.Application"^) > "%vbsGetPrivileges%" -@ECHO args = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@ECHO pre = "/c %fullscriptname% PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@REM @echo pre = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@echo args = pre >> "%vbsGetPrivileges%" @ECHO For Each strArg in WScript.Arguments >> "%vbsGetPrivileges%" -@ECHO args = args ^& strArg ^& " " >> "%vbsGetPrivileges%" +@ECHO args = args ^& Chr(34) ^& strArg ^& Chr(34) ^& " " >> "%vbsGetPrivileges%" @ECHO Next >> "%vbsGetPrivileges%" -@ECHO UAC.ShellExecute "%~dp0%~n0%~x0", args, "", "runas", 1 >> "%vbsGetPrivileges%" -@ECHO Launching script in new window due to administrator elevation +@GOTO skiptest + +%= Option Explicit =% +%= We need a child process to locate the current script. =% +@ECHO Const FLAG_PROCESS = "winver.exe" >> "%vbsGetPrivileges%" + +%= ' WMI constants %= +@ECHO Const wbemFlagForwardOnly = 32 >> "%vbsGetPrivileges%" + +%=' Generate a unique value to be used as a flag =% +@ECHO Dim guid >> "%vbsGetPrivileges% +@ECHO guid = Left(CreateObject("Scriptlet.TypeLib").GUID,38) >> "%vbsGetPrivileges%" + +%= ' Start a process using the indicated flag inside its command line =% +@ECHO WScript.CreateObject("WScript.Shell").Run """" ^& FLAG_PROCESS ^& """ " ^& guid, 0, False >> "%vbsGetPrivileges%" + +%= ' To retrieve process information a WMI reference is needed =% +@ECHO Dim wmi >> "%vbsGetPrivileges%" +@ECHO Set wmi = GetObject("winmgmts:{impersonationLevel=impersonate}^!\\.\root\cimv2") >> "%vbsGetPrivileges%" + +%= ' Query the list of processes with the flag in its command line, retrieve the =% +%= ' process ID of its parent process ( our script! ) and terminate the process =% +@ECHO Dim colProcess, process, myProcessID >> "%vbsGetPrivileges%" +@ECHO Set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT ParentProcessID From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE Name='" ^& FLAG_PROCESS ^& "' " ^& _>> "%vbsGetPrivileges%" +@ECHO "AND CommandLine LIKE '%%" ^& guid ^& "%%'" _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO myProcessID = process.ParentProcessID >> "%vbsGetPrivileges%" +@ECHO process.Terminate >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" + +%= ' Knowing the process id of our script we can query the process list =% +%= ' and retrieve its command line =% +@ECHO Dim commandLine >> "%vbsGetPrivileges%" +@ECHO set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT CommandLine From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE ProcessID=" ^& myProcessID _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO commandLine = process.CommandLine >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "raw commandline: " ^& commandLine >>"%vbsGetPrivileges%" + +%= ' Done =% +@ECHO intpos = 0 >> "%vbsGetPrivileges%" +@ECHO intCount = 0 >> "%vbsGetPrivileges%" +@ECHO intstartsearch = 1 >> "%vbsGetPrivileges%" +@ECHO intmax = 100 >> "%vbsGetPrivileges%" +@ECHO do While intCount ^< 4 and intmax ^> 0 >> "%vbsGetPrivileges%" +@ECHO intpos = InStr(intstartsearch, commandline, """") >> "%vbsGetPrivileges%" +@ECHO if intpos ^<^> 0 then >> "%vbsGetPrivileges%" +@ECHO intCount = intCount + 1 >> "%vbsGetPrivileges%" +@ECHO if intcount = 4 then >> "%vbsGetPrivileges%" +@ECHO ' wscript.echo "position: " ^& intpos >> "%vbsGetPrivileges%" +@ECHO commandline = Mid(commandline,intpos+1) >> "%vbsGetPrivileges%" +@ECHO exit do >> "%vbsGetPrivileges%" +@ECHO else >> "%vbsGetPrivileges%" +@ECHO intstartsearch = intpos + 1 >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO intmax = intmax -1 >> "%vbsGetPrivileges%" +@ECHO Loop >> "%vbsGetPrivileges%" +@ECHO if intcount ^< 4 then >> "%vbsGetPrivileges%" +@ECHO err.raise vbObjectError + 1001, "vbsGetPrivileges", "failed to parse commandline" >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO commandline = pre ^& commandline >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "commandline: " ^& commandLine >>"%vbsGetPrivileges%" +@ECHO WScript.Echo "args: " ^& args >>"%vbsGetPrivileges%" +:skiptest + +@ECHO UAC.ShellExecute "cmd.exe", args, "", "runas", 1 >> "%vbsGetPrivileges%" +@REM @ECHO UAC.ShellExecute "%fullscriptname%", commandline, "", "runas", 1 >> "%vbsGetPrivileges%" +@ECHO Launching script "%fullscriptname%" in new window due to administrator elevation with args: "%*" @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" %* +@REM @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" !newcommandline! @EXIT /B +@REM buffer +@REM buffer :gotPrivileges @REM setlocal & pushd . @PUSHD . -@cd /d %~dp0 +@cd /d %winpath% @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" ( @DEL "%vbsGetPrivileges%" 1>nul 2>nul @SET arglist=%arglist:~14% + @SHIFT ) :skip_privileges @SET need_ps1=0 @REM we want the ps1 to exist even if the nextshell isn't powershell -@if not exist "%~dp0%~n0.ps1" ( +@if not exist "%scriptrootname%.ps1" ( @SET need_ps1=1 ) ELSE ( - fc "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >nul || goto different + fc "%fullscriptname%" "%scriptrootname%.ps1" >nul || goto different @REM @ECHO "files same" @SET need_ps1=0 ) @@ -223,73 +361,13 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET need_ps1=1 :pscontinue @IF !need_ps1!==1 ( - COPY "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >NUL + COPY "%fullscriptname%" "%scriptrootname%.ps1" >NUL ) @REM avoid using CALL to launch pwsh,tclsh etc - it will intercept some args such as /? @IF "!selected_shelltype_trimmed!"=="none" ( SET selected_shelltype_trimmed=pwsh ) - - - -@set argCount=30 -@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe -@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon -@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. - -@set tmpfile_base=%TEMP%\punkbatch_params -@call :getUniqueFile %tmpfile_base% ".txt" paramfile -@echo %paramfile% - -%= NOTE when we loop like this using the percent-n args, we lose unquoted separators such as comma and semicolon %= -@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 -@rem outer loop required to redirect all rem lines at once to file -@for %%x in (1) do @( - @for /L %%f in (1,1,%argCount%) do @( - @set "argnum=%%~nf" - @set "a1=%%1" - @rem @set "argname=%%!argnum!" - @rem @echo argname: !argname! - @call :rem_output !argnum! !a1! - @shift - ) -) > %paramfile% -@echo off - -@set "newcommandline= " - -@(set target=cmd_pwsh) -@if "%target%"=="cmd_pwsh" ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - @REM @echo ######### %%L - @rem call :buildcmdline newcommandline param "{" "}" - call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell %= - @rem @echo . - ) -) ELSE ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - call :buildcmdline newcommandline param - ) -) -@REM padding -SETLOCAL EnableDelayedExpansion - -@echo off -@IF EXIST %paramfile% ( - @DEL /F /Q %paramfile% -) -@IF EXIST %paramfile% ( - echo failed to delete %paramfile% - cat %paramfile% -) - - - @REM @SET "squoted_args=" @REM @for %%a in (%*) do @( @REM set "v=%%a" @@ -303,25 +381,31 @@ SETLOCAL EnableDelayedExpansion @IF "!selected_shelltype_trimmed!"=="pwsh" ( REM pwsh vs powershell hasn't been tested because we didn't need to copy cmd to ps1 this time REM test availability of preferred option of powershell7+ pwsh + REM when run without cmd.exe - pwsh will receive the semicolon (for cmd.exe unquoted semicolon and comma are separators that aren't seen in positional arguments) pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted 2>NUL; write-host "statusmessage: pwsh-found" >NUL SET pwshtest_exitcode=!errorlevel! REM ECHO pwshtest_exitcode !pwshtest_exitcode! REM fallback to powershell if pwsh failed IF !pwshtest_exitcode!==0 ( @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; "%scriptrootname%.ps1" %arglist% - @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted - cmd /c pwsh -nop -nol -ExecutionPolicy bypass -c "%scriptrootname%.ps1" !newcommandline! + @rem pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" %arglist% %= ok =% + @rem cmd /c pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% SET task_exitcode=!errorlevel! ) ELSE ( REM TODO prompt user with option to call script to install pwsh using winget - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ) ELSE ( IF "!selected_shelltype_trimmed!"=="powershell" ( - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + @rem @echo powershell - !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% %= ok - this works =% + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ELSE ( IF "!selected_shelltype_trimmed!"=="wslbash" ( @@ -335,24 +419,23 @@ SETLOCAL EnableDelayedExpansion REM sh on windows uses /c/ instead of /mnt/c - at least if using msys. Todo, review what is the norm on windows with and without msys2,cygwin,wsl REM and what logic if any may be needed. For now sh with /c/xxx seems to work the same as sh with c:/xxx REM The compound statement with trailing call is required to stop batch termination confirmation, whilst still capturing exitcode - @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" - %selected_shellpath_trimmed% "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; + @REM @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" + !selected_shellpath_trimmed! "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; ) ELSE ( ECHO %fname% has invalid nextshelltype value %selected_shelltype% valid options are %validshelltypes% SET task_exitcode=66 @REM boundary padding - GOTO :exit_multishell ) ) ) ) @REM batch file library functions - @GOTO :endlib @REM padding @REM padding @REM padding +@REM padding %= ---------------------------------------------------------------------- =% @rem courtesy of dbenham @@ -373,6 +456,7 @@ endlocal & set "%~3=%rtn%" exit /b %= ---------------------------------------------------------------------- =% +@REM padding :buildcmdline cmdlinevar paramvar wrapA wrapB %= quoting for cmd.exe /c pwsh -nop !args! =% @SETLOCAL EnableDelayedExpansion @@ -455,6 +539,7 @@ do if not defined param1 set %%~"param1=%2%%~" rem %1 #%2# @exit /b +@rem padding @REM courtesy of: https://stackoverflow.com/users/463115/jeb :strlen stringVar returnVar @( @@ -503,6 +588,8 @@ do if not defined param1 set %%~"param1=%2%%~" ) @EXIT /B +@REM padding +@REM padding :getFileTail @REM return tail of file without any normalization e.g c:/punkshell/bin/Punk.cmd returns Punk.cmd even if file is punk.cmd @REM we can't use things such as %~nx1 as it can change capitalisation @@ -542,6 +629,7 @@ do if not defined param1 set %%~"param1=%2%%~" @EXIT /B @REM boundary padding @REM boundary padding +@REM boundary padding :getNormalizedScriptTail @SETLOCAL @SET "result=%~nx0" @@ -650,13 +738,15 @@ do if not defined param1 set %%~"param1=%2%%~" @REM boundary padding @REM boundary padding @REM boundary padding +@REM boundary padding :stringTrimTrailingUnderscores @SETLOCAL @SET "rtrn=%~2" @SET "string=%~1" @SET "trimstring=%~1" - @REM trim up to 63 underscores from the end of a string using string substitution + @REM trim up to 127 underscores from the end of a string using string substitution @SET "trimstring=%trimstring%###" + @SET "trimstring=%trimstring:________________________________________________________________###=###%" @SET "trimstring=%trimstring:________________________________###=###%" @SET "trimstring=%trimstring:________________###=###%" @SET "trimstring=%trimstring:________###=###%" @@ -702,10 +792,11 @@ do if not defined param1 set %%~"param1=%2%%~" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### # -- tcl script section # -- This is a punk multishell file -# -- Primary payload target is Tcl, with sh,bash,powershell as helpers -# -- but it may equally be used with any of these being the primary script. -# -- It is tuned to run when called as a batch file, a tcl script a sh/bash script or a pwsh/powershell script +# -- It is tuned to run (and possibly divert to different payload shell) when called from cmd.exe as a batch file, tclsh,sh,zsh,bash,perl or pwsh/powershell script # -- i.e it is a polyglot file. +# -- The payload target (by os) is defined in the nextshell block at the top which is constructed when generating the polyglot +# -- using the tcl 'dev scriptwrap.multishell' command in a tcl punk shell +# -- The payload can be tcl,perl,powershell/pwsh or zsh/bash. # -- The specific layout including some lines that appear just as comments is quite sensitive to change. # -- It can be called on unix or windows platforms with or without the interpreter being specified on the commandline. # -- e.g ./scriptname.cmd in sh or zsh or bash @@ -715,7 +806,13 @@ do if not defined param1 set %%~"param1=%2%%~" rename set ""; rename S set; set k {-- "$@" "a}; if {[info exists ::env($k)]} {unset ::env($k)} ;# tidyup and restore Hide :exit_multishell;Hide {<#};Hide '@ #--------------------------------------------------------------------- +puts "info script : [info script]" +#puts "argcount : $::argc" +#puts "argvalues: $::argv" +#puts "argv0 : $::argv0" +# -- --- --- --- --- --- --- --- --- --- --- --- #divert to configured nextshell +set script_as_called [info script] package require platform set plat_full [platform::generic] set plat [lindex [split $plat_full -] 0] @@ -728,6 +825,14 @@ set in_data 0 set nextshellpath "" set nextshelltype "" puts stderr "PLAT: $plat" +switch -glob -- $plat { + "msys" - "mingw*" { + set os "win32" + } + default { + set os $plat + } +} foreach ln [split $scriptdata \n] { if {[string trim $ln] eq ""} {continue} if {!$in_data} { @@ -735,14 +840,14 @@ foreach ln [split $scriptdata \n] { set in_data 1 } } else { - if {[string match "*@SET*nextshellpath?${plat}_*" $ln]} { + if {[string match "*@SET*nextshellpath?${os}_*" $ln]} { set lineparts [split $ln =] set tail [lindex $lineparts 1] set nextshellpath [string trimright $tail {_"}] if {$nextshellpath ne "" && $nextshelltype ne ""} { break } - } elseif {[string match "*@SET*nextshelltype?${plat}_*" $ln]} { + } elseif {[string match "*@SET*nextshelltype?${os}_*" $ln]} { set lineparts [split $ln =] set tail [lindex $lineparts 1] set nextshelltype [string trimright $tail {_"}] @@ -755,31 +860,111 @@ foreach ln [split $scriptdata \n] { } } if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { + set script_rootname [file rootname $script_as_called] if {$nextshelltype in "pwsh powershell"} { - set scrname [file rootname [info script]].ps1 - set arglist [list] - foreach a $::argv { - set a "'$a'" - lappend arglist $a + # experimental + set script_ps1 $script_rootname.ps1 + set arglist $::argv + + if {[file extension $script_as_called] ne ".ps1"} { + #we need to ensure .ps1 is up to date + set needs_updating 0 + if {![file exists $script_ps1]} { + set needs_updating 1 + } else { + #both exist + if {[file size $script_as_called] != [file size $script_ps1]} { + set needs_updating 1 + } else { + #both exist with same size - do full check that they're identical + catch {package require sha256} + if {[package provide sha256] ne ""} { + set h1 [sha2::sha256 -hex -file $script_as_called] + set h2 [sha2::sha256 -hex -file $script_ps1] + if {[string length $h1] != 64 || [string length $h2] != 64} { + set needs_updating 1 + } elseif {$h1 ne $h2} { + set needs_updating 1 + } + } else { + #manually compare - scripts aren't too big, so slurp and string compare is fine + set fd [open $script_as_called] + chan configure $fd -translation binary + set data1 [read $fd] + close $fd + set fd [open $script_ps1] + chan configure $fd -translation binary + set data2 [read $fd] + close $fd + if {![string equal $data1 $data2]} { + set needs_updating 1 + } + } + } + } + + if {$needs_updating} { + file copy -force $script_as_called $script_ps1 + } + } else { + #when called on the .ps1 - we assume it's up to date - review } + set scrname $script_ps1 + + #set arglist [list] + #foreach a $::argv { + # set a "'$a'" + # lappend arglist $a + #} } else { - set scrname [info script] + set scrname $script_as_called set arglist $::argv } - puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" #todo - handle /usr/bin/env #todo - exitcode - if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { - set nextshell_words [list $nextshellpath] - } else { - set nextshell_words $nextshellpath + #review - test spaced quoted words in nextshellpath? + # + #if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { + # set nextshell_words [list $nextshellpath] + #} else { + # set nextshell_words $nextshellpath + #} + + #perform any msys argument munging on a cmd/cmd.exe based nextshellpath before we convert the first word to an auto_exec path + switch -glob -- $plat { + "msys" - "mingw*" { + set cmdword [lindex $nextshellpath 0] + #we only act on cmd or cmd.exe - not a full path such as c:/WINDOWS/system32/cmd.exe + #the nextshellpath should generally be configured as cmd /c ... or cmd.exe ... but specifying it as a path could allow bypassing this un-munging. + #The un-munging only applies to msys/mingw, so such bypassing should be unnecessary - review + #maint: keep this munging in sync with zsh/bash and perl blocks which must also do msys mangling + if {[regexp {^cmd$|^cmd[.]exe$} $cmdword]} { + #need to deal with msys argument munging + #for now we only deal with /C or /c - todo - other cmd.exe flags? + #In this context we would usually only be using cmd.exe /c to launch older 'desktop' powershell to avoid spaced-argument problems - so we aren't expecting other flags + set new_nextshellpath [list $cmdword] + #for now - just do what zsh munging does - bash regex/string/array processing is tedious and footgunny for the unfamiliar (me), + #so determine the minimum viable case for code there, then port behaviour to perl/tcl msys munging sections. + foreach w [lrange $nextshellpath 1 end] { + if {[regexp {^/[Cc]$} $w]} { + lappend new_nextshellpath {//C} + } else { + lappend new_nextshellpath $w + } + } + set nextshellpath $new_nextshellpath + } + } } + set ns_firstword [lindex $nextshellpath 0] - if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { - set ns_firstword [string range $ns_firstword 1 end-1] - } + #review - is this test for extra layer of double quoting on first word really necessary? + #if we are treaing $nextshellpath as a tcl list - the first layer of double quotes will already have disappeared + ##if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { + ## set ns_firstword [string range $ns_firstword 1 end-1] + ##} - if {[string match {/*/env} $ns_firstword] && $::tcl_platform(platform) ne "windows"} { + if {$::tcl_platform(platform) ne "windows" && [string match {/*/env} $ns_firstword]} { set exec_part $nextshellpath } else { set epath [auto_execok $ns_firstword] @@ -789,6 +974,10 @@ if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { set exec_part [list {*}$epath {*}[lrange $nextshellpath 1 end]] } } + + + puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" + puts stdout "exec: $exec_part $scrname $arglist" catch {exec {*}$exec_part $scrname {*}$arglist <@stdin >@stdout 2>@stderr} emsg eopts if {[dict exists $eopts -errorcode]} { @@ -832,34 +1021,19 @@ namespace eval ::punk::multishell { } } # -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin Tcl Payload -#puts "script : [info script]" -#puts "argcount : $::argc" -#puts "argvalues: $::argv" -#puts "argv0 : $::argv0" -# -- --- --- --- --- --- --- --- --- --- --- --- # -puts stdout "::argc" -puts stdout $::argc -puts stdout "::argv" -puts stdout "$::argv" -puts stdout ----------------------- -foreach a $::argv { - puts stdout $a -} +puts stdout "::argc" +puts stdout $::argc +puts stdout "::argv" +puts stdout "$::argv" +puts stdout ----------------------- +foreach a $::argv { + puts stdout $a +} puts stdout -done- # -# -# - -# -# - - -# -# - # -- --- --- --- --- --- --- --- --- --- --- --- # -- Best practice is to always return or exit above, or just by leaving the below defaults in place. @@ -880,40 +1054,76 @@ if {[::punk::multishell::is_main]} { HEREDOC1B_HIDE_FROM_BASH_AND_SH # Be wary of any non-trivial sed/awk etc - can be brittle to maintain across linux,freebsd,macosx due to differing implementations \ echo "var0: $0 @: $@" -# echo "script: `echo $0 | sed 's/^-//'`" -# use oldschool backticks and sed - lowest common denominator \ -# echo "shell: " `ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` -# zsh diversion \ -# if [[ "$argv[*]" != "[*]" ]]; then /usr/bin/env bash "$0" "${argv[@]:2:$((${#argv[@]}-2))}"; exit $?; fi -# \ -ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` -# \ -echo "shell from ps: $ps_shellname argc: ${#@} inner: ${@:2:$((${#@}-2))}" -# non-bash-like diversion \ -if [[ "$ps_shellname" != "bash" && "$ps_shellname" != "zsh" ]]; then /usr/bin/env bash "$0" "${@:2:$((${#@}-2))}"; exit $?; fi -# sh/bash (or zsh?) \ -shift && set -- "${@:1:$((${#@}-1))}" +# use oldschool backticks and sed (posix - lowest common denominator) \ +# ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` \ +# some ps impls will return arguments - so last field not always appropriate \ +# some ps impls don't have -o (e.g cygwin) so ps_shellname may remain empty and emit an error \ +ps_shellname=`ps -o pid,comm -p $$ | awk '$1 != "PID" {print $2}'` # \ -#echo "shell:" `ps -o args= $$ | sed -E 's/^.*\/|^-//' | awk '{print $1}'` +echo "shell from ps: $ps_shellname" # \ echo "args: $@" +# ------------------------------------------------------------------------------ +# -- This if block wraps posix sh diversion section - only needed if Tcl didn't exit or return above. +if false==false # else { + then + : # + +# https://gist.github.com/fcard/e26c5a1f7c8b0674c17c7554fb0cd35c0 (MIT lic) +# https://stackoverflow.com/questions/63864755/remove-last-argument-in-shell-script-posix +# posix compliant pop +pop() { + __pop_n=$(($1 - ${2:-1})) + if [ -n "$ZSH_VERSION" -o -n "$BASH_VERSION" ]; then + POP_EXPR='set -- "${@:1:'$__pop_n'}"' + elif [ $__pop_n -ge 500 ]; then + POP_EXPR="set -- $(seq -s " " 1 $__pop_n | sed 's/[0-9]\+/"${\0}"/g')" + else + __pop_index=0 + __pop_arguments="" + while [ $__pop_index -lt $__pop_n ]; do + __pop_index=$((__pop_index+1)) + __pop_arguments="$__pop_arguments \"\${$__pop_index}\"" + done + POP_EXPR="set -- $__pop_arguments" + fi +} +# ------------------------------------------------------------------------------ + +# non-bash-like posix diversion +# we don't use $BASH_VERSION/$ZSH_VERSION as these can still be set when for example +# sh is a symlink to bash (posix-mode bash - reduced bashism capabilities?) +# if our ps_shellname didn't contain a result, don't divert and risk looping +if [ -n "$ps_shellname" ] && [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ] ; then + shift + pop $# + eval "$POP_EXPR" + echo "divert to bash $0 $@" + + /usr/bin/env bash "$0" "$@" + exit $? +fi +# close false==false block +fi +# close tcl wrap } # ------------------------------------------------------ -# -- This if block only needed if Tcl didn't exit or return above. +# -- This if block wraps whole zsh/bash and perl sections - only needed if Tcl didn't exit or return above. if false==false # else { then : # + +# zsh/bash \ +shift && set -- "${@:1:$((${#@}-1))}" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### -# -- sh/bash script section -# -- leave as is if all that is required is launching the Tcl payload" -# -- -# -- Note that sh/bash script isn't called when running a .bat/.cmd from cmd.exe on windows by default -# -- adjust the %nextshell% value above -# -- if sh/bash scripting needs to run on windows too. +# -- zsh/bash script section # -- +# -- review - for zsh do we want to use: setopt KSH_ARRAYS ? +# -- arrays in bash 0-based vs 1-based in zsh +# -- stick to the @:i:len syntax which is same for both # ## ### ### ### ### ### ### ### ### ### ### ### ### ### plat=$(uname -s) #platform/system -if [[ "$plat" = "Linux"* ]]; then +if [[ "$plat" == "Linux"* ]]; then os="linux" elif [[ "$plat" == "Darwin"* ]]; then os="macosx" @@ -925,25 +1135,38 @@ elif [[ "$plat" == "NetBSD"* ]]; then os="netbsd" elif [[ "$plat" == "OpenBSD"* ]]; then os="openbsd" -elif [[ "$plat" = "MINGW32"* ]]; then +elif [[ "$plat" == "MINGW32"* ]]; then os="win32" -elif [[ "$plat" = "MINGW64"* ]]; then +elif [[ "$plat" == "MINGW64"* ]]; then os="win32" -elif [[ "$plat" = "CYGWIN_NT"* ]]; then +elif [[ "$plat" == "CYGWIN_NT"* ]]; then os="win32" elif [[ "$plat" == "MSYS_NT"* ]]; then - #review.. - echo MSYS - #win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' + #review.. + + #Need to consider the difference between when msys2 was launched (which strips some paths and sets up the environment) + # vs if the msys2 sh was called - (actually bash) in which case paths will be different + + #wsl and cygwin or msys2 can commonly be problematic combinations - primarily due to path issues + #e.g "c:/windows/system32/" is quite likely in the path ahead of msys,git etc. + #e.g It means a /usr/bin/env bash call may launch the (linux elf) bash for wsl rather than the msys bash + # + + #msys provides win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' #bash reports $OSTYPE msys + + #there are statements around the web that cmd /c .. will work under msys2 + # - but from experience, it can be required to use cmd //c ... + # or MSYS2_ARG_CONV_ECL='*' cmd /c .. + # This seems to be because process arguments that look like unix paths are converted to windows paths :/ + #review! + os="win32" #review - need ps/sed/awk to determine shell? - interp = `ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` + interp=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` #use 'command -v' (shell builtin preferred over external which) shellpath=`command -v $interp` shellfolder="${shellpath%/*}" #avoid dependency on basename or dirname - #"c:/windows/system32/" is quite likely in the path ahead of msys,git etc. - #This breaks calls to various unix utils such as sed etc (wsl related?) export PATH="$shellfolder${PATH:+:${PATH}}" elif [[ "$OSTYPE" == "win32" ]]; then os="win32" @@ -965,6 +1188,8 @@ elif [[ "$ps_shellname" == "zsh" ]]; then else #fallback - doesn't seem to work in zsh - untested in early bash IFS=$'\n' arr_oslines=($shellconfiglines) + IFS=$' \t\n' + # review fi nextshellpath="" nextshelltype="" @@ -975,62 +1200,89 @@ for ln in "${arr_oslines[@]}"; do pathraw="${splitln%%\"*}" #take everything before the quote - use %% to get longest match #remove trailing underscores (% means must match at end) nextshellpath="${pathraw/%_*/}" - echo "nextshellpath: $nextshellpath" + # echo "nextshellpath: $nextshellpath" elif [[ "$ln" == *"nextshelltype"* ]]; then splitln="${ln#*=}" typeraw="${splitln%%\"*}" nextshelltype="${typeraw/%_*/}" - echo "nextshelltype: $nextshelltype" + # echo "nextshelltype: $nextshelltype" fi done exitcode=0 #-- sh/bash launches nextscript here instead of shebang line at top if [[ "$nextshelltype" != "bash" && "$nextshelltype" != "none" ]]; then - echo bash launching subshell of type: $nextshelltype shellpath: $nextshellpath on "$0" with args "$@" - #e.g /usr/bin/env tclsh "$0" "$@" - ${nextshellpath} "$0" "$@" + echo zsh/bash launching subshell of type: $nextshelltype shellpath: $nextshellpath on "$0" with args "$@" + + script="$0" + if [[ "$nextshelltype" == "pwsh" || "$nextshelltype" == "powershell" ]]; then + #powershell requires the file extension to be .ps1 (always - on windows) + #on other platforms it's not required if a shebang line is used - but this script must be shebangless for portability and to maintain polyglot capabilities. + cmdpattern="[.]cmd$" + if [[ "$script" =~ $cmdpattern ]]; then + ps1script="${script%????}.ps1" + if ! cmp -s "$script" "$ps1script" ; then + #ps1script either different or missing + #on windows - batch script copies .cmd -> .ps1 if not identical + cp -f "$script" "$ps1script" + fi + script=$ps1script + fi + fi + if [[ "$plat" == "MSYS_NT"* ]]; then + + #we need to deal with MSYS argument munging + cmdpattern="^cmd.exe |^cmd " + #do not double quote cmdpattern - or it will be treated as literal string + if [[ "$nextshellpath" =~ $cmdpattern ]]; then + #for now - tell the user what's going on + echo "cmd call via msys detected. performing translation of /c to //c and escaping backslashes in script path" + #flags to cmd.exe such as /c are interpreted by msys as looking like a unix path + #review - for nextshellpath targets specified in the block for win32 - we don't expect unix paths (?) + #what about other flags? - can we just double up all forward slashes? + #maint: keep this munging in sync with the tcl block and perl block which must also do msys munging + nextshellpath="${nextshellpath// \/[cC] / \/\/c }" + # echo "new nextshellpath: ${nextshellpath}" + #don't double quote this + script=${script//\\/\\\\} + fi + echo "calling ${nextshellpath} $script $@" + + #load into array + cmd_array=($nextshellpath) + cmd_array+=("$script") #add script, which may contain spaces as a single entry ? + cmd_array+=( "$@" ) #add each element of args to array as a separate entry (equiv ? "${arr[@]}") + # printf "%s\n" "${cmd_array[@]}" + "${cmd_array[@]}" + + # this works to make nextshellpath run - but joins $@ members incorrectly + #eval ${nextshellpath} "$script" "$@" + else + #e.g /usr/bin/env tclsh "$0" "$@" + ${nextshellpath} "$script" "$@" + fi + exitcode=$? - #echo "sh/bash reporting exitcode: ${exitcode}" + #echo "zsh/bash reporting exitcode: ${exitcode}" exit $exitcode #-- override exitcode example #exit 66 else #already in bash - don't launch another process or we would loop - #echo "bash payload" + #echo "zsh/bash payload" : fi -# -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin sh Payload -#printf "start of bash or sh code" +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin zsh Payload +#printf "start of bash or zsh code" # echo "No bash code for this script. Try another program such as perl or tcl" >&2 # -# -# -# -- --- --- --- --- --- --- --- -# -#-- sh/bash launches Tcl here instead of shebang line at top -#-- use exec to use exitcode (if any) directly from the tcl script -#exec /usr/bin/env tclsh "$0" "$@" -#-- alternative - can run sh/bash script after the tcl call. -#/usr/bin/env tclsh "$0" "$@" -#exitcode=$? -#echo "sh/bash reporting tcl exitcode: ${exitcode}" -#-- override exitcode example -#exit 66 -# -# -- --- --- --- --- --- --- --- - -# -# - - -#printf "sh/bash done \n" -# -- --- --- --- --- --- --- --- --- --- --- --- --- ---end sh Payload +#printf "zsh/bash done \n" +# -- --- --- --- --- --- --- --- --- --- --- --- --- ---end zsh Payload #------------------------------------------------------ fi exit ${exitcode} @@ -1066,7 +1318,6 @@ print "os $os\n"; # -- --- --- - my $i =1; foreach my $a(@ARGV) { print "Arg # $i: $a\n"; @@ -1076,21 +1327,11 @@ foreach my $a(@ARGV) { print STDERR "No perl code for this script. Try another program such as tcl or bash"; # -# -# - - - # -- --- --- --- --- --- --- --- -# #$exit_code=system("tclsh", $scriptname, @ARGV); #print "perl reporting tcl exitcode: $exit_code"; -# # -- --- --- --- --- --- --- --- -# -# - # -- --- --- --- --- --- --- --- --- --- --- --- --- ---end perl Payload exit $exit_code; @@ -1200,18 +1441,44 @@ if ($match.Success) { $admininfo = $match.Groups[1].Value $asadmin = $admininfo.Contains("asadmin=1") if ($asadmin) { + if ($args[0] -eq "PUNK-ELEVATED") { + # May be present if launch and elevation was done via cmd.exe script + # shift away first arg + $newargs = $args | Select-Object -Skip 1 + } else { + $newargs = $args + } + # -Wait e.g for starting a service or other operations which remainder of script may depend on + $arguments = @("-NoProfile","-NoLogo", "-NoExit", "-ExecutionPolicy", "Bypass") + $arguments += @("-File", $($MyInvocation.MyCommand.Path)) + foreach ($a in $newargs) { + if ($a -match '\s') { + $arguments += "`"$a`"" + } else { + $arguments += $a + } + } if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { # If not elevated, relaunch with elevated privileges - # -Wait e.g for starting a service or other operations which remainder of script may depend on - $arguments = @("-NoProfile", "-NoExit", "-ExecutionPolicy", "Bypass") - $arguments += @("-File", $($MyInvocation.MyCommand.Path)) - $arguments += $args + Write-Host "Powershell elevating using start-process with -Verb RunAs" if ($PSVersionTable.PSEdition -eq 'Core') { Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -Wait -Verb RunAs } else { Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Wait -Verb RunAs } Exit # Exit the current non-elevated process + } else { + if ($args[0] -eq "PUNK-ELEVATED") { + #Already elevated (by cmd.exe) + #.. but it is impossible to modify or reassign the automatic $args variable + # so let's start yet another whole new process just to remove one leading argument so the custom script can operate on parameters cleanly - thanks powershell :/ + if ($PSVersionTable.PSEdition -eq 'Core') { + Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -NoNewWindow -Wait + } else { + Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -NoNewWindow -Wait + } + Exit + } } } } @@ -1219,10 +1486,10 @@ if ($match.Success) { $startTag = ": <>" $endTag = ": <>" $pattern = "(?s)`n$startTag[^`n]*`n(.*?)`n$endTag" -$match = [regex]::Matches($thisfileContent,$pattern) +$match = [regex]::Match($thisfileContent,$pattern) if ($match.Success) { $plat = [System.Environment]::OSVersion.Platform - if ($plat = "Unix") { + if ($plat -eq "Unix") { $runtime_ident = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier switch ($runtime_ident.split("-")[0]) { "freebsd" { @@ -1261,12 +1528,13 @@ if ($match.Success) { break } } - if (-not (("pwsh", "powershell") -contains $nextshell_type)) { + if (-not (("pwsh", "powershell", "") -contains $nextshell_type)) { #nextshell diversion exists for this platform - write-host "pwsh/powershell launching subshell of type: $nextshell_type shellpath: $nextshell_path on script $scriptname" + write-host "os: $os pwsh/powershell launching subshell of type: $nextshell_type shellpath: $nextshell_path on script $scriptname" # $arguments = @($($MyInvocation.MyCommand.Path)) # $arguments += $args + # NOTE - this gives incorrect argument quoting e.g wrong number of arguments received by launched process for arguments: a "b c" # $process = (Start-Process -FilePath $nextshell_path -ArgumentList $arguments -NoNewWindow -Wait) # Exit $process.ExitCode @@ -1282,20 +1550,6 @@ Write-Error "No powershell code for this script. Try another program such as tcl "powershell args : {0}" -f ($args -join ", ") | write-host # -# -# - - -# -- --- --- --- --- --- --- --- -# -#tclsh $scriptname $args -#"powershell reporting exitcode: {0}" -f $LASTEXITCODE | write-host -# -# -- --- --- --- --- --- --- --- - - -# -# # -- --- --- --- --- --- --- --- --- --- --- --- --- ---end powershell Payload Exit $LASTEXITCODE diff --git a/getpunk.cmd b/getpunk.cmd index 858fd64f..32a31792 100755 --- a/getpunk.cmd +++ b/getpunk.cmd @@ -95,19 +95,19 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @REM If more than 64 chars needed for a target, it can still be done but overall script padding may need checking/adjusting @REM Supporting more explicit oses than those listed may also require script padding adjustment : <> -@SET "nextshellpath[win32___________]=powershell -nop -nol -ExecutionPolicy ByPass -File______________" +@SET "nextshellpath[win32___________]=cmd.exe /c powershell -nop -nol -ExecutionPolicy ByPass -File___________________________________________________________________" @SET "nextshelltype[win32___________]=powershell______" -@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[dragonflybsd____]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[dragonflybsd____]=bash____________" -@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[freebsd_________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[freebsd_________]=bash____________" -@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[netbsd__________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[netbsd__________]=bash____________" -@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[linux___________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[linux___________]=bash____________" -@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[macosx__________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[macosx__________]=bash____________" -@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________" +@SET "nextshellpath[other___________]=/usr/bin/env bash_______________________________________________________________________________________________________________" @SET "nextshelltype[other___________]=bash____________" : <> @rem asadmin is for automatic elevation to administrator. Separate window will be created (seems unavoidable with current elevation mechanism) and user will still get security prompt (probably reasonable). @@ -119,7 +119,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringTrimTrailingUnderscores %selected_shelltype% selected_shelltype_trimmed @REM @ECHO selected_shelltype_trimmed %selected_shelltype_trimmed% @SET "selected_shellpath=%nextshellpath[win32___________]%" -@CALL :stringTrimTrailingUnderscores %selected_shellpath% selected_shellpath_trimmed +@CALL :stringTrimTrailingUnderscores "%selected_shellpath%" selected_shellpath_trimmed @CALL SET "keyRemoved=%%validshelltypes:!selected_shelltype!=%%" @REM @ECHO keyremoved %keyRemoved% @REM Note that 'powershell' e.g v5 is just a fallback for when pwsh is not available @@ -151,6 +151,7 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET "winpath=%~dp0" %= e.g c:\punkshell\bin\ %= @SET "fname=%~nx0" @SET "scriptrootname=%~dp0%~n0" %= e.g c:\punkshell\bin\runtime (full path without extension) unavailable after shift, so store it =% +@SET "fullscriptname=%~dp0%~n0%~x0" @REM @ECHO fname %fname% @REM @ECHO winpath %winpath% @REM @ECHO commandlineascalled %0 @@ -162,6 +163,62 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @CALL :stringToUpper %~nx0 capscripttail @REM @ECHO capscriptname: %capscripttail% +@goto skip_parameter_wrangling +@set argCount=30 +@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe +@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon +@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. +@set tmpfile_base=%TEMP%\punkbatch_params +@call :getUniqueFile %tmpfile_base% ".txt" paramfile +@echo %paramfile% + +%= NOTE when we loop like this using the percent-n args and shift, we lose unquoted separators such as comma and semicolon %= +@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 +@rem outer loop required to redirect all rem lines at once to file +@for %%x in (1) do @( + @for /L %%f in (1,1,%argCount%) do @( + @set "argnum=%%~nf" + @set "a1=%%1" + @rem @set "argname=%%!argnum!" + @rem @echo argname: !argname! + @call :rem_output !argnum! !a1! + @shift + ) +) > %paramfile% +@echo off + +@set "newcommandline= " + +@(set target=cmd_pwsh) +@if "%target%"=="cmd_pwsh" ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + @REM @echo ######### %%L + @rem call :buildcmdline newcommandline param "{" "}" + @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= + call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= + @rem @echo . + ) +) ELSE ( + @for /F "delims=" %%L in (%paramfile%) do @( + SETLOCAL DisableDelayedExpansion + set "param=%%L" + call :buildcmdline newcommandline param + ) +) +@REM padding +SETLOCAL EnableDelayedExpansion +@echo off +@IF EXIST %paramfile% ( + @DEL /F /Q %paramfile% +) +@IF EXIST %paramfile% ( + echo failed to delete %paramfile% + cat %paramfile% +) +:skip_parameter_wrangling + @IF "%nftail%"=="%capscripttail%" ( @ECHO forcing asadmin=1 due to file name on filesystem being uppercase @SET "asadmin=1" @@ -189,31 +246,112 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% :getPrivileges @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" (echo PUNK-ELEVATED & shift /1 & goto :gotPrivileges ) @ECHO Set UAC = CreateObject^("Shell.Application"^) > "%vbsGetPrivileges%" -@ECHO args = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@ECHO pre = "/c %fullscriptname% PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@REM @echo pre = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@echo args = pre >> "%vbsGetPrivileges%" @ECHO For Each strArg in WScript.Arguments >> "%vbsGetPrivileges%" -@ECHO args = args ^& strArg ^& " " >> "%vbsGetPrivileges%" +@ECHO args = args ^& Chr(34) ^& strArg ^& Chr(34) ^& " " >> "%vbsGetPrivileges%" @ECHO Next >> "%vbsGetPrivileges%" -@ECHO UAC.ShellExecute "%~dp0%~n0%~x0", args, "", "runas", 1 >> "%vbsGetPrivileges%" -@ECHO Launching script in new window due to administrator elevation +@GOTO skiptest + +%= Option Explicit =% +%= We need a child process to locate the current script. =% +@ECHO Const FLAG_PROCESS = "winver.exe" >> "%vbsGetPrivileges%" + +%= ' WMI constants %= +@ECHO Const wbemFlagForwardOnly = 32 >> "%vbsGetPrivileges%" + +%=' Generate a unique value to be used as a flag =% +@ECHO Dim guid >> "%vbsGetPrivileges% +@ECHO guid = Left(CreateObject("Scriptlet.TypeLib").GUID,38) >> "%vbsGetPrivileges%" + +%= ' Start a process using the indicated flag inside its command line =% +@ECHO WScript.CreateObject("WScript.Shell").Run """" ^& FLAG_PROCESS ^& """ " ^& guid, 0, False >> "%vbsGetPrivileges%" + +%= ' To retrieve process information a WMI reference is needed =% +@ECHO Dim wmi >> "%vbsGetPrivileges%" +@ECHO Set wmi = GetObject("winmgmts:{impersonationLevel=impersonate}^!\\.\root\cimv2") >> "%vbsGetPrivileges%" + +%= ' Query the list of processes with the flag in its command line, retrieve the =% +%= ' process ID of its parent process ( our script! ) and terminate the process =% +@ECHO Dim colProcess, process, myProcessID >> "%vbsGetPrivileges%" +@ECHO Set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT ParentProcessID From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE Name='" ^& FLAG_PROCESS ^& "' " ^& _>> "%vbsGetPrivileges%" +@ECHO "AND CommandLine LIKE '%%" ^& guid ^& "%%'" _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO myProcessID = process.ParentProcessID >> "%vbsGetPrivileges%" +@ECHO process.Terminate >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" + +%= ' Knowing the process id of our script we can query the process list =% +%= ' and retrieve its command line =% +@ECHO Dim commandLine >> "%vbsGetPrivileges%" +@ECHO set colProcess = wmi.ExecQuery( _>> "%vbsGetPrivileges%" +@ECHO "SELECT CommandLine From Win32_Process " ^& _>> "%vbsGetPrivileges%" +@ECHO "WHERE ProcessID=" ^& myProcessID _>> "%vbsGetPrivileges%" +@ECHO ,"WQL" , wbemFlagForwardOnly _>> "%vbsGetPrivileges%" +@ECHO ) >> "%vbsGetPrivileges%" +@ECHO For Each process In colProcess >> "%vbsGetPrivileges%" +@ECHO commandLine = process.CommandLine >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "raw commandline: " ^& commandLine >>"%vbsGetPrivileges%" + +%= ' Done =% +@ECHO intpos = 0 >> "%vbsGetPrivileges%" +@ECHO intCount = 0 >> "%vbsGetPrivileges%" +@ECHO intstartsearch = 1 >> "%vbsGetPrivileges%" +@ECHO intmax = 100 >> "%vbsGetPrivileges%" +@ECHO do While intCount ^< 4 and intmax ^> 0 >> "%vbsGetPrivileges%" +@ECHO intpos = InStr(intstartsearch, commandline, """") >> "%vbsGetPrivileges%" +@ECHO if intpos ^<^> 0 then >> "%vbsGetPrivileges%" +@ECHO intCount = intCount + 1 >> "%vbsGetPrivileges%" +@ECHO if intcount = 4 then >> "%vbsGetPrivileges%" +@ECHO ' wscript.echo "position: " ^& intpos >> "%vbsGetPrivileges%" +@ECHO commandline = Mid(commandline,intpos+1) >> "%vbsGetPrivileges%" +@ECHO exit do >> "%vbsGetPrivileges%" +@ECHO else >> "%vbsGetPrivileges%" +@ECHO intstartsearch = intpos + 1 >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO intmax = intmax -1 >> "%vbsGetPrivileges%" +@ECHO Loop >> "%vbsGetPrivileges%" +@ECHO if intcount ^< 4 then >> "%vbsGetPrivileges%" +@ECHO err.raise vbObjectError + 1001, "vbsGetPrivileges", "failed to parse commandline" >> "%vbsGetPrivileges%" +@ECHO end if >> "%vbsGetPrivileges%" +@ECHO commandline = pre ^& commandline >> "%vbsGetPrivileges%" +@ECHO WScript.Echo "commandline: " ^& commandLine >>"%vbsGetPrivileges%" +@ECHO WScript.Echo "args: " ^& args >>"%vbsGetPrivileges%" +:skiptest + +@ECHO UAC.ShellExecute "cmd.exe", args, "", "runas", 1 >> "%vbsGetPrivileges%" +@REM @ECHO UAC.ShellExecute "%fullscriptname%", commandline, "", "runas", 1 >> "%vbsGetPrivileges%" +@ECHO Launching script "%fullscriptname%" in new window due to administrator elevation with args: "%*" @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" %* +@REM @"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" !newcommandline! @EXIT /B +@REM buffer +@REM buffer :gotPrivileges @REM setlocal & pushd . @PUSHD . -@cd /d %~dp0 +@cd /d %winpath% @IF "is%qstrippedargs:~4,13%"=="isPUNK-ELEVATED" ( @DEL "%vbsGetPrivileges%" 1>nul 2>nul @SET arglist=%arglist:~14% + @SHIFT ) :skip_privileges @SET need_ps1=0 @REM we want the ps1 to exist even if the nextshell isn't powershell -@if not exist "%~dp0%~n0.ps1" ( +@if not exist "%scriptrootname%.ps1" ( @SET need_ps1=1 ) ELSE ( - fc "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >nul || goto different + fc "%fullscriptname%" "%scriptrootname%.ps1" >nul || goto different @REM @ECHO "files same" @SET need_ps1=0 ) @@ -223,74 +361,13 @@ set ^"endlocal=for %%# in (1 2) do if %%#==2 (%\n% @SET need_ps1=1 :pscontinue @IF !need_ps1!==1 ( - COPY "%~dp0%~n0%~x0" "%~dp0%~n0.ps1" >NUL + COPY "%fullscriptname%" "%scriptrootname%.ps1" >NUL ) @REM avoid using CALL to launch pwsh,tclsh etc - it will intercept some args such as /? @IF "!selected_shelltype_trimmed!"=="none" ( SET selected_shelltype_trimmed=pwsh ) - - - -@set argCount=30 -@rem This is the max number of args we are willing to handle. also bounded by approx 8k char limit of cmd.exe -@rem We do not loop over %* to count args as it is brittle for some inputs e.g will always skip cmd.exe separators e.g comma and semicolon -@rem Set argCount higher if desired, but there is a small amount of additional looping overhead. - -@set tmpfile_base=%TEMP%\punkbatch_params -@call :getUniqueFile %tmpfile_base% ".txt" paramfile -@echo %paramfile% - -%= NOTE when we loop like this using the percent-n args, we lose unquoted separators such as comma and semicolon %= -@rem https://stackoverflow.com/questions/26551/how-can-i-pass-arguments-to-a-batch-file/5493124#5493124 -@rem outer loop required to redirect all rem lines at once to file -@for %%x in (1) do @( - @for /L %%f in (1,1,%argCount%) do @( - @set "argnum=%%~nf" - @set "a1=%%1" - @rem @set "argname=%%!argnum!" - @rem @echo argname: !argname! - @call :rem_output !argnum! !a1! - @shift - ) -) > %paramfile% -@echo off - -@set "newcommandline= " - -@(set target=cmd_pwsh) -@if "%target%"=="cmd_pwsh" ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - @REM @echo ######### %%L - @rem call :buildcmdline newcommandline param "{" "}" - @rem call :buildcmdline newcommandline param ' ' %= cmd.exe /c powershell ... -c %= - call :buildcmdline newcommandline param %= cmd.exe /c powershell ... -f %= - @rem @echo . - ) -) ELSE ( - @for /F "delims=" %%L in (%paramfile%) do @( - SETLOCAL DisableDelayedExpansion - set "param=%%L" - call :buildcmdline newcommandline param - ) -) -@REM padding -SETLOCAL EnableDelayedExpansion - -@echo off -@IF EXIST %paramfile% ( - @DEL /F /Q %paramfile% -) -@IF EXIST %paramfile% ( - echo failed to delete %paramfile% - cat %paramfile% -) - - - @REM @SET "squoted_args=" @REM @for %%a in (%*) do @( @REM set "v=%%a" @@ -311,19 +388,24 @@ SETLOCAL EnableDelayedExpansion REM fallback to powershell if pwsh failed IF !pwshtest_exitcode!==0 ( @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; "%scriptrootname%.ps1" %arglist% - @rem pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted - cmd /c pwsh -nop -nol -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + @rem pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" %arglist% %= ok =% + @rem cmd /c pwsh -nop -nologo -ExecutionPolicy bypass -f "%scriptrootname%.ps1" !newcommandline! + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% SET task_exitcode=!errorlevel! ) ELSE ( REM TODO prompt user with option to call script to install pwsh using winget - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ) ELSE ( IF "!selected_shelltype_trimmed!"=="powershell" ( - @rem powershell -nop -nol -ExecutionPolicy Bypass -c "%scriptrootname%.ps1" %arglist% - cmd /c powershell -nop -nol -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! + %= powershell with -file flag treats it's arguments differently to pwsh - we need cmd /c to preserve args with spaces =% + @rem @echo powershell - !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" %arglist% %= ok - this works =% + !selected_shellpath_trimmed! "%scriptrootname%.ps1" %arglist% + @rem cmd /c powershell -nop -nologo -ExecutionPolicy Bypass -f "%scriptrootname%.ps1" !newcommandline! SET task_exitcode=!errorlevel! ) ELSE ( IF "!selected_shelltype_trimmed!"=="wslbash" ( @@ -337,24 +419,23 @@ SETLOCAL EnableDelayedExpansion REM sh on windows uses /c/ instead of /mnt/c - at least if using msys. Todo, review what is the norm on windows with and without msys2,cygwin,wsl REM and what logic if any may be needed. For now sh with /c/xxx seems to work the same as sh with c:/xxx REM The compound statement with trailing call is required to stop batch termination confirmation, whilst still capturing exitcode - @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" - %selected_shellpath_trimmed% "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; + @REM @ECHO HERE "!selected_shelltype_trimmed!" "!selected_shellpath_trimmed!" + !selected_shellpath_trimmed! "%winpath%%fname%" %arglist% & SET task_exitcode=!errorlevel! & Call; ) ELSE ( ECHO %fname% has invalid nextshelltype value %selected_shelltype% valid options are %validshelltypes% SET task_exitcode=66 @REM boundary padding - GOTO :exit_multishell ) ) ) ) @REM batch file library functions - @GOTO :endlib @REM padding @REM padding @REM padding +@REM padding %= ---------------------------------------------------------------------- =% @rem courtesy of dbenham @@ -458,6 +539,7 @@ do if not defined param1 set %%~"param1=%2%%~" rem %1 #%2# @exit /b +@rem padding @REM courtesy of: https://stackoverflow.com/users/463115/jeb :strlen stringVar returnVar @( @@ -506,6 +588,8 @@ do if not defined param1 set %%~"param1=%2%%~" ) @EXIT /B +@REM padding +@REM padding :getFileTail @REM return tail of file without any normalization e.g c:/punkshell/bin/Punk.cmd returns Punk.cmd even if file is punk.cmd @REM we can't use things such as %~nx1 as it can change capitalisation @@ -545,6 +629,7 @@ do if not defined param1 set %%~"param1=%2%%~" @EXIT /B @REM boundary padding @REM boundary padding +@REM boundary padding :getNormalizedScriptTail @SETLOCAL @SET "result=%~nx0" @@ -654,14 +739,14 @@ do if not defined param1 set %%~"param1=%2%%~" @REM boundary padding @REM boundary padding @REM boundary padding -@REM boundary padding :stringTrimTrailingUnderscores @SETLOCAL @SET "rtrn=%~2" @SET "string=%~1" @SET "trimstring=%~1" - @REM trim up to 63 underscores from the end of a string using string substitution + @REM trim up to 127 underscores from the end of a string using string substitution @SET "trimstring=%trimstring%###" + @SET "trimstring=%trimstring:________________________________________________________________###=###%" @SET "trimstring=%trimstring:________________________________###=###%" @SET "trimstring=%trimstring:________________###=###%" @SET "trimstring=%trimstring:________###=###%" @@ -707,10 +792,11 @@ do if not defined param1 set %%~"param1=%2%%~" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### # -- tcl script section # -- This is a punk multishell file -# -- Primary payload target is Tcl, with sh,bash,powershell as helpers -# -- but it may equally be used with any of these being the primary script. -# -- It is tuned to run when called as a batch file, a tcl script a sh/bash script or a pwsh/powershell script +# -- It is tuned to run (and possibly divert to different payload shell) when called from cmd.exe as a batch file, tclsh,sh,zsh,bash,perl or pwsh/powershell script # -- i.e it is a polyglot file. +# -- The payload target (by os) is defined in the nextshell block at the top which is constructed when generating the polyglot +# -- using the tcl 'dev scriptwrap.multishell' command in a tcl punk shell +# -- The payload can be tcl,perl,powershell/pwsh or zsh/bash. # -- The specific layout including some lines that appear just as comments is quite sensitive to change. # -- It can be called on unix or windows platforms with or without the interpreter being specified on the commandline. # -- e.g ./scriptname.cmd in sh or zsh or bash @@ -720,7 +806,13 @@ do if not defined param1 set %%~"param1=%2%%~" rename set ""; rename S set; set k {-- "$@" "a}; if {[info exists ::env($k)]} {unset ::env($k)} ;# tidyup and restore Hide :exit_multishell;Hide {<#};Hide '@ #--------------------------------------------------------------------- +puts "info script : [info script]" +#puts "argcount : $::argc" +#puts "argvalues: $::argv" +#puts "argv0 : $::argv0" +# -- --- --- --- --- --- --- --- --- --- --- --- #divert to configured nextshell +set script_as_called [info script] package require platform set plat_full [platform::generic] set plat [lindex [split $plat_full -] 0] @@ -733,6 +825,14 @@ set in_data 0 set nextshellpath "" set nextshelltype "" puts stderr "PLAT: $plat" +switch -glob -- $plat { + "msys" - "mingw*" { + set os "win32" + } + default { + set os $plat + } +} foreach ln [split $scriptdata \n] { if {[string trim $ln] eq ""} {continue} if {!$in_data} { @@ -740,14 +840,14 @@ foreach ln [split $scriptdata \n] { set in_data 1 } } else { - if {[string match "*@SET*nextshellpath?${plat}_*" $ln]} { + if {[string match "*@SET*nextshellpath?${os}_*" $ln]} { set lineparts [split $ln =] set tail [lindex $lineparts 1] set nextshellpath [string trimright $tail {_"}] if {$nextshellpath ne "" && $nextshelltype ne ""} { break } - } elseif {[string match "*@SET*nextshelltype?${plat}_*" $ln]} { + } elseif {[string match "*@SET*nextshelltype?${os}_*" $ln]} { set lineparts [split $ln =] set tail [lindex $lineparts 1] set nextshelltype [string trimright $tail {_"}] @@ -760,31 +860,111 @@ foreach ln [split $scriptdata \n] { } } if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { + set script_rootname [file rootname $script_as_called] if {$nextshelltype in "pwsh powershell"} { - set scrname [file rootname [info script]].ps1 - set arglist [list] - foreach a $::argv { - set a "'$a'" - lappend arglist $a + # experimental + set script_ps1 $script_rootname.ps1 + set arglist $::argv + + if {[file extension $script_as_called] ne ".ps1"} { + #we need to ensure .ps1 is up to date + set needs_updating 0 + if {![file exists $script_ps1]} { + set needs_updating 1 + } else { + #both exist + if {[file size $script_as_called] != [file size $script_ps1]} { + set needs_updating 1 + } else { + #both exist with same size - do full check that they're identical + catch {package require sha256} + if {[package provide sha256] ne ""} { + set h1 [sha2::sha256 -hex -file $script_as_called] + set h2 [sha2::sha256 -hex -file $script_ps1] + if {[string length $h1] != 64 || [string length $h2] != 64} { + set needs_updating 1 + } elseif {$h1 ne $h2} { + set needs_updating 1 + } + } else { + #manually compare - scripts aren't too big, so slurp and string compare is fine + set fd [open $script_as_called] + chan configure $fd -translation binary + set data1 [read $fd] + close $fd + set fd [open $script_ps1] + chan configure $fd -translation binary + set data2 [read $fd] + close $fd + if {![string equal $data1 $data2]} { + set needs_updating 1 + } + } + } + } + + if {$needs_updating} { + file copy -force $script_as_called $script_ps1 + } + } else { + #when called on the .ps1 - we assume it's up to date - review } + set scrname $script_ps1 + + #set arglist [list] + #foreach a $::argv { + # set a "'$a'" + # lappend arglist $a + #} } else { - set scrname [info script] + set scrname $script_as_called set arglist $::argv } - puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" #todo - handle /usr/bin/env #todo - exitcode - if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { - set nextshell_words [list $nextshellpath] - } else { - set nextshell_words $nextshellpath + #review - test spaced quoted words in nextshellpath? + # + #if {[llength $nextshellpath] == 1 && [string index $nextshellpath 0] eq {"} && [string index $nextshellpath end] eq {"}} { + # set nextshell_words [list $nextshellpath] + #} else { + # set nextshell_words $nextshellpath + #} + + #perform any msys argument munging on a cmd/cmd.exe based nextshellpath before we convert the first word to an auto_exec path + switch -glob -- $plat { + "msys" - "mingw*" { + set cmdword [lindex $nextshellpath 0] + #we only act on cmd or cmd.exe - not a full path such as c:/WINDOWS/system32/cmd.exe + #the nextshellpath should generally be configured as cmd /c ... or cmd.exe ... but specifying it as a path could allow bypassing this un-munging. + #The un-munging only applies to msys/mingw, so such bypassing should be unnecessary - review + #maint: keep this munging in sync with zsh/bash and perl blocks which must also do msys mangling + if {[regexp {^cmd$|^cmd[.]exe$} $cmdword]} { + #need to deal with msys argument munging + #for now we only deal with /C or /c - todo - other cmd.exe flags? + #In this context we would usually only be using cmd.exe /c to launch older 'desktop' powershell to avoid spaced-argument problems - so we aren't expecting other flags + set new_nextshellpath [list $cmdword] + #for now - just do what zsh munging does - bash regex/string/array processing is tedious and footgunny for the unfamiliar (me), + #so determine the minimum viable case for code there, then port behaviour to perl/tcl msys munging sections. + foreach w [lrange $nextshellpath 1 end] { + if {[regexp {^/[Cc]$} $w]} { + lappend new_nextshellpath {//C} + } else { + lappend new_nextshellpath $w + } + } + set nextshellpath $new_nextshellpath + } + } } + set ns_firstword [lindex $nextshellpath 0] - if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { - set ns_firstword [string range $ns_firstword 1 end-1] - } + #review - is this test for extra layer of double quoting on first word really necessary? + #if we are treaing $nextshellpath as a tcl list - the first layer of double quotes will already have disappeared + ##if {[string index $ns_firstword 0] eq {"} && [string index $ns_firstword end] eq {"}} { + ## set ns_firstword [string range $ns_firstword 1 end-1] + ##} - if {[string match {/*/env} $ns_firstword] && $::tcl_platform(platform) ne "windows"} { + if {$::tcl_platform(platform) ne "windows" && [string match {/*/env} $ns_firstword]} { set exec_part $nextshellpath } else { set epath [auto_execok $ns_firstword] @@ -794,6 +974,10 @@ if {$nextshelltype ne "tcl" && $nextshelltype ne "none"} { set exec_part [list {*}$epath {*}[lrange $nextshellpath 1 end]] } } + + + puts stdout "tclsh launching subshell of type: $nextshelltype shellpath: $nextshellpath on script $scrname with args: $arglist" + puts stdout "exec: $exec_part $scrname $arglist" catch {exec {*}$exec_part $scrname {*}$arglist <@stdin >@stdout 2>@stderr} emsg eopts if {[dict exists $eopts -errorcode]} { @@ -837,11 +1021,6 @@ namespace eval ::punk::multishell { } } # -- --- --- --- --- --- --- --- --- --- --- --- --- ---begin Tcl Payload -#puts "script : [info script]" -#puts "argcount : $::argc" -#puts "argvalues: $::argv" -#puts "argv0 : $::argv0" -# -- --- --- --- --- --- --- --- --- --- --- --- # puts stderr "No tcl code for this script. Try another program such as zsh or bash or perl" @@ -867,8 +1046,11 @@ if {[::punk::multishell::is_main]} { HEREDOC1B_HIDE_FROM_BASH_AND_SH # Be wary of any non-trivial sed/awk etc - can be brittle to maintain across linux,freebsd,macosx due to differing implementations \ echo "var0: $0 @: $@" -# use oldschool backticks and sed - lowest common denominator \ -ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` +# use oldschool backticks and sed (posix - lowest common denominator) \ +# ps_shellname=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` \ +# some ps impls will return arguments - so last field not always appropriate \ +# some ps impls don't have -o (e.g cygwin) so ps_shellname may remain empty and emit an error \ +ps_shellname=`ps -o pid,comm -p $$ | awk '$1 != "PID" {print $2}'` # \ echo "shell from ps: $ps_shellname" # \ @@ -900,8 +1082,11 @@ pop() { } # ------------------------------------------------------------------------------ -# non-bash-like posix diversion \ -if [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ]; then +# non-bash-like posix diversion +# we don't use $BASH_VERSION/$ZSH_VERSION as these can still be set when for example +# sh is a symlink to bash (posix-mode bash - reduced bashism capabilities?) +# if our ps_shellname didn't contain a result, don't divert and risk looping +if [ -n "$ps_shellname" ] && [ "$ps_shellname" != "bash" ] && [ "$ps_shellname" != "zsh" ] ; then shift pop $# eval "$POP_EXPR" @@ -919,17 +1104,14 @@ if false==false # else { then : # - # zsh/bash \ shift && set -- "${@:1:$((${#@}-1))}" # ## ### ### ### ### ### ### ### ### ### ### ### ### ### -# -- sh/bash script section -# -- leave as is if all that is required is launching the Tcl payload" -# -- -# -- Note that sh/bash script isn't called when running a .bat/.cmd from cmd.exe on windows by default -# -- adjust the %nextshell% value above -# -- if sh/bash scripting needs to run on windows too. +# -- zsh/bash script section # -- +# -- review - for zsh do we want to use: setopt KSH_ARRAYS ? +# -- arrays in bash 0-based vs 1-based in zsh +# -- stick to the @:i:len syntax which is same for both # ## ### ### ### ### ### ### ### ### ### ### ### ### ### plat=$(uname -s) #platform/system @@ -952,18 +1134,31 @@ elif [[ "$plat" == "MINGW64"* ]]; then elif [[ "$plat" == "CYGWIN_NT"* ]]; then os="win32" elif [[ "$plat" == "MSYS_NT"* ]]; then - #review.. - echo MSYS - #win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' + #review.. + + #Need to consider the difference between when msys2 was launched (which strips some paths and sets up the environment) + # vs if the msys2 sh was called - (actually bash) in which case paths will be different + + #wsl and cygwin or msys2 can commonly be problematic combinations - primarily due to path issues + #e.g "c:/windows/system32/" is quite likely in the path ahead of msys,git etc. + #e.g It means a /usr/bin/env bash call may launch the (linux elf) bash for wsl rather than the msys bash + # + + #msys provides win32 binaries - but e.g tclsh installed in msys reports ::tcl_platform(platform) as 'unix' #bash reports $OSTYPE msys + + #there are statements around the web that cmd /c .. will work under msys2 + # - but from experience, it can be required to use cmd //c ... + # or MSYS2_ARG_CONV_ECL='*' cmd /c .. + # This seems to be because process arguments that look like unix paths are converted to windows paths :/ + #review! + os="win32" #review - need ps/sed/awk to determine shell? - interp = `ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` + interp=`ps -p $$ | awk '$1 != "PID" {print $(NF)}' | tr -d '()' | sed -E 's/^.*\/|^-//'` #use 'command -v' (shell builtin preferred over external which) shellpath=`command -v $interp` shellfolder="${shellpath%/*}" #avoid dependency on basename or dirname - #"c:/windows/system32/" is quite likely in the path ahead of msys,git etc. - #This breaks calls to various unix utils such as sed etc (wsl related?) export PATH="$shellfolder${PATH:+:${PATH}}" elif [[ "$OSTYPE" == "win32" ]]; then os="win32" @@ -985,6 +1180,8 @@ elif [[ "$ps_shellname" == "zsh" ]]; then else #fallback - doesn't seem to work in zsh - untested in early bash IFS=$'\n' arr_oslines=($shellconfiglines) + IFS=$' \t\n' + # review fi nextshellpath="" nextshelltype="" @@ -1008,8 +1205,55 @@ exitcode=0 #-- sh/bash launches nextscript here instead of shebang line at top if [[ "$nextshelltype" != "bash" && "$nextshelltype" != "none" ]]; then echo zsh/bash launching subshell of type: $nextshelltype shellpath: $nextshellpath on "$0" with args "$@" - #e.g /usr/bin/env tclsh "$0" "$@" - ${nextshellpath} "$0" "$@" + + script="$0" + if [[ "$nextshelltype" == "pwsh" || "$nextshelltype" == "powershell" ]]; then + #powershell requires the file extension to be .ps1 (always - on windows) + #on other platforms it's not required if a shebang line is used - but this script must be shebangless for portability and to maintain polyglot capabilities. + cmdpattern="[.]cmd$" + if [[ "$script" =~ $cmdpattern ]]; then + ps1script="${script%????}.ps1" + if ! cmp -s "$script" "$ps1script" ; then + #ps1script either different or missing + #on windows - batch script copies .cmd -> .ps1 if not identical + cp -f "$script" "$ps1script" + fi + script=$ps1script + fi + fi + if [[ "$plat" == "MSYS_NT"* ]]; then + + #we need to deal with MSYS argument munging + cmdpattern="^cmd.exe |^cmd " + #do not double quote cmdpattern - or it will be treated as literal string + if [[ "$nextshellpath" =~ $cmdpattern ]]; then + #for now - tell the user what's going on + echo "cmd call via msys detected. performing translation of /c to //c and escaping backslashes in script path" + #flags to cmd.exe such as /c are interpreted by msys as looking like a unix path + #review - for nextshellpath targets specified in the block for win32 - we don't expect unix paths (?) + #what about other flags? - can we just double up all forward slashes? + #maint: keep this munging in sync with the tcl block and perl block which must also do msys munging + nextshellpath="${nextshellpath// \/[cC] / \/\/c }" + # echo "new nextshellpath: ${nextshellpath}" + #don't double quote this + script=${script//\\/\\\\} + fi + echo "calling ${nextshellpath} $script $@" + + #load into array + cmd_array=($nextshellpath) + cmd_array+=("$script") #add script, which may contain spaces as a single entry ? + cmd_array+=( "$@" ) #add each element of args to array as a separate entry (equiv ? "${arr[@]}") + # printf "%s\n" "${cmd_array[@]}" + "${cmd_array[@]}" + + # this works to make nextshellpath run - but joins $@ members incorrectly + #eval ${nextshellpath} "$script" "$@" + else + #e.g /usr/bin/env tclsh "$0" "$@" + ${nextshellpath} "$script" "$@" + fi + exitcode=$? #echo "zsh/bash reporting exitcode: ${exitcode}" @@ -1025,7 +1269,8 @@ fi #printf "start of bash or zsh code" # - +#curl -LO https://www.gitea1.intx.com.au/jn/punkshell/raw/branch/master/getpunk.cmd +#chmod +x getpunk.cmd git_upstream="https://gitea1.intx.com.au/jn/punkshell.git" #review - how can we test if another url such as ssh://git@pcm-gitea1.corp.intx.com.au:2222/jn/punkshell is actually the same repo, without cloning and comparing files/history? @@ -1279,18 +1524,44 @@ if ($match.Success) { $admininfo = $match.Groups[1].Value $asadmin = $admininfo.Contains("asadmin=1") if ($asadmin) { + if ($args[0] -eq "PUNK-ELEVATED") { + # May be present if launch and elevation was done via cmd.exe script + # shift away first arg + $newargs = $args | Select-Object -Skip 1 + } else { + $newargs = $args + } + # -Wait e.g for starting a service or other operations which remainder of script may depend on + $arguments = @("-NoProfile","-NoLogo", "-NoExit", "-ExecutionPolicy", "Bypass") + $arguments += @("-File", $($MyInvocation.MyCommand.Path)) + foreach ($a in $newargs) { + if ($a -match '\s') { + $arguments += "`"$a`"" + } else { + $arguments += $a + } + } if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { # If not elevated, relaunch with elevated privileges - # -Wait e.g for starting a service or other operations which remainder of script may depend on - $arguments = @("-NoProfile", "-NoExit", "-ExecutionPolicy", "Bypass") - $arguments += @("-File", $($MyInvocation.MyCommand.Path)) - $arguments += $args + Write-Host "Powershell elevating using start-process with -Verb RunAs" if ($PSVersionTable.PSEdition -eq 'Core') { Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -Wait -Verb RunAs } else { Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -Wait -Verb RunAs } Exit # Exit the current non-elevated process + } else { + if ($args[0] -eq "PUNK-ELEVATED") { + #Already elevated (by cmd.exe) + #.. but it is impossible to modify or reassign the automatic $args variable + # so let's start yet another whole new process just to remove one leading argument so the custom script can operate on parameters cleanly - thanks powershell :/ + if ($PSVersionTable.PSEdition -eq 'Core') { + Start-Process -FilePath "pwsh.exe" -ArgumentList $arguments -NoNewWindow -Wait + } else { + Start-Process -FilePath "powershell.exe" -ArgumentList $arguments -NoNewWindow -Wait + } + Exit + } } } } diff --git a/src/scriptapps/getpunk.bash b/src/scriptapps/getpunk.bash index 6f9307aa..53278fc6 100644 --- a/src/scriptapps/getpunk.bash +++ b/src/scriptapps/getpunk.bash @@ -1,4 +1,5 @@ - +#curl -LO https://www.gitea1.intx.com.au/jn/punkshell/raw/branch/master/getpunk.cmd +#chmod +x getpunk.cmd git_upstream="https://gitea1.intx.com.au/jn/punkshell.git" #review - how can we test if another url such as ssh://git@pcm-gitea1.corp.intx.com.au:2222/jn/punkshell is actually the same repo, without cloning and comparing files/history? diff --git a/src/scriptapps/getpunk_wrap.toml b/src/scriptapps/getpunk_wrap.toml index e206c989..2cf7048f 100644 --- a/src/scriptapps/getpunk_wrap.toml +++ b/src/scriptapps/getpunk_wrap.toml @@ -13,8 +13,11 @@ default_nextshelltype="bash" #valid nextshelltype entries are: tcl perl powershell bash. - #nextshellpath entries must be 64 characters or less. + #nextshellpath entries must be 128 characters or less. - win32.nextshellpath="powershell -nop -nol -ExecutionPolicy ByPass -File" + #win32.nextshellpath="pwsh -nop -nol -ExecutionPolicy bypass -File" + #win32.nextshelltype="pwsh" + + win32.nextshellpath="cmd.exe /c powershell -nop -nol -ExecutionPolicy ByPass -File" win32.nextshelltype="powershell" win32.outputfile="getpunk.cmd" diff --git a/src/scriptapps/runtime_wrap.toml b/src/scriptapps/runtime_wrap.toml index 41285863..115a88f1 100644 --- a/src/scriptapps/runtime_wrap.toml +++ b/src/scriptapps/runtime_wrap.toml @@ -16,7 +16,12 @@ #nextshellpath entries must be 64 characters or less. #don't use -c for launching - or in old powershell, arguments such as "a b" will become 2 arguments a b - #do use -File (even though pwsh doesn't require it) - win32.nextshellpath="powershell -nop -nol -ExecutionPolicy bypass -File" + #do use -File (even though pwsh doesn't require it) + #win32.nextshellpath="pwsh -nop -nol -ExecutionPolicy bypass -File" + #win32.nextshelltype="pwsh" + + #powershell needs cmd.exe to preserve spaced args + win32.nextshellpath="cmd.exe /c powershell -nop -nol -ExecutionPolicy bypass -File" win32.nextshelltype="powershell" + win32.outputfile="runtime.cmd" diff --git a/src/scriptapps/tclargs.tcl b/src/scriptapps/tclargs.tcl index 47258278..0b2a3c0a 100644 --- a/src/scriptapps/tclargs.tcl +++ b/src/scriptapps/tclargs.tcl @@ -1,9 +1,9 @@ -puts stdout "::argc" -puts stdout $::argc -puts stdout "::argv" -puts stdout "$::argv" -puts stdout ----------------------- -foreach a $::argv { - puts stdout $a -} +puts stdout "::argc" +puts stdout $::argc +puts stdout "::argv" +puts stdout "$::argv" +puts stdout ----------------------- +foreach a $::argv { + puts stdout $a +} puts stdout -done- \ No newline at end of file diff --git a/src/scriptapps/tclargs_wrap.toml b/src/scriptapps/tclargs_wrap.toml index 807fbdd4..9818078d 100644 --- a/src/scriptapps/tclargs_wrap.toml +++ b/src/scriptapps/tclargs_wrap.toml @@ -1,15 +1,15 @@ -[application] - template="punk.multishell.cmd" - as_admin=false - - scripts=[ - "tclargs.tcl", - ] - - default_outputfile="tclargs.cmd" - default_nextshellpath="tclsh" - default_nextshelltype="tcl" - - win32.nextshellpath="tclsh" - win32.nextshelltype="tcl" +[application] + template="punk.multishell.cmd" + as_admin=false + + scripts=[ + "tclargs.tcl", + ] + + default_outputfile="tclargs.cmd" + default_nextshellpath="tclsh" + default_nextshelltype="tcl" + + win32.nextshellpath="tclsh" + win32.nextshelltype="tcl" win32.outputfile="tclargs.cmd" \ No newline at end of file