From 70d745a61bc12d94b9b217887004c1a5c263cb9d Mon Sep 17 00:00:00 2001 From: Shay Date: Mon, 22 Sep 2025 19:02:24 +0000 Subject: [PATCH] runtime(zip): support PowerShell Core fixes: #17987 closes: #18345 Signed-off-by: Shay Signed-off-by: Christian Brabandt --- runtime/autoload/zip.vim | 161 ++++++++++++++++++++++++++++++++++++--- runtime/doc/pi_zip.txt | 15 +++- 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/runtime/autoload/zip.vim b/runtime/autoload/zip.vim index c46ec44708..49e4e81981 100644 --- a/runtime/autoload/zip.vim +++ b/runtime/autoload/zip.vim @@ -1,4 +1,4 @@ -" zip.vim: Handles browsing zipfiles + " zip.vim: Handles browsing zipfiles " AUTOLOAD PORTION " Date: 2024 Aug 21 " Version: 34 @@ -16,6 +16,7 @@ " 2024 Aug 21 by Vim Project: simplify condition to detect MS-Windows " 2025 Mar 11 by Vim Project: handle filenames with leading '-' correctly " 2025 Jul 12 by Vim Project: drop ../ on write to prevent path traversal attacks +" 2025 Sep 22 by Vim Project: support PowerShell Core " License: Vim License (see vim's :help license) " Copyright: Copyright (C) 2005-2019 Charles E. Campbell {{{1 " Permission is hereby granted to use and distribute this code, @@ -78,15 +79,124 @@ if v:version < 901 finish endif " sanity checks -if !executable(g:zip_unzipcmd) +if !executable(g:zip_unzipcmd) && &shell !~ 'pwsh' call s:Mess('Error', "***error*** (zip#Browse) unzip not available on your system") finish endif -if !dist#vim#IsSafeExecutable('zip', g:zip_unzipcmd) +if !dist#vim#IsSafeExecutable('zip', g:zip_unzipcmd) && &shell !~ 'pwsh' call s:Mess('Error', "Warning: NOT executing " .. g:zip_unzipcmd .. " from current directory!") finish endif +" ---------------- +" PowerShell: {{{1 +" ---------------- + +function! s:TryExecGnuFallBackToPs(executable, gnu_func_call, ...) + " Check that a gnu executable is available, run the gnu_func_call if so. If + " the gnu executable is not available or if gnu_func_call fails, try + " ps_func_call if &shell =~ 'pwsh'. If all attempts fail, print errors. + " a:executable - one of (g:zip_zipcmd, g:zip_unzipcmd, g:zip_extractcmd) + " a:gnu_func_call - (string) a gnu function call to execute + " a:1 - (optional string) a PowerShell function call to execute. + let failures = [] + if executable(substitute(a:executable,'\s\+.*$','','')) + try + exe a:gnu_func_call + return + catch + call add(failures, 'Failed to execute '.a:gnu_func_call) + endtry + else + call add(failures, a:executable.' not available on your system') + endif + if &shell =~ 'pwsh' && a:0 == 1 + try + exe a:1 + return + catch + call add(failures, 'Fallback to PowerShell attempted but failed') + endtry + endif + for msg in failures + call s:Mess('Error', msg) + endfor +endfunction + + +function! s:ZipBrowsePS(zipfile) + " Browse the contents of a zip file using PowerShell's + " Equivalent `unzip -Z1 -- zipfile` + let cmds = [ + \ '$zip = [System.IO.Compression.ZipFile]::OpenRead(' . s:Escape(a:zipfile, 1) . ');', + \ '$zip.Entries | ForEach-Object { $_.FullName };', + \ '$zip.Dispose()' + \ ] + return 'pwsh -NoProfile -Command ' . s:Escape(join(cmds, ' '), 1) +endfunction + +function! s:ZipReadPS(zipfile, fname, tempfile) + " Read a filename within a zipped file to a temporary file. + " Equivalent to `unzip -p -- zipfile fname > tempfile` + if a:fname =~ '/' + call s:Mess('WarningMsg', "***warning*** PowerShell can display, but cannot update, files in archive subfolders") + endif + let cmds = [ + \ '$zip = [System.IO.Compression.ZipFile]::OpenRead(' . s:Escape(a:zipfile, 1) . ');', + \ '$fileEntry = $zip.Entries | Where-Object { $_.FullName -eq ' . s:Escape(a:fname, 1) . ' };', + \ '$stream = $fileEntry.Open();', + \ '$fileStream = [System.IO.File]::Create(' . s:Escape(a:tempfile, 1) . ');', + \ '$stream.CopyTo($fileStream);', + \ '$fileStream.Close();', + \ '$stream.Close();', + \ '$zip.Dispose()' + \ ] + return 'pwsh -NoProfile -Command ' . s:Escape(join(cmds, ' '), 1) +endfunction + +function! s:ZipUpdatePS(zipfile, fname) + " Update a filename within a zipped file + " Equivalent to `zip -u zipfile fname` + if a:fname =~ '/' + call s:Mess('Error', "***error*** PowerShell cannot update files in archive subfolders") + return ':' + endif + return 'Compress-Archive -Path ' . a:fname . ' -Update -DestinationPath ' . a:zipfile +endfunction + +function! s:ZipExtractFilePS(zipfile, fname) + " Extract a single file from an archive + " Equivalent to `unzip -o zipfile fname` + if a:fname =~ '/' + call s:Mess('Error', "***error*** PowerShell cannot extract files in archive subfolders") + return ':' + endif + let cmds = [ + \ '$zip = [System.IO.Compression.ZipFile]::OpenRead(' . s:Escape(a:zipfile, 1) . ');', + \ '$fileEntry = $zip.Entries | Where-Object { $_.FullName -eq ' . a:fname . ' };', + \ '$stream = $fileEntry.Open();', + \ '$fileStream = [System.IO.File]::Create(' . a:fname . ');', + \ '$stream.CopyTo($fileStream);', + \ '$fileStream.Close();', + \ '$stream.Close();', + \ '$zip.Dispose()' + \ ] + return 'pwsh -NoProfile -Command ' . s:Escape(join(cmds, ' '), 1) +endfunction + +function! s:ZipDeleteFilePS(zipfile, fname) + " Delete a single file from an archive + " Equivalent to `zip -d zipfile fname` + let cmds = [ + \ 'Add-Type -AssemblyName System.IO.Compression.FileSystem;', + \ '$zip = [System.IO.Compression.ZipFile]::Open(' . s:Escape(a:zipfile, 1) . ', ''Update'');', + \ '$entry = $zip.Entries | Where-Object { $_.Name -eq ' . s:Escape(a:fname, 1) . ' };', + \ 'if ($entry) { $entry.Delete(); $zip.Dispose() }', + \ 'else { $zip.Dispose() }' + \ ] + return 'pwsh -NoProfile -Command ' . s:Escape(join(cmds, ' '), 1) +endfunction + " ---------------- " Functions: {{{1 " ---------------- @@ -105,7 +215,7 @@ fun! zip#Browse(zipfile) defer s:RestoreOpts(dict) " sanity checks - if !executable(g:zip_unzipcmd) + if !executable(g:zip_unzipcmd) && &shell !~ 'pwsh' call s:Mess('Error', "***error*** (zip#Browse) unzip not available on your system") return endif @@ -140,7 +250,10 @@ fun! zip#Browse(zipfile) \ '" Select a file with cursor and press ENTER']) keepj $ - exe $"keepj sil r! {g:zip_unzipcmd} -Z1 -- {s:Escape(a:zipfile, 1)}" + let gnu_cmd = "keepj sil r! " . g:zip_unzipcmd . " -Z1 -- " . s:Escape(a:zipfile, 1) + let ps_cmd = 'keepj sil r! ' . s:ZipBrowsePS(a:zipfile) + call s:TryExecGnuFallBackToPs(g:zip_unzipcmd, gnu_cmd, ps_cmd) + if v:shell_error != 0 call s:Mess('WarningMsg', "***warning*** (zip#Browse) ".fnameescape(a:zipfile)." is not a zip file") keepj sil! %d @@ -210,7 +323,7 @@ fun! zip#Read(fname,mode) endif let fname = fname->substitute('[', '[[]', 'g')->escape('?*\\') " sanity check - if !executable(substitute(g:zip_unzipcmd,'\s\+.*$','','')) + if !executable(substitute(g:zip_unzipcmd,'\s\+.*$','','')) && &shell !~ 'pwsh' call s:Mess('Error', "***error*** (zip#Read) sorry, your system doesn't appear to have the ".g:zip_unzipcmd." program") return endif @@ -220,7 +333,11 @@ fun! zip#Read(fname,mode) " but allows zipfile://... entries in quickfix lists let temp = tempname() let fn = expand('%:p') - exe "sil !".g:zip_unzipcmd." -p -- ".s:Escape(zipfile,1)." ".s:Escape(fname,1).' > '.temp + + let gnu_cmd = 'sil !' . g:zip_unzipcmd . ' -p -- ' . s:Escape(zipfile, 1) . ' ' . s:Escape(fname, 1) . ' > ' . s:Escape(temp, 1) + let ps_cmd = 'sil !' . s:ZipReadPS(zipfile, fname, temp) + call s:TryExecGnuFallBackToPs(g:zip_unzipcmd, gnu_cmd, ps_cmd) + sil exe 'keepalt file '.temp sil keepj e! sil exe 'keepalt file '.fnameescape(fn) @@ -241,7 +358,7 @@ fun! zip#Write(fname) defer s:RestoreOpts(dict) " sanity checks - if !executable(substitute(g:zip_zipcmd,'\s\+.*$','','')) + if !executable(substitute(g:zip_zipcmd,'\s\+.*$','','')) && &shell !~ 'pwsh' call s:Mess('Error', "***error*** (zip#Write) sorry, your system doesn't appear to have the ".g:zip_zipcmd." program") return endif @@ -273,7 +390,10 @@ fun! zip#Write(fname) let fname = substitute(a:fname,'^.\{-}zipfile://.\{-}::\([^\\].*\)$','\1','') endif if fname =~ '^[.]\{1,2}/' - call system(g:zip_zipcmd." -d ".s:Escape(fnamemodify(zipfile,":p"),0)." ".s:Escape(fname,0)) + let gnu_cmd = g:zip_zipcmd . ' -d ' . s:Escape(fnamemodify(zipfile,":p"),0) . ' ' . s:Escape(fname,0) + let gnu_cmd = 'call system(''' . substitute(gnu_cmd, "'", "''", 'g') . ''')' + let ps_cmd = $"call system({s:Escape(s:ZipDeleteFilePS(zipfile, fname), 1)})" + call s:TryExecGnuFallBackToPs(g:zip_zipcmd, gnu_cmd, ps_cmd) let fname = fname->substitute('^\([.]\{1,2}/\)\+', '', 'g') let need_rename = 1 endif @@ -299,7 +419,20 @@ fun! zip#Write(fname) let fname = substitute(fname, '[', '[[]', 'g') endif - call system(g:zip_zipcmd." -u ".s:Escape(fnamemodify(zipfile,":p"),0)." ".s:Escape(fname,0)) + let gnu_cmd = g:zip_zipcmd . ' -u '. s:Escape(fnamemodify(zipfile,":p"),0) . ' ' . s:Escape(fname,0) + let gnu_cmd = 'call system(''' . substitute(gnu_cmd, "'", "''", 'g') . ''')' + let ps_cmd = s:ZipUpdatePS(s:Escape(fnamemodify(zipfile, ':p'), 0), s:Escape(fname, 0)) + let ps_cmd = 'call system(''' . substitute(ps_cmd, "'", "''", 'g') . ''')' + call s:TryExecGnuFallBackToPs(g:zip_zipcmd, gnu_cmd, ps_cmd) + if &shell =~ 'pwsh' + " Vim flashes 'creation in progress ...' from what I believe is the + " ProgressAction stream of PowerShell. Unfortunately, this cannot be + " suppressed (as of 250824) due to an open PowerShell issue. + " https://github.com/PowerShell/PowerShell/issues/21074 + " This necessitates a redraw of the buffer. + redraw! + endif + if v:shell_error != 0 call s:Mess('Error', "***error*** (zip#Write) sorry, unable to update ".zipfile." with ".fname) @@ -370,10 +503,14 @@ fun! zip#Extract() endif " extract the file mentioned under the cursor - call system($"{g:zip_extractcmd} -o {shellescape(b:zipfile)} {target}") + let gnu_cmd = g:zip_extractcmd . ' -o '. shellescape(b:zipfile) . ' ' . target + let gnu_cmd = 'call system(''' . substitute(gnu_cmd, "'", "''", 'g') . ''')' + let ps_cmd = $"call system({s:Escape(s:ZipExtractFilePS(b:zipfile, target), 1)})" + call s:TryExecGnuFallBackToPs(g:zip_extractcmd, gnu_cmd, ps_cmd) + if v:shell_error != 0 call s:Mess('Error', "***error*** ".g:zip_extractcmd." ".b:zipfile." ".fname.": failed!") - elseif !filereadable(fname) + elseif !filereadable(fname) && &shell !~ 'pwsh' call s:Mess('Error', "***error*** attempted to extract ".fname." but it doesn't appear to be present!") else echomsg "***note*** successfully extracted ".fname diff --git a/runtime/doc/pi_zip.txt b/runtime/doc/pi_zip.txt index 0f7ef4ec3b..b9ee44b7e2 100644 --- a/runtime/doc/pi_zip.txt +++ b/runtime/doc/pi_zip.txt @@ -1,4 +1,4 @@ -*pi_zip.txt* For Vim version 9.1. Last change: 2025 Jul 15 +*pi_zip.txt* For Vim version 9.1. Last change: 2025 Sep 22 +====================+ | Zip File Interface | @@ -50,7 +50,7 @@ Copyright: Copyright (C) 2005-2015 Charles E Campbell *zip-copyright* allow spaces and whatnot in filenames; however, if it is incorrectly guessing the quote to use for your setup, you may use > g:zip_shq -< which by default is a single quote under Unix (') and a double quote +< which by default is a single quote under Unix (') and a double quote under Windows ("). If you'd rather have no quotes, simply set g:zip_shq to the empty string (let g:zip_shq= "") in your <.vimrc>. @@ -77,6 +77,16 @@ Copyright: Copyright (C) 2005-2015 Charles E Campbell *zip-copyright* "0": > let g:zip_exec=0 < + FALLBACK TO POWERSHELL CORE~ + + This plugin will first attempt to use the (more capable) GNU zip/unzip + commands. If these commands are not available or fail, and the user is + using PowerShell Core (i.e., the 'shell' option matches "pwsh"), the + plugin will fall back to a PowerShell Core cmdlet. The PowerShell Core + cmdlets are limited: they cannot write or extract files within + subdirectories of a zip archive. The advantage, however, is that no + separate unzip binary needs to be installed. + PREVENTING LOADING~ If for some reason you do not wish to use vim to examine zipped files, @@ -112,6 +122,7 @@ Copyright: Copyright (C) 2005-2015 Charles E Campbell *zip-copyright* ============================================================================== 4. History *zip-history* {{{1 unreleased: + Sep 19, 2025 * support PowerShell Core Jul 12, 2025 * drop ../ on write to prevent path traversal attacks Mar 11, 2025 * handle filenames with leading '-' correctly Aug 21, 2024 * simplify condition to detect MS-Windows