From 9b21d3b4f3f716bff8b5d1f5aae1ca08952d0ce7 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Sat, 6 Jan 2024 18:08:12 +1100 Subject: [PATCH] punk-multishell fixes - 512byte batch script label boundaries --- .../mix/commandset/scriptwrap-999999.0a1.0.tm | 293 ++++++++++++++++- .../scriptappwrappers/punk-multishell.cmd | 303 +++++++++++++++--- 2 files changed, 552 insertions(+), 44 deletions(-) diff --git a/src/modules/punk/mix/commandset/scriptwrap-999999.0a1.0.tm b/src/modules/punk/mix/commandset/scriptwrap-999999.0a1.0.tm index e7dca288..fecf684b 100644 --- a/src/modules/punk/mix/commandset/scriptwrap-999999.0a1.0.tm +++ b/src/modules/punk/mix/commandset/scriptwrap-999999.0a1.0.tm @@ -97,6 +97,280 @@ namespace eval punk::mix::commandset::scriptwrap { return $table } + proc range_spans_512_boundaries {start end} { + #todo - something more elegant + set is_span 0 + set lowmult [expr {$start / 512}] + set highmult [expr {$end / 512}] + + set test_boundaries [list] + for {set bindex $lowmult} {$bindex <= ($highmult + 1)} {incr bindex} { + lappend test_boundaries [expr {$bindex * 512}] + } + set lowboundary unset + set highboundary unset + foreach t $test_boundaries { + if {$lowboundary eq "unset"} { + if {$start <= $t } { + set lowboundary $t + } + } + if {$end >= $t} { + set highboundary $t + } + } + set boundaries [list] + foreach b $test_boundaries { + if {$lowboundary <= $b && $highboundary >= $b} { + lappend boundaries $b + } + } + + if {[llength $boundaries]} { + set is_span 1 + } + + if {$is_span} { + return [list is_span 1 boundaries $boundaries] + } else { + return [list is_span 0] + } + } + #A batch file with unix line-endings is sensitive to label positioning. + #batch file with windows crlf line endings can exhibit this problem - but probably only if specifically crafted with long lines deliberately designed to trigger it. + #see: https://www.dostips.com/forum/viewtopic.php?t=8988#p58888 (Call and goto may fail when the batch file has Unix line endings) + #The windows batch file scanner appears to parse in 512 Byte chunks. + #If a label following a call/goto is at a position spanning a 512 byte block as counted from the call/goto site (callsite assumed to be EOL - works for basic cases at least) then the label won't be found. + #A label preceding a call/goto site can't span a 512 byte boundary as counted from the beginning of the file + #checkoutput produces warnings and errors in ansi-coloured form (both to stdout and a summary in the return value) + #The script should then be adjusted with comments and/or whitespace and checkoutput should be re-run to confirm there are no new boundary-spanning labels. + #checkoutput needs to be run even on previously tested scriptwrapper templates because the final :exit label is beyond the payloads and so could span a 512 Byte block + #It is more likely to catch issues if adjustments are made to the initial batch-script code in a template. + proc checkoutput {filepath args} { + if {![file exists $filepath]} { + error "punk::mix::commandset:scriptwrap error cannot find file '$filepath'" + } + set defaults [dict create\ + -ignore_rems 0\ + ] + set opts [dict merge $defaults $args] + set opt_ignore_rems [dict get $opts -ignore_rems] + + set filedata [fcat $filepath] + if {$opt_ignore_rems} { + set data "" + set skipped_rems 0 + foreach ln [split $filedata \n] { + set ln [string trim $ln] + if {[string match @REM* $ln] || [string match REM* $ln] || [string match @rem* $ln] || [string match rem* $ln]} { + #ignore + incr skipped_rems + } else { + append data $ln \n + } + } + puts stderr "Skipped $skipped_rems rem lines" + } else { + set data $filedata + } + + set lines_before_after 3 + set dsize [string length $data] + puts stdout "examining $dsize bytes of file $filepath" + puts "checking 512 byte boundaries from call sites - displaying $lines_before_after lines before and after" + set result "" + + + set file_offset 0 + set linenum 0 + set error_labels [list] + set warning_labels [list] + set file_lines [split $data \n] + foreach ln $file_lines { + set callposn -1 + incr linenum + set trimln [string trim $ln] + if {[string match "rem *" $trimln] || [string match "@rem *" $trimln] || [string match "REM *" $trimln] || [string match "@REM *" $trimln]} { + #ignore things that look like a call that are beind a REM + } else { + foreach search_regex [list {(.*\s+|^)(@*call\s*:)(\S.*)} {(.*\s+|^)(@*CALL\s*:)(\S.*)} {(.*\s+|^)(@*goto\s*:)(\S.*)} {(.*\s+|^)(@*GOTO\s*:)(\S.*)}] { + if {[regexp $search_regex $ln _m precall call labelplus]} { + set callposn [expr {$file_offset + [string length $ln]}] ;#take callposn as end of line .. review - multiline statements? + break + } + } + if {$callposn != -1} { + puts stdout "[a+ bold cyan]Found callsite '${call}${labelplus}' at byte $callposn line_num:$linenum line: $ln[a]" + set labelpluswords [regexp -inline -all {\S+} $labelplus] ;#don't assume labelplus can be treated as Tcl list - use regexp to split + set label [lindex $labelpluswords 0] + set labelsize [string length $label] + #scan forward for labels at boundaries + set forward_data [string range $data $callposn end] + set dsize [string length $forward_data] + set num_boundaries [expr {$dsize / 512} ] + puts "scanning $dsize forward bytes in file for labels - num_boundaries: $num_boundaries" + set scan_offset 0 + set total_offset $file_offset + set found_forward_label 0 + foreach scanline [split $forward_data \n] { + set line_bytes [expr {[string length $scanline] +1}] ;#+1 for unix lf + set line_start $total_offset + set line_end [expr {$total_offset + $line_bytes}] + set trimscanline [string trim $scanline] + if {[string match ":$label*" $trimscanline]} { + incr found_forward_label + set label_posn_in_line [string first : $scanline] + set labelposn [expr {$scan_offset + $label_posn_in_line}] + #we only really care about exactly landing on a boundary or else the next 512 byte boundaries above the labelposn + if {($labelposn % 512) == 0} { + set ubound [expr {($labelposn / 512) * 512}] + } else { + set ubound [expr {(($labelposn / 512)+1) * 512}] + } + set lbound [expr {$ubound - $labelsize}] + if {($labelposn >= $lbound) && ($labelposn <= $ubound)} { + lappend error_labels [list label $label call_offset_bytes $labelposn callsite [list call ${call}${labelplus} call_linenum $linenum]] + puts stdout "[a+ bold red]ERROR: label $trimscanline at offset from callsite: $labelposn total offset: $total_offset[a]" + puts stdout "[a+ bold red] This label appears to span the 512byte boundary at byte $ubound[a] [a+ yellow bold]from callsite[a]" + } else { + puts stdout "[a+ bold green]OK: label $trimscanline at offset from callsite: $labelposn total offset: $total_offset[a]" + } + } + set forward_spaninfo [range_spans_512_boundaries $line_start $line_end] + if {[dict get $forward_spaninfo is_span]} { + set boundaries [dict get $forward_spaninfo boundaries] + puts stdout "line $linenum scan from call label $label at $callposn boundaries crossed: $boundaries" + } + incr total_offset $line_bytes + incr scan_offset $line_bytes + } + + #scan behind for labels at boundaries - using offset from start of file + #we do a backward scan even if a forward label has been found, so that we can warn of duplicate labels. + + set prior_lines [lrange $file_lines 0 $linenum] ;#only scan from file start to call-site + set prior_total_offset 0 + set found_backward_label 0 + set p_linenum 0 + foreach pline $prior_lines { + incr p_linenum + set pline_bytes [expr {[string length $pline] +1}] ;#+1 for unix lf + set pline_start $prior_total_offset + set pline_end [expr {$prior_total_offset + $pline_bytes}] + set spaninfo [range_spans_512_boundaries $pline_start $pline_end] + puts stdout "line $p_linenum byte range $pline_start -> $pline_end [a+ bold purple]$spaninfo[a]" + + set trimpline [string trim $pline] + if {[string match ":$label*" $trimpline]} { + incr found_backward_label + set prior_label_posn_in_line [string first : $pline] + set prior_label_posn [expr {$prior_total_offset + $prior_label_posn_in_line}] + if {($prior_label_posn % 512) == 0} { + set p_ubound [expr {($prior_label_posn / 512) * 512}] + } else { + set p_ubound [expr {(($prior_label_posn /512) +1) * 512}] + } + set p_lbound [expr {$p_ubound - $labelsize}] + if {($prior_label_posn >= $p_lbound) && ($prior_label_posn <= $p_ubound)} { + lappend error_labels [list label $label file_offset_bytes $prior_label_posn callsite [list call ${call}${labelplus} call_linenum $linenum]] + puts stdout "[a+ bold red]ERROR: label '$trimpline' at line $p_linenum and offset from file start: $prior_label_posn total offset: $prior_total_offset[a]" + puts stdout "[a+ bold red] This label appears to span the 512byte boundary at byte $p_ubound[a] [a+ yellow bold]from file start[a]" + } else { + puts stdout "[a+ bold green]OK: prior label '$trimpline' at offset from file start: $prior_label_posn total offset: $prior_total_offset[a]" + + } + } + if {[dict get $spaninfo is_span]} { + #check boundaries within the line + set boundaries [dict get $spaninfo boundaries] + foreach b $boundaries { + if {$b == 0} { + #skip - beginning of line already handled (review?) + continue + } + #overlap test is just a warning - we have a label-like thing overlapping the boundary + set overlaptail [string range $pline [expr {$b - $labelsize}] [expr {($b + $labelsize) -1}]] ;#subtracting labelsize gives earliest possible overlap + if {[string match "*:$label *" $overlaptail] } { + lappend warning_labels [list label $label warning label_spanning callsite [list call ${call}${labelplus} call_linenum $linenum]] + puts stdout "[a+ bold yellow] WARNING: possible label $label spans boundary $b from start of file" + } + + set pline_tail [string range $pline $b end] + #if {[string match ":$label *" $pline_tail]} {} + set re1 {\s*:%lbl%[\s|^|=].*} + set re1 [string map [list %lbl% $label] $re1] + set re2 {\s*:%lbl%$} + set re2 [string map [list %lbl% $label] $re2] + if {[regexp $re1 $pline_tail] || [regexp $re2 $pline_tail]} { + lappend error_labels [list label $label file_offset_bytes $b note "label at boundary but no preceeding newline - cmd may interpret as label and execute following line or code at next boundary" callsite [list call ${call}${labelplus} call_linenum $linenum]] + puts stdout "[a+ bold red]ERROR: *possible* label '$label' at line $p_linenum and offset from file start: $b total offset: $prior_total_offset[a]" + puts stdout "[a+ bold red] This label with no preceeding newline appears to span the 512byte boundary at byte $b[a] [a+ yellow bold]from file start[a]" + puts stdout "[a+ bold yellow] label starting at $b : $pline_tail[a]" + set tail_start $b + set tail_end [expr {$b + [string length $pline_tail]}] + set tail_spaninfo [range_spans_512_boundaries $tail_start $tail_end] + if {[dict get $tail_spaninfo is_span]} { + set tail_boundaries [dict get $tail_spaninfo boundaries] + set extra_tail_boundaries [lsearch -all -inline -not $tail_boundaries $b] + if {[llength $extra_tail_boundaries]} { + puts "Line $p_linenum also spans additional boundaries: $extra_tail_boundaries" + set next_boundary [lindex $extra_tail_boundaries 0] + set next_boundary_data [string range $pline [expr {$prior_total_offset + $next_boundary}] end] + puts "Line $p_linenum data at next boundary: [a+ yellow bold]$next_boundary_data[a]" + puts "[a+ yellow bold]NOTE: cmd may attempt to treat this data as code[a]" + } + } else { + set nextline [lindex $file_lines $pline_tail+1] + puts "Line $p_linenum + 1 has data: [a+ yellow bold]$nextline[a]" + puts "[a+ yellow bold]NOTE: cmd may attempt to treat this data as code[a]" + } + } + + } + + } + incr prior_total_offset $pline_bytes + } + + if {$found_forward_label == 0} { + if {[string toupper $label] eq "EOF"} { + #EOF/eof label is special - it doesn't have to exist - but if it does - it probably shouldn't be on a boundary + puts stdout "[a+ bold green]OK: label :$label doesn't exist - but it's not required. callsite: [list call ${call}${labelplus} call_linenum $linenum] [a]" + } else { + if {$found_backward_label == 0} { + lappend warning_labels [list label $label warning label_not_found callsite [list call ${call}${labelplus} call_linenum $linenum]] + puts stdout "[a+ bold yellow]WARNING: label :$label not found (in forward or backward scan)[a]" + } + } + } + if {($found_forward_label + $found_backward_label) > 1} { + lappend warning_labels [list label $label warning multiple_labels_found callsite [list call ${call}${labelplus} call_linenum $linenum]] + puts stdout "[a+ bold yellow]WARNING: label :$label seems to appear multiple times[a]" + } + } + } + incr file_offset [string length $ln] + incr file_offset ;# for unix nl + } + if {[llength $warning_labels]} { + append result "WARNING:" \n + append result "The following labels had warnings" \n + foreach w $warning_labels { + append result " [a+ bold yellow]$w[a]" \n + } + } + if {[llength $error_labels]} { + append result "ERROR: label location errors found" \n + append result "The following labels appear to span 512 Byte boundaries or occur on boundaries without a preceding newline and are likely to cause batch script errors" \n + append result "For labels spanning boundaries the label is likely to be missed by the batch interpreter" \n + append result "For labels occuring at boundaries but not at the beginning of a line, the label may be interpreted as a label when not expected, and the interpreter may run code on next line or next boundary" \n + append result "Try adding comments and/or spacing between the call site at the call_lineum indicated and the label and then re-test in case there are further boundary collisions" \n + foreach err $error_labels { + append result " [a+ bold red]$err[a]" \n + } + } + return $result + } #specific filepath to just wrap one script at the tcl-payload or xxx-payload-pre-tcl site #scriptset name to substiture multiple scriptset.xxx files at the default locations - or as specified in scriptset.wrapconf proc multishell {filepath_or_scriptset args} { @@ -436,12 +710,27 @@ namespace eval punk::mix::commandset::scriptwrap { puts -nonewline $fdtarget $newscript close $fdtarget puts stdout "Wrote script file at $output_file" - + set check_result [checkoutput $output_file] + set with_errors "" + set with_warnings "" + if {$check_result ne ""} { + puts stdout $check_result + set check_lines [split $check_result \n] + foreach cl $check_lines { + set trimcl [string trim $cl] + if {[string match "ERROR:*" $trimcl]} { + set with_errors "[a+ bold red]with errors[a]" + } + if {[string match "WARNING:*" $trimcl]} { + set with_warnings "[a+ bold yellow] with warnings[a]" + } + } + } #even though chmod might exist on windows - we will leave permissions alone if {$::tcl_platform(platform) ne "windows"} { catch {exec chmod +x $output_file} } - puts stdout "-done-" + puts stdout "-done- $with_errors $with_warnings" return $output_file } diff --git a/src/modules/punk/mix/templates/utility/scriptappwrappers/punk-multishell.cmd b/src/modules/punk/mix/templates/utility/scriptappwrappers/punk-multishell.cmd index 2375bdde..462d0c9f 100644 --- a/src/modules/punk/mix/templates/utility/scriptappwrappers/punk-multishell.cmd +++ b/src/modules/punk/mix/templates/utility/scriptappwrappers/punk-multishell.cmd @@ -1,24 +1,62 @@ : "[rename set s;proc Hide x {proc $x args {}};Hide :]" "\$(function : {<#pwsh#>})" ^ -set -- "$@" "a=[list shebangless punk MULTISHELL tclsh sh bash cmd pwsh powershell;proc Hide x {proc $x args {}}; Hide :;Hide <#;Hide set;s 1 list]"; set -- : "$@";$1 = @' +set -- "$@" "a=[list shebangless punk MULTISHELL tclsh sh bash cmd pwsh powershell;proc Hide x {proc $x args {}}; 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 \ : "[Hide @ECHO; Hide ); Hide (;Hide echo; Hide @REM]#not necessary but can help avoid errs in testing" : << 'HEREDOC1B_HIDE_FROM_BASH_AND_SH' : Continuation char at end of this line and rem with curly-braces used to exlude Tcl from the whole cmd block \ -@REM { -@REM DO NOT MODIFY FIRST LINE OF THIS SCRIPT. shebang #! line is not required and will reduce functionality. -@REM Even comment lines can be part of the functionality of this script - modify with care. -@REM Change the value of nextshell in the next line if desired, and code within payload sections as appropriate. +: { +: STRONG SUGGESTION: DO NOT MODIFY FIRST LINE OF THIS SCRIPT. shebang #! line is not required on unix or windows and will reduce functionality and/or portability. +: Even comment lines can be part of the functionality of this script (both on unix and windows) - modify with care. +@REM ############################################################################################################################ +@REM THIS IS A POLYGLOT SCRIPT - supporting payloads in Tcl, bash, sh 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 On windows, change the value of nextshell to one of the listed 2 digit values if desired, and add code within payload sections for tcl,sh,bash,powershell as appropriate. +@REM This wrapper can be edited manually (carefully!) - or sh,bash,tcl,powershell scripts can be wrapped using the Tcl-based punkshell system +@REM e.g from within a running punkshell: pmix scriptwrap.multishell -outputfolder +@REM On unix-like systems, call with sh, bash or tclsh. (powershell untested on unix - and requires wscript if security elevation is used) +@REM Due to lack of shebang (#! line) Unix-like systems will probably (hopefully) default to sh if the script is called without an interpreter - but it may depend on the shell in use when called. +@REM If you find yourself really wanting/needing to add a shebang line - do so on the basis that the script will exist on unix-like systems only. +@SET "validshells=pwsh,sh,bash,tclsh" +@SET shells[10]="pwsh" +@SET shells[11]="sh" +@set shells[12]="bash" +@SET shells[13]="tclsh" +: @SET "nextshell=tclsh" +: +@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). +: +@SET "asadmin=0" +: @REM nextshell set to pwsh,sh,bash or tclsh @REM @ECHO nextshell is %nextshell% -@SET "validshells=pwsh,sh,bash,tclsh" @CALL SET keyRemoved=%%validshells:%nextshell%=%% @REM Note that 'powershell' e.g v5 is just a fallback for when pwsh is not available @REM ## ### ### ### ### ### ### ### ### ### ### ### ### ### @REM -- cmd/batch file section (ignored on unix) -@REM -- This section intended only to launch the next shell -@REM -- Avoid customising this if possible. cmd/batch script is probably the least expressive language and most error prone. +@REM -- This section intended mainly to launch the next shell (and to escalate privileges if necessary) +@REM -- Avoid customising this if you are not familiar with batch scripting. cmd/batch script is useful, but is probably the least expressive language and most error prone. +@REM -- For example - as this file needs to use unix-style lf line-endings - the label scanner is susceptible to the 512Byte boundary issue: https://www.dostips.com/forum/viewtopic.php?t=8988#p58888 +@REM -- This label issue can be triggered/abused in files with crlf line endings too - but it is less likely to happen accidentaly. +@REm -- See also: https://stackoverflow.com/questions/4094699/how-does-the-windows-command-interpreter-cmd-exe-parse-scripts/4095133#4095133 +@REM ############################################################################################################################ +@REM -- Due to this issue -seemingly trivial edits of the batch file section can break the script! (for Windows anyway) +@REM -- Even something as simple as adding or removing an @REM +@REM -- From within punkshell - use: +@REM -- pmix scriptwrap.checkoutput +@REM -- to check your templates or final wrapped scripts for byte boundary issues +@REM -- It will report any labels that are on boundaries +@REM -- This is why the nextshell value above is a 2 digit key instead of a string - so that editing the value doesn't change the byte offsets. +@REM -- Editing your sh,bash,tcl,pwsh payloads is much less likely to cause an issue. There is the possibility of the final batch :exit_multishell label spanning a boundary - so testing using pmix scriptwrap.checkoutput is still recommended. +@REM -- Alternatively, as you should do anyway - test the final script on windows +@REM -- Aside from adding comments/whitespace to tweak the location of labels - you can try duplicating the label (e.g just add the label on a line above) but this is not guaranteed to work in all situations. +@REM -- '@REM' is a safer comment mechanism than a leading colon - which is used sparingly here. +@REM -- A colon anywhere in the script that happens to land on a 512 Byte boundary (from file start or from a callsite) could be misinterpreted as a label +@REM -- It is unknown what versions of cmd interpreters behave this way - and pmix scriptwrap.checkoutput doesn't check all such boundaries. +@REm -- For this reason, batch labels should be chosen to be relatively unlikely to collide with other strings in the file, and simple names such as :exit or :end should probably be avoided +@REM ############################################################################################################################ @REM -- custom windows payloads should be in powershell,tclsh (or sh/bash if available) code sections @REM ## ### ### ### ### ### ### ### ### ### ### ### ### ### @SETLOCAL EnableExtensions EnableDelayedExpansion @@ -26,7 +64,59 @@ set -- "$@" "a=[list shebangless punk MULTISHELL tclsh sh bash cmd pwsh powershe @SET "fname=%~nx0" @REM @ECHO fname %fname% @REM @ECHO winpath %winpath% -@SET pwshtest_exitcode=99 +@REM @ECHO commandlineascalled %0 +@REM @ECHO commandlineresolved %~f0 +@CALL :getNormalizedScriptTail nftail +@ECHO normalizedscripttail %nftail% +@CALL :getFileTail %0 clinetail +@ECHO clinetail %clinetail% +@CALL :stringToUpper %~nx0 capscripttail +@ECHO capscriptname: %capscripttail% +@CALL :isNumeric "blah" +@CALL :isNumeric etc +@CALL :isNumeric 3 +@CALL :isNumeric 6 + +@IF %nftail%==%capscripttail% ( + @ECHO forcing asadmin=1 due to file name on filesystem being uppercase + @SET "asadmin=1" +) else ( + @CALL :stringToUpper %clinetail% capcmdlinetail + @ECHO capcmdlinetail %capcmdlintetail% + IF %clinetail%==%capcmdlinetail% ( + @ECHO forcing asadmin=1 due to cmdline scriptname in uppercase + @set "asadmin=1" + ) +) +@SET "vbsGetPrivileges=%temp%\punk_bat_elevate_%fname%.vbs" +@SET arglist=%* +@IF !asadmin!==1 ( + net file 1>NUL 2>NUL + @IF '!errorlevel!'=='0' ( GOTO gotPrivileges ) else ( GOTO getPrivileges ) +) +@GOTO skip_privileges +:getPrivileges +@IF '%1'=='PUNK-ELEVATED' (echo PUNK-ELEVATED & shift /1 & goto gotPrivileges) +@ECHO Set UAC = CreateObject^("Shell.Application"^) > "%vbsGetPrivileges%" +@ECHO args = "PUNK-ELEVATED " >> "%vbsGetPrivileges%" +@ECHO For Each strArg in WScript.Arguments >> "%vbsGetPrivileges%" +@ECHO args = args ^& strArg ^& " " >> "%vbsGetPrivileges%" +@ECHO Next >> "%vbsGetPrivileges%" +@ECHO UAC.ShellExecute "%~dp0%~n0.cmd", args, "", "runas", 1 >> "%vbsGetPrivileges%" +@ECHO Launching script in new windows due to administrator elevation +@"%SystemRoot%\System32\WScript.exe" "%vbsGetPrivileges%" %* +@EXIT /B + +:gotPrivileges +@REM setlocal & pushd . +@PUSHD . +@cd /d %~dp0 +@IF '%1'=='PUNK-ELEVATED' ( + @DEL "%vbsGetPrivileges%" 1>nul 2>nul + @SET arglist=%arglist:~14% +) + +: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" ( @@ -42,50 +132,43 @@ set -- "$@" "a=[list shebangless punk MULTISHELL tclsh sh bash cmd pwsh powershe ) :pscontinue @IF !need_ps1!==1 ( - CALL pwsh -nop -nol -c write-host "statusmessage: pwsh-found" >NUL - SET pwshtest_exitcode=!errorlevel! - if !pwshtest_exitcode!==0 ( - CALL pwsh -nop -c set-executionpolicy -Scope CurrentUser RemoteSigned - COPY "%~dp0%~n0.cmd" "%~dp0%~n0.ps1" >NUL - ) else ( - CALL powershell -nop -c set-executionpolicy -Scope CurrentUser RemoteSigned - COPY "%~dp0%~n0.cmd" "%~dp0%~n0.ps1" >NUL - ) + COPY "%~dp0%~n0.cmd" "%~dp0%~n0.ps1" >NUL ) +@REM avoid using CALL to launch pwsh,tclsh etc - it will intercept some args such as /? @IF %nextshell%==pwsh ( - if !pwshtest_exitcode!==99 ( - REM pws 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 - CALL pwsh -nop -nol -c write-host "statusmessage: pwsh-found" >NUL - SET pwshtest_exitcode=!errorlevel! - REM ECHO pwshtest_exitcode !pwshtest_exitcode! - ) + REM pws 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 + pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; 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 ( - CALL pwsh -nop -nol "%~dp0%~n0.ps1" %* & SET task_exitcode=!errorlevel! + pwsh -nop -nol -c set-executionpolicy -Scope Process Unrestricted; "%~dp0%~n0.ps1" %arglist% & SET task_exitcode=!errorlevel! ) ELSE ( REM CALL powershell -nop -nol -c write-host powershell-found - CALL powershell -nop -nol -file "%~dp0%~n0.ps1" %* + REM powershell -nop -nol -file "%~dp0%~n0.ps1" %* + powershell -nop -nol -c set-executionpolicy -Scope Process Unrestricted; %~dp0%~n0.ps1" %arglist% SET task_exitcode=!errorlevel! ) ) ELSE ( IF %nextshell%==bash ( CALL :getWslPath %winpath% wslpath REM ECHO wslfullpath "!wslpath!%fname%" - CALL %nextshell% "!wslpath!%fname%" %* & SET task_exitcode=!errorlevel! + %nextshell% "!wslpath!%fname%" %arglist% & SET task_exitcode=!errorlevel! ) ELSE ( REM probably tclsh or sh IF NOT "x%keyRemoved%"=="x%validshells%" ( - REM sh 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 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 - CALL %nextshell% "%~dp0%fname%" %* & SET task_exitcode=!errorlevel! + %nextshell% "%~dp0%fname%" %arglist% & SET task_exitcode=!errorlevel! ) ELSE ( ECHO %fname% has invalid nextshell value %nextshell% valid options are %validshells% SET task_exitcode=66 - GOTO :exit + GOTO :exit_multishell ) ) ) +@REM batch file library functions @GOTO :endlib :getWslPath @SETLOCAL @@ -101,14 +184,148 @@ set -- "$@" "a=[list shebangless punk MULTISHELL tclsh sh bash cmd pwsh powershe ECHO %result% ) ) -@GOTO :eof -:endlib +@EXIT /B + +: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 +@REM This function is designed explicitly to preserve capitalisation +@REM accepts full paths with either / or \ as delimiters - or +@SETLOCAL + @SET "rtrn=%~2" + @SET "arg=%~1" + @REM @SET "result=%_arg:*/=%" + @REM @SET "result=%~1" + @SET LF=^ + + + : The above 2 empty lines are important. Don't remove + @CALL :stringContains "!arg!" "\" hasBackSlash + @IF "!hasBackslash!"=="true" ( + @for %%A in ("!LF!") do @( + @FOR /F %%B in ("!arg:\=%%~A!") do @set "result=%%B" + ) + ) ELSE ( + @CALL :stringContains "!arg!" "/" hasForwardSlash + @IF "!hasForwardSlash!"=="true" ( + @FOR %%A in ("!LF!") do @( + @FOR /F %%B in ("!arg:/=%%~A!") do @set "result=%%B" + ) + ) ELSE ( + @set "result=%arg%" + ) + ) +@ENDLOCAL & ( + @if "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO %result% + ) +) +@EXIT /B + +:getNormalizedScriptTail +@SETLOCAL + @SET "result=%~nx0" + @SET "rtrn=%~1" +@ENDLOCAL & ( + @IF "%~1" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO %result% + ) +) +@EXIT /B + +:getNormalizedFileTailFromPath +@REM warn via echo, and do not set return variable if path not found +@REM note that %~nx1 does not preserve case of provided path - hence the name 'normalized' +@SETLOCAL + @CALL :stringContains %~1 "\" hasBackSlash + @CALL :stringContains %~1 "/" hasForwardSlash + @IF "%hasBackslash%-%hasForwardslash%"=="false-false" ( + @SET "P=%cd%%~1" + @CALL :getNormalizedFileTailFromPath "!P!" ftail2 + @SET "result=!ftail2!" + ) else ( + @IF EXIST "%~1" ( + @SET "result=%~nx1" + ) else ( + @ECHO error getNormalizedFileTailFromPath file not found: %~1 + @EXIT /B 1 + ) + ) + @SET "rtrn=%~2" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + SET "%rtrn%=%result%" + ) ELSE ( + @ECHO getNormalizedFileTailFromPath %1 result: %result% + ) +) +@EXIT /B +:stringContains +@REM usage: @CALL:stringContains string needle returnvarname +@SETLOCAL + @SET "rtrn=%~3" + @SET "string=%~1" + @SET "needle=%~2" + @IF "!string:%needle%=!"=="!string!" @( + @SET "result=false" + ) ELSE ( + @SET "result=true" + ) +@ENDLOCAL & ( + @IF "%~3" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO stringContains %string% %needle% result: %result% + ) +) +@EXIT /B + +:stringToUpper +@SETLOCAL + @SET "rtrn=%~2" + @SET "string=%~1" + @SET "capstring=%~1" + @FOR %%A in (A B C D E F G H I J K L M N O P Q R S T U V W X Y Z) DO @( + @SET "capstring=!capstring:%%A=%%A!" + ) + @SET "result=!capstring!" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO stringToUpper %string% result: %result% + ) +) +@EXIT /B + +:isNumeric +@SETLOCAL + @SET "notnumeric="&FOR /F "delims=0123456789" %%i in ("%1") do set "notnumeric=%%i" + @IF defined notnumeric ( + @SET "result=false" + ) else ( + @SET "result=true" + ) + @SET "rtrn=%~2" +@ENDLOCAL & ( + @IF "%~2" neq "" ( + @SET "%rtrn%=%result%" + ) ELSE ( + @ECHO %result% + ) +) +@EXIT /B + +:endlib : \ -@REM @SET taskexit_code=!errorlevel! & goto :exit -@GOTO :exit +@REM @SET taskexit_code=!errorlevel! & goto :exit_multishell +@GOTO :exit_multishell # } -# rem call %nextshell% "%~dp0%~n0.cmd" %* # -*- tcl -*- # ## ### ### ### ### ### ### ### ### ### ### ### ### ### # -- tcl script section @@ -123,8 +340,8 @@ set -- "$@" "a=[list shebangless punk MULTISHELL tclsh sh bash cmd pwsh powershe # -- e.g tclsh filename.cmd # -- # ## ### ### ### ### ### ### ### ### ### ### ### ### ### -rename set ""; rename s set; set k {-- "$@" "a}; if {[info exists ::env($k)]} {unset ::env($k)} ;# tidyup -Hide :exit;Hide {<#};Hide '@ +rename set ""; rename s set; set k {-- "$@" "a}; if {[info exists ::env($k)]} {unset ::env($k)} ;# tidyup and restore +Hide :exit_multishell;Hide {<#};Hide '@ namespace eval ::punk::multishell { set last_script_root [file dirname [file normalize ${argv0}/__]] set last_script [file dirname [file normalize [info script]/__]] @@ -268,16 +485,18 @@ Exit $LASTEXITCODE # heredoc2 for powershell to ignore block below $1 = @' ' -: end hide powershell-block from Tcl \ +: comment end hide powershell-block from Tcl \ # This comment with closing brace should stay in place whether 'if' commented or not } -: cmd exit label - return exitcode -:exit +: multishell cmd exit label - return exitcode +:exit_multishell : \ @REM @ECHO exitcode: !task_exitcode! : \ +@IF '%asadmin%'=='1' (echo. & @cmd /k echo elevated prompt: type exit to quit) +: \ @EXIT /B !task_exitcode! # cmd has exited -: end heredoc2 \ +: comment end heredoc2 \ '@ <# # id:tailblock0