# -*- tcl -*- # Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from -buildversion.txt # module template: punkshell/src/decktemplates/vendor/punk/modules/template_module-0.0.3.tm # # Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem. # Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository. # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # (C) 2024 JMN # (C) 2009 Path Thoyts # # @@ Meta Begin # Application punk::zip 999999.0a1.0 # Meta platform tcl # Meta license MIT # @@ Meta End # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # doctools header # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools #[manpage_begin punkshell_module_punk::zip 0 999999.0a1.0] #[copyright "2024"] #[titledesc {Module API}] [comment {-- Name section and table of contents description --}] #[moddesc {-}] [comment {-- Description at end of page heading --}] #[require punk::zip] #[keywords module zip fileformat] #[description] #[para] - # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools #[section Overview] #[para] overview of punk::zip #[subsection Concepts] #[para] - # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ ## Requirements # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools #[subsection dependencies] #[para] packages used by punk::zip #[list_begin itemized] package require Tcl 8.6- package require punk::args #*** !doctools #[item] [package {Tcl 8.6}] #[item] [package {punk::args}] #*** !doctools #[list_end] # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #*** !doctools #[section API] # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # Base namespace # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ tcl::namespace::eval punk::zip { tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase #G-126 accelerator state - see punk::zip::accelerator and punk::zip::unzip. #The pure-Tcl reader is the always-available floor; the vendored punkzip #binary is an optional per-call extraction accelerator. variable accelerator_config auto ;#auto | none | variable accelerator_resolved "" variable accelerator_resolved_for "\uFFFF" ;#config value the cached resolution was computed for variable last_unzip_engine "" ;#tcl | accelerated - which engine the last unzip used variable last_accelerator_note "" ;#why the last unzip skipped the accelerator or fell back #*** !doctools #[subsection {Namespace punk::zip}] #[para] Core API functions for punk::zip #[list_begin definitions] proc Path_a_atorbelow_b {path_a path_b} { return [expr {[StripPath $path_b $path_a] ne $path_a}] } proc Path_a_at_b {path_a path_b} { return [expr {[StripPath $path_a $path_b] eq "." }] } proc Path_strip_alreadynormalized_prefixdepth {path prefix} { if {$prefix eq ""} { return $path } set pathparts [file split $path] set prefixparts [file split $prefix] if {[llength $prefixparts] >= [llength $pathparts]} { return "" } return [file join \ {*}[lrange \ $pathparts \ [llength $prefixparts] \ end]] } #StripPath - borrowed from tcllib fileutil # ::fileutil::stripPath -- # # If the specified path references/is a path in prefix (or prefix itself) it # is made relative to prefix. Otherwise it is left unchanged. # In the case of it being prefix itself the result is the string '.'. # # Arguments: # prefix prefix to strip from the path. # path path to modify # # Results: # path The (possibly) modified path. if {[string equal $::tcl_platform(platform) windows]} { # Windows. While paths are stored with letter-case preserved al # comparisons have to be done case-insensitive. For reference see # SF Tcllib Bug 2499641. proc StripPath {prefix path} { # [file split] is used to generate a canonical form for both # paths, for easy comparison, and also one which is easy to modify # using list commands. set prefix [file split $prefix] set npath [file split $path] if {[string equal -nocase $prefix $npath]} { return "." } if {[string match -nocase "${prefix} *" $npath]} { set path [eval [linsert [lrange $npath [llength $prefix] end] 0 file join ]] } return $path } } else { proc StripPath {prefix path} { # [file split] is used to generate a canonical form for both # paths, for easy comparison, and also one which is easy to modify # using list commands. set prefix [file split $prefix] set npath [file split $path] if {[string equal $prefix $npath]} { return "." } if {[string match "${prefix} *" $npath]} { set path [eval [linsert [lrange $npath [llength $prefix] end] 0 file join ]] } return $path } } proc Timet_to_dos {time_t} { #*** !doctools #[call [fun Timet_to_dos] [arg time_t]] #[para] convert a unix timestamp into a DOS timestamp for ZIP times. #[example { # DOS timestamps are 32 bits split into bit regions as follows: # 24 16 8 0 # +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ # |Y|Y|Y|Y|Y|Y|Y|m| |m|m|m|d|d|d|d|d| |h|h|h|h|h|m|m|m| |m|m|m|s|s|s|s|s| # +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ #}] set s [clock format $time_t -format {%Y %m %e %k %M %S}] scan $s {%d %d %d %d %d %d} year month day hour min sec expr {(($year-1980) << 25) | ($month << 21) | ($day << 16) | ($hour << 11) | ($min << 5) | ($sec >> 1)} } #Inverse of Timet_to_dos. DOS timestamps carry no timezone, and Timet_to_dos #formats in local time - so this scans in local time to keep the pair symmetric. #Entries stored with an unrepresentable date (zero, or corrupt) yield 0. proc Dos_to_timet {dosdatetime} { set dosdate [expr {($dosdatetime >> 16) & 0xFFFF}] set dostime [expr {$dosdatetime & 0xFFFF}] set year [expr {(($dosdate >> 9) & 0x7F) + 1980}] set month [expr {($dosdate >> 5) & 0x0F}] set day [expr {$dosdate & 0x1F}] set hour [expr {($dostime >> 11) & 0x1F}] set minute [expr {($dostime >> 5) & 0x3F}] set second [expr {($dostime & 0x1F) << 1}] if {$month < 1 || $month > 12 || $day < 1 || $day > 31 || $hour > 23 || $minute > 59 || $second > 59} { return 0 } set stamp [format {%04d %02d %02d %02d %02d %02d} $year $month $day $hour $minute $second] if {[catch {clock scan $stamp -format {%Y %m %d %H %M %S}} timet]} { return 0 } return $timet } #Compression method ids as they appear in a zip central directory. #punk::zip READS 0 (store) and 8 (deflate); the rest are named so an #unsupported archive is refused by name rather than by number. variable methodnames set methodnames [dict create {*}{ 0 store 1 shrink 2 reduce1 3 reduce2 4 reduce3 5 reduce4 6 implode 8 deflate 9 deflate64 10 pkware-implode 12 bzip2 14 lzma 16 cmpsc 18 terse 19 lz77 20 zstd-deprecated 93 zstd 94 mp3 95 xz 96 jpeg 97 wavpack 98 ppmd 99 aes }] proc Method_name {method} { variable methodnames if {[dict exists $methodnames $method]} { return [dict get $methodnames $method] } return method-$method } punk::args::define { @id -id ::punk::zip::walk @cmd -name punk::zip::walk -help\ "Walk the directory structure starting at base/<-subpath> and return a list of the files and folders encountered. Resulting paths are relative to base unless -resultrelative is supplied. Folder names will end with a trailing slash. " -resultrelative -optional 1 -help\ "Resulting paths are relative to this value. Defaults to the value of base. If empty string is given to -resultrelative the paths returned are effectively absolute paths." -emptydirs -default 0 -type boolean -help\ "Whether to include directory trees in the result which had no matches for the given fileglobs. Intermediate dirs are always returned if there is a match with fileglobs further down even if -emptdirs is 0. " -excludes -default "" -help "list of glob expressions to match against files and exclude" -subpath -default "" -help\ "May contain glob chars for folder elements" #If we don't include --, the call walk -- .. will return nothing as 'base' will receive the -- -- -type none -optional 1 @values -min 1 -max -1 base fileglobs -default {*} -multiple 1 } proc walk {args} { #*** !doctools #[call [fun walk] [arg ?options?] [arg base]] #[para] Walk a directory tree rooted at base #[para] the -excludes list can be a set of glob expressions to match against files and avoid #[para] e.g #[example { # punk::zip::walk -exclude {CVS/* *~.#*} library #}] #todo: -relative 0|1 flag? set argd [punk::args::parse $args withid ::punk::zip::walk] set base [dict get $argd values base] set fileglobs [dict get $argd values fileglobs] set subpath [dict get $argd opts -subpath] set excludes [dict get $argd opts -excludes] set emptydirs [dict get $argd opts -emptydirs] set received [dict get $argd received] set imatch [list] foreach fg $fileglobs { lappend imatch [file join $subpath $fg] } if {![dict exists $received -resultrelative]} { set relto $base set prefix "" } else { set relto [file normalize [dict get $argd opts -resultrelative]] if {$relto ne ""} { if {![Path_a_atorbelow_b $base $relto]} { error "punk::zip::walk base must be at or below -resultrelative value (backtracking not currently supported)" } set prefix [Path_strip_alreadynormalized_prefixdepth $base $relto] } else { set prefix $base } } set result {} #set imatch [file join $subpath $match] set files [glob -nocomplain -tails -types f -directory $base -- {*}$imatch] foreach file $files { set excluded 0 foreach glob $excludes { if {[string match $glob $file]} { set excluded 1 break } } if {!$excluded} {lappend result [file join $prefix $file]} } foreach dir [glob -nocomplain -tails -types d -directory $base -- [file join $subpath *]] { set submatches [walk -subpath $dir -emptydirs $emptydirs -excludes $excludes $base {*}$fileglobs] set subdir_entries [list] set thisdir_match [list] set has_file 0 foreach sd $submatches { set fullpath [file join $prefix $sd] ;#file join destroys trailing slash if {[string index $sd end] eq "/"} { lappend subdir_entries $fullpath/ } else { set has_file 1 lappend subdir_entries $fullpath } } if {$emptydirs} { set thisdir_match [list "[file join $prefix $dir]/"] } else { if {$has_file} { set thisdir_match [list "[file join $prefix $dir]/"] } else { set subdir_entries [list] } } #NOTE: trailing slash required for entries to be recognised as 'file type' = "directory" #This is true for 2024 Tcl9 mounted zipfs at least. zip utilities such as 7zip seem(icon correct) to recognize dirs with or without trailing slash #Although there are attributes on some systems to specify if entry is a directory - it appears trailing slash should always be used for folder names. set result [list {*}$result {*}$thisdir_match {*}$subdir_entries] } return $result } # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # Archive structure - the shared reader machinery. # # Every reading surface (extract_preamble, archive_info, members, unzip) goes # through Archive_read, so a plain zip and a zip attached to an executable are # one code path, and the archive-relative vs file-relative offset convention is # decided in exactly one place. # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #Locate and validate the End Of Central Directory record. #Scans candidate PK\5\6 signatures from the end of the file backwards - a plain #executable can contain that byte sequence in its data, so a candidate is only #accepted if it describes a single-disk directory that really begins with PK\1\2. #The first pass also requires the record to end exactly at EOF (comment length #consistent); a second pass drops that so archives with trailing junk still read. proc Eocd_scan {chan filesize} { set info [dict create {*}{ status nozip reason {no end-of-central-directory record found} eocdoffset -1 cdiroffset -1 cdirsize 0 count 0 diroffset 0 offsetbase 0 comment {} }] dict set info filesize $filesize if {$filesize < 22} { return $info } set tailstart [expr {$filesize < 65559 ? 0 : $filesize - 65559}] chan seek $chan $tailstart start set tail [read $chan] set candidates [list] set idx [string length $tail] while {1} { set p [string last "\x50\x4b\x05\x06" $tail [expr {$idx - 1}]] if {$p < 0} { break } lappend candidates [expr {$p + $tailstart}] set idx $p } foreach strict {1 0} { foreach eocdoffset $candidates { set try [Eocd_try $chan $filesize $eocdoffset $strict] if {[dict get $try status] ne "reject"} { return $try } } } return $info } proc Eocd_try {chan filesize eocdoffset strict} { if {$eocdoffset + 22 > $filesize} { return [dict create status reject] } chan seek $chan $eocdoffset start set rec [read $chan 22] binary scan $rec issssiis sig disknbr cdirdisk numondisk totalnum cdirsize diroffset commentlen set disknbr [expr {$disknbr & 0xFFFF}] set cdirdisk [expr {$cdirdisk & 0xFFFF}] set numondisk [expr {$numondisk & 0xFFFF}] set totalnum [expr {$totalnum & 0xFFFF}] set commentlen [expr {$commentlen & 0xFFFF}] set cdirsize [expr {$cdirsize & 0xFFFFFFFF}] set diroffset [expr {$diroffset & 0xFFFFFFFF}] #single-disk archives only - anything else is a spanned archive or a false positive if {$disknbr != 0 || $cdirdisk != 0 || $numondisk != $totalnum} { return [dict create status reject] } if {$strict && $eocdoffset + 22 + $commentlen != $filesize} { return [dict create status reject] } set info [dict create {*}{ status ok reason {} } filesize $filesize {*}{ } eocdoffset $eocdoffset {*}{ } cdirsize $cdirsize {*}{ } count $totalnum {*}{ } diroffset $diroffset {*}{ }] #A saturated field means the real value lives in a zip64 record we do not read. if {$totalnum == 0xFFFF || $cdirsize == 0xFFFFFFFF || $diroffset == 0xFFFFFFFF} { dict set info status unsupported dict set info reason "zip64 archive - punk::zip reads single-disk zip32 archives only" dict set info cdiroffset -1 dict set info offsetbase 0 dict set info comment "" return $info } set cdiroffset [expr {$eocdoffset - $cdirsize}] if {$cdiroffset < 0} { return [dict create status reject] } if {$totalnum > 0} { chan seek $chan $cdiroffset start if {[read $chan 4] ne "\x50\x4b\x01\x02"} { return [dict create status reject] } } elseif {$cdirsize != 0} { return [dict create status reject] } #The recorded directory offset is either archive-relative (the directory sits #that far past a preamble) or file-relative (it IS the file position). The #difference between where the directory actually is and where the record says #it is therefore gives the preamble length directly; negative means neither #reading holds and this candidate is not a real EOCD. set offsetbase [expr {$cdiroffset - $diroffset}] if {$offsetbase < 0} { return [dict create status reject] } set comment "" if {$commentlen > 0} { chan seek $chan [expr {$eocdoffset + 22}] start set comment [read $chan $commentlen] } dict set info cdiroffset $cdiroffset dict set info offsetbase $offsetbase dict set info comment $comment return $info } punk::args::define { @id -id ::punk::zip::Cdir_records @cmd -name punk::zip::Cdir_records\ -summary\ "Parse every central directory record of an archive"\ -help\ "Walk ALL central directory file headers and return one member dict per entry, in stored order. Sizes and offsets come from the central directory, which sidesteps data descriptors entirely. See punk::zip::members for the member dict keys." @values -min 2 -max 2 chan -help "open binary channel positioned anywhere in the archive" info -type dict -help "archive dict from Eocd_scan with status ok" } proc Cdir_records {chan info} { set cdiroffset [dict get $info cdiroffset] set cdirsize [dict get $info cdirsize] set count [dict get $info count] set offsetbase [dict get $info offsetbase] if {$count == 0} { return [list] } chan seek $chan $cdiroffset start set cd [read $chan $cdirsize] if {[string length $cd] != $cdirsize} { error "punk::zip: central directory truncated - wanted $cdirsize bytes at $cdiroffset, got [string length $cd]" } set members [list] set pos 0 for {set i 0} {$i < $count} {incr i} { if {$pos + 46 > $cdirsize} { error "punk::zip: central directory truncated - record [expr {$i + 1}] of $count starts beyond the directory" } binary scan $cd @${pos}issssssiiisssssii sig madeby version flags method dostime dosdate crc csize size namelen extralen commentlen disknbr iattr eattr offset if {$sig != 33639248} { error "punk::zip: bad central directory record [expr {$i + 1}] of $count - expected signature PK\\1\\2" } set madeby [expr {$madeby & 0xFFFF}] set version [expr {$version & 0xFFFF}] set flags [expr {$flags & 0xFFFF}] set method [expr {$method & 0xFFFF}] set dostime [expr {$dostime & 0xFFFF}] set dosdate [expr {$dosdate & 0xFFFF}] set namelen [expr {$namelen & 0xFFFF}] set extralen [expr {$extralen & 0xFFFF}] set commentlen [expr {$commentlen & 0xFFFF}] set iattr [expr {$iattr & 0xFFFF}] set crc [expr {$crc & 0xFFFFFFFF}] set csize [expr {$csize & 0xFFFFFFFF}] set size [expr {$size & 0xFFFFFFFF}] set eattr [expr {$eattr & 0xFFFFFFFF}] set offset [expr {$offset & 0xFFFFFFFF}] set namestart [expr {$pos + 46}] set rawname [string range $cd $namestart [expr {$namestart + $namelen - 1}]] set commentstart [expr {$namestart + $namelen + $extralen}] set rawcomment [string range $cd $commentstart [expr {$commentstart + $commentlen - 1}]] set packedtime [expr {($dosdate << 16) | $dostime}] set name [Decode_text $rawname $flags] set hostsystem [expr {($madeby >> 8) & 0xFF}] lappend members [dict create {*}{ } name $name {*}{ } isdirectory [Is_directory_entry $name $size $eattr $hostsystem] {*}{ } size $size {*}{ } csize $csize {*}{ } method $method {*}{ } methodname [Method_name $method] {*}{ } mtime [Dos_to_timet $packedtime] {*}{ } dostime $packedtime {*}{ } crc $crc {*}{ } offset $offset {*}{ } fileoffset [expr {$offsetbase + $offset}] {*}{ } flags $flags {*}{ } encrypted [expr {($flags & 0x41) != 0}] {*}{ } attributes $eattr {*}{ } iattributes $iattr {*}{ } madeby $madeby {*}{ } hostsystem $hostsystem {*}{ } version $version {*}{ } comment [Decode_text $rawcomment $flags] {*}{ }] incr pos [expr {46 + $namelen + $extralen + $commentlen}] } return $members } #Member names and comments are utf-8 when general purpose bit 11 is set #(punk::zip::mkzip always sets it), and cp437 otherwise. proc Decode_text {bytes flags} { if {$bytes eq ""} { return "" } if {$flags & 0x800} { return [encoding convertfrom utf-8 $bytes] } if {[catch {encoding convertfrom cp437 $bytes} decoded]} { set decoded [encoding convertfrom iso8859-1 $bytes] } return $decoded } #A trailing slash is the portable marker; the FAT directory attribute and the #unix S_IFDIR mode bits are accepted as secondary evidence for archives that #store directory entries without one. proc Is_directory_entry {name size eattr hostsystem} { if {[string index $name end] eq "/"} { return 1 } if {$hostsystem == 3 && (($eattr >> 16) & 0xF000) == 0x4000} { return 1 } if {$size == 0 && ($eattr & 0x10)} { return 1 } return 0 } punk::args::define { @id -id ::punk::zip::Archive_read @cmd -name punk::zip::Archive_read\ -summary\ "Read the structure of a zip archive on an open channel"\ -help\ "Derive the archive geometry and read every central directory record. Returns a 2-element dict: 'info' (see punk::zip::archive_info for the keys) and 'members' (see punk::zip::members). Never raises for a file that simply is not an archive - callers decide what a non-ok status means. Does raise for an archive whose central directory is truncated or malformed." @values -min 1 -max 1 chan -help "open channel - reconfigured to binary translation by this call" } proc Archive_read {chan} { chan configure $chan -translation binary chan seek $chan 0 end set filesize [tell $chan] set info [Eocd_scan $chan $filesize] if {[dict get $info status] ne "ok"} { #the whole file is preamble as far as any splitting caller is concerned dict set info dataoffset $filesize dict set info offsetstyle none return [dict create info $info members {}] } set members [Cdir_records $chan $info] set offsetbase [dict get $info offsetbase] set minoffset "" foreach m $members { set o [dict get $m offset] if {$minoffset eq "" || $o < $minoffset} { set minoffset $o } } if {$minoffset eq ""} { set minoffset 0 } if {$offsetbase > 0} { #external preamble, archive-relative offsets: the archive begins exactly #where its own offsets say it does set dataoffset $offsetbase set offsetstyle archive } else { #file-relative offsets (or no preamble at all): the archive begins at the #topmost local file header the directory points to. Looking at ALL records #rather than just the first is what makes this reliable - records are in no #guaranteed order. set dataoffset $minoffset set offsetstyle [expr {$minoffset > 0 ? "file" : "plain"}] } dict set info dataoffset $dataoffset dict set info offsetstyle $offsetstyle if {[llength $members]} { #the derivation is only trustworthy if a local file header really sits #where the first record says it does chan seek $chan [expr {$offsetbase + $minoffset}] start if {[read $chan 4] ne "\x50\x4b\x03\x04"} { dict set info status unsupported dict set info reason "no local file header at derived archive position [expr {$offsetbase + $minoffset}] - offsets do not describe this file" } } return [dict create info $info members $members] } #if there is an external preamble - extract that. (if there is also an internal preamble - ignore and consider part of the archive-data) #Otherwise extract an internal preamble. #if neither -? #review - reconsider auto-determination of internal vs external preamble punk::args::define { @id -id ::punk::zip::extract_preamble @cmd -name punk::zip::extract_preamble -help\ "Split a zipfs based executable or library into its constituent binary and zip parts. Note that the binary preamble might be either 'within' the zip offsets, or simply catenated prior to an unadjusted zip. Some build processes may have 'adjusted' the zip offsets to make the zip cover the entire file ('file based' offset) whilst the more modern approach is to simply concatenate the binary and the zip ('archive based' offset). An archive-based offset is simpler and more reliably points to the proper split location. It also allows 'zipfs info //zipfs:/app' to return the correct offset information. Either way, extract_preamble can usually separate them, but in the unusual case that there is both an external preamble and a preamble within the zip, only the external preamble will be split, with the internal one remaining in the zip. The inverse of this process would be to extract the .zip file created by this split to a folder, e.g extracted_zip_folder (adjusting contents as required) and then to run: zipfs mkimg newbinaryname.exe extracted_zip_folder \"\" " @values -min 2 -max 3 infile -type file -optional 0 -help\ "Name of existing tcl executable or shared lib with attached zipfs filesystem" outfile_preamble -optional 0 -type file -help\ "Name of output file for binary preamble to be extracted to. If this file already exists, an error will be raised" outfile_zip -default "" -type file -help\ "Name of output file for zip data to be extracted to. If this file already exists, an error will be raised" } proc extract_preamble {args} { set argd [punk::args::parse $args withid ::punk::zip::extract_preamble] lassign [dict values $argd] leaders opts values received set infile [dict get $values infile] set outfile_preamble [dict get $values outfile_preamble] set outfile_zip [dict get $values outfile_zip] set inzip [open $infile r] fconfigure $inzip -encoding iso8859-1 -translation binary if {[file exists $outfile_preamble]} { error "outfile_preamble $outfile_preamble already exists - please remove first" } if {$outfile_zip ne ""} { if {[file exists $outfile_zip] && [file size $outfile_zip]} { error "outfile_zip $outfile_zip already exists - please remove first" } } chan seek $inzip 0 end set insize [tell $inzip] ;#faster (including seeks) than calling out to filesystem using file size - but should be equivalent #Archive_read does the whole derivation: it finds the EOCD (rejecting the #PK\5\6 byte sequences that occur in plain executables), decides whether the #recorded offsets are archive-relative or file-relative, and walks ALL central #directory records to find the topmost local file header in the file-relative #case. dataoffset is the split point either way. #expect CDFH PK\1\2 #above the CD - we expect a bunch of PK\3\4 records - (possibly not all of them pointed to by the CDR) #above that we expect: *possibly* a stored password with trailing marker - then the prefixed exe/script set info [dict get [Archive_read $inzip] info] switch -- [dict get $info status] { ok { set baseoffset [dict get $info dataoffset] } nozip { #no zip eocdr - consider entire file to be the zip preamble set baseoffset $insize } default { close $inzip error "unable to determine zip baseoffset of file $infile - [dict get $info reason]" } } if {$baseoffset < $insize} { set pout [open $outfile_preamble w] fconfigure $pout -encoding iso8859-1 -translation binary chan seek $inzip 0 start chan copy $inzip $pout -size $baseoffset close $pout if {$outfile_zip ne ""} { #A file-relative archive splits into a .zip whose offsets still count #from the removed preamble - readers that only accept a bare .zip choke #on it. punk::zip's own reader never needs the split: point it at the #ORIGINAL file and the derived base offset makes the shape irrelevant. #Rewriting the offsets here stays open for callers that must hand a #plain .zip to something else. set zout [open $outfile_zip w] fconfigure $zout -encoding iso8859-1 -translation binary chan copy $inzip $zout close $zout } close $inzip } else { #no valid (from our perspective) eocdr found - baseoffset has been set to insize close $inzip file copy $infile $outfile_preamble if {$outfile_zip ne ""} { #touch equiv? set fd [open $outfile_zip w] close $fd } } } # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # Reading - archive_info / members / unzip # # Stock Tcl only: no zipfs, no vfs::zip, no tcllib. Works on a plain .zip and on # a zip attached to an executable or script prefix, under either offset # convention, because everything reads the ORIGINAL file at a derived base # offset rather than a split-off intermediate. # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ #open + read structure + close, for the surfaces that take a filename proc Open_archive {zipfile requirezip} { if {![file exists $zipfile]} { error "punk::zip: no such file '$zipfile'" } set chan [open $zipfile rb] if {[catch {Archive_read $chan} arc erropts]} { close $chan return -options $erropts $arc } set info [dict get $arc info] set status [dict get $info status] if {$requirezip && $status ne "ok"} { close $chan switch -- $status { nozip { error "punk::zip: '$zipfile' is not a zip archive - [dict get $info reason]" } default { error "punk::zip: cannot read '$zipfile' - [dict get $info reason]" } } } return [dict set arc chan $chan] } #glob selection shared by members and unzip. Patterns are matched against the #member name both as stored and with any trailing slash removed, so a pattern #like lib/* selects the lib/ directory entry as well as its contents. proc Select_members {members globs excludes} { set selected [list] foreach m $members { set name [dict get $m name] set bare [string trimright $name /] set matched 0 foreach g $globs { if {[string match $g $name] || [string match $g $bare]} { set matched 1 break } } if {!$matched} { continue } set excluded 0 foreach e $excludes { if {[string match $e $name] || [string match $e $bare]} { set excluded 1 break } } if {!$excluded} { lappend selected $m } } return $selected } #Member names are archive data, not trusted paths: a name that escapes the target #directory is refused rather than followed. Backslashes are normalised to forward #slashes (some writers emit them) so the check cannot be side-stepped on windows. proc Safe_member_path {name} { set segments [list] foreach seg [split [string map {\\ /} $name] /] { if {$seg eq "" || $seg eq "."} { continue } if {$seg eq ".."} { error "punk::zip: refusing member '$name' - parent-directory segment would escape the target directory" } lappend segments $seg } if {![llength $segments]} { error "punk::zip: refusing member '$name' - empty path" } if {[file pathtype [lindex $segments 0]] ne "relative"} { error "punk::zip: refusing member '$name' - not a relative path" } return $segments } #Reasons punk::zip cannot extract a member. Checked for every selected member #BEFORE anything is written, so an unsupported archive produces an error instead #of a partly-populated target directory. proc Member_unsupported_reason {m} { if {[dict get $m encrypted]} { return "entry is encrypted - punk::zip cannot decrypt zip entries" } if {[dict get $m size] == 0xFFFFFFFF || [dict get $m csize] == 0xFFFFFFFF || [dict get $m offset] == 0xFFFFFFFF} { return "entry uses zip64 size/offset fields - punk::zip reads zip32 entries only" } set method [dict get $m method] if {$method ni {0 8}} { return "entry uses compression method $method ([dict get $m methodname]) - punk::zip reads stored (0) and deflated (8) entries only" } return "" } #Read one member's data from the archive and write it to target. #Sizes and offsets come from the central directory, so entries written with a #data descriptor (streamed, sizes zero in the local header) read correctly. proc Extract_member {chan m target verify} { set name [dict get $m name] set fileoffset [dict get $m fileoffset] set csize [dict get $m csize] set size [dict get $m size] set method [dict get $m method] chan seek $chan $fileoffset start set lfh [read $chan 30] if {[string length $lfh] != 30} { error "punk::zip: truncated local file header for member '$name' at offset $fileoffset" } binary scan $lfh isssssiiiss lsig lversion lflags lmethod ltime ldate lcrc lcsize lsize lnamelen lextralen #dec2hex 67324752 = 4034B50 = PK\3\4 if {$lsig != 67324752} { error "punk::zip: no local file header for member '$name' at offset $fileoffset - archive offsets do not describe this file" } set lnamelen [expr {$lnamelen & 0xFFFF}] set lextralen [expr {$lextralen & 0xFFFF}] chan seek $chan [expr {$fileoffset + 30 + $lnamelen + $lextralen}] start set out [open $target wb] set crc 0 set written 0 try { if {$size < 0x00200000} { #small member - one chunk, mirroring Addentry's 2MB write threshold set data "" if {$csize > 0} { set data [read $chan $csize] if {[string length $data] != $csize} { error "punk::zip: truncated data for member '$name' - wanted $csize bytes, got [string length $data]" } if {$method == 8} { set data [zlib inflate $data] } } set crc [zlib crc32 $data] set written [string length $data] puts -nonewline $out $data } else { #large member - stream, to avoid holding the whole thing in memory set strm "" if {$method == 8} { set strm [zlib stream inflate] } set remaining $csize while {$remaining > 0} { set want [expr {$remaining > 65536 ? 65536 : $remaining}] set chunk [read $chan $want] if {[string length $chunk] != $want} { error "punk::zip: truncated data for member '$name' - archive ends mid-entry" } incr remaining -$want if {$strm eq ""} { set crc [zlib crc32 $chunk $crc] incr written [string length $chunk] puts -nonewline $out $chunk continue } if {$remaining == 0} { $strm put -finalize $chunk } else { $strm put $chunk } #a single get returns only what the stream has already produced - #drain until empty or the tail of the member is silently dropped while {1} { set plain [$strm get 65536] if {$plain eq ""} { break } set crc [zlib crc32 $plain $crc] incr written [string length $plain] puts -nonewline $out $plain } } if {$strm ne ""} { $strm close } } close $out set out "" if {$written != $size} { error "punk::zip: size mismatch for member '$name' - central directory says $size bytes, decompressed to $written" } if {$verify && ($crc & 0xFFFFFFFF) != [dict get $m crc]} { error "punk::zip: crc mismatch for member '$name' - archive records [dict get $m crc], extracted data is [expr {$crc & 0xFFFFFFFF}]" } } on error {result erropts} { if {$out ne ""} { catch {close $out} } #never leave unverified bytes behind catch {file delete -- $target} return -options $erropts $result } return $written } punk::args::define { @id -id ::punk::zip::archive_info @cmd -name punk::zip::archive_info\ -summary\ "Report the zip-archive geometry of a file"\ -help\ "Report where the zip archive inside a file begins and which offset convention it uses, without extracting anything. The file may be a plain .zip or a zip attached to an executable or script prefix - a punk kit, a tclsh carrying a zipfs image, a zip-based .tm modpod. This is the derivation punk::zip::members, punk::zip::unzip and punk::zip::extract_preamble all share. Does not raise for a file that simply is not an archive - read 'status'. Returned dict keys: status ok | nozip | unsupported reason why status is not ok filesize size of the whole file offsetstyle plain | archive | file | none plain - a bare zip, no prefix archive - prefixed, offsets counted from the zip file - prefixed, offsets counted from the file none - no archive found dataoffset where the zip data starts, ie the prefix length offsetbase add this to a member's stored offset for a file position eocdoffset file position of the end-of-central-directory record cdiroffset file position of the central directory cdirsize size of the central directory count number of members diroffset central directory offset as recorded in the archive comment archive comment Examples: #which convention is this runtime using? dict get [punk::zip::archive_info bin/punk91.exe] offsetstyle #how big is the executable prefix? dict get [punk::zip::archive_info bin/punk91.exe] dataoffset #is there an archive attached at all? (status is ok when there is) dict get [punk::zip::archive_info some.exe] status " @values -min 1 -max 1 zipfile -type existingfile -help\ "Path of a zip archive, or of a file with one attached" } proc archive_info {args} { set argd [punk::args::parse $args withid ::punk::zip::archive_info] set zipfile [dict get $argd values zipfile] set arc [Open_archive $zipfile 0] close [dict get $arc chan] return [dict get $arc info] } punk::args::define { @id -id ::punk::zip::members @cmd -name punk::zip::members\ -summary\ "List the members of a zip archive without extracting"\ -help\ "Read the central directory of a zip archive and return one dict per member, in stored order, without decompressing anything. The archive may be a plain .zip or a zip attached to an executable or script prefix - see punk::zip::archive_info. Requires only stock Tcl: no zipfs, no vfs::zip, no tcllib. Entries punk::zip cannot EXTRACT (encrypted, zip64, an unsupported compression method) are still listed - the refusal belongs to punk::zip::unzip, which names the reason. Each member dict carries: name member path as stored (directories keep a trailing /) isdirectory 1 for a directory entry, else 0 size uncompressed size in bytes csize compressed size in bytes method compression method id (0 store, 8 deflate) methodname symbolic name for that method mtime modification time as a unix timestamp (0 if unusable) dostime the packed DOS date/time exactly as stored crc crc32 of the uncompressed data offset local header offset as stored in the central directory fileoffset absolute position of that local header in the file flags general purpose bit flag encrypted 1 if the entry is encrypted attributes external file attributes as stored iattributes internal file attributes as stored madeby version-made-by field hostsystem host system code (upper byte of madeby - 0 fat, 3 unix) version version needed to extract comment per-member comment Examples: #everything inside a kit punk::zip::members bin/punk91.exe #just the tcl_library scripts punk::zip::members bin/punk91.exe tcl_library/*.tcl #total uncompressed size set bytes 0 foreach m [punk::zip::members some.zip] {incr bytes [dict get \$m size]} #names only, files not directories set names [list] foreach m [punk::zip::members some.zip] { if {![dict get \$m isdirectory]} {lappend names [dict get \$m name]} } " @opts -exclude -default {} -help\ "List of glob expressions matched against member names. A member matching any of them is left out of the result." -- -type none -optional 1 -help\ "End of options marker" @values -min 1 -max -1 zipfile -type existingfile -help\ "Path of a zip archive, or of a file with one attached" globs -default {*} -multiple 1 -help\ "Glob patterns matched against member names with 'string match'. A member is included if it matches any of them. The whole stored name is matched, so * spans path separators: *.txt selects sub/deeper/notes.txt as well as top.txt. Directory entries match with or without their trailing slash." } proc members {args} { set argd [punk::args::parse $args withid ::punk::zip::members] set zipfile [dict get $argd values zipfile] set globs [dict get $argd values globs] set excludes [dict get $argd opts -exclude] set arc [Open_archive $zipfile 1] close [dict get $arc chan] return [Select_members [dict get $arc members] $globs $excludes] } punk::args::define { @id -id ::punk::zip::accelerator @cmd -name punk::zip::accelerator\ -summary\ "Query or configure the optional punkzip extraction accelerator"\ -help\ "punk::zip can hand whole-archive extraction to the vendored punkzip tool (goal G-126; built to bin/ by 'make.tcl tool build punkzip') when a usable binary is present - materially faster for large member counts - while the pure-Tcl reader remains the always-available floor and the authority on preflight refusals, member selection and returned names. With no argument, returns the currently resolved accelerator path, or an empty string when none is usable. With an argument, sets the configuration and returns the new resolution: auto - (default) probe: env(PUNKZIP_EXE) if set, else a punkzip executable beside [info nameofexecutable] none - disable the accelerator (pure-Tcl always) - use the punkzip binary at an explicit path Resolution is cached until the configuration changes. See punk::zip::unzip for when the accelerator is actually used: calls it cannot serve identically run pure-Tcl, and an accelerator failure falls back to pure-Tcl silently. For diagnostics and tests, punk::zip::last_unzip_engine records which engine the last unzip used (tcl|accelerated) and punk::zip::last_accelerator_note records why the accelerator was skipped or abandoned." @values -min 0 -max 1 config -optional 1 -default "" -help\ "auto | none | path of a punkzip executable. Empty (or omitted) queries without changing the configuration." } proc accelerator {args} { set argd [punk::args::parse $args withid ::punk::zip::accelerator] variable accelerator_config variable accelerator_resolved variable accelerator_resolved_for set config [dict get $argd values config] if {$config ne ""} { set accelerator_config $config } if {$accelerator_resolved_for eq $accelerator_config} { return $accelerator_resolved } set resolved "" set candidates [list] switch -exact -- $accelerator_config { none {} auto { if {[info exists ::env(PUNKZIP_EXE)] && $::env(PUNKZIP_EXE) ne ""} { lappend candidates $::env(PUNKZIP_EXE) } else { set exedir [file dirname [info nameofexecutable]] lappend candidates [file join $exedir punkzip.exe] [file join $exedir punkzip] } } default { lappend candidates $accelerator_config } } foreach c $candidates { #file executable is unreliable for some windows setups - existence suffices there if {[file isfile $c] && ([file executable $c] || $::tcl_platform(platform) eq "windows")} { set resolved [file normalize $c] break } } set accelerator_resolved $resolved set accelerator_resolved_for $accelerator_config return $resolved } punk::args::define { @id -id ::punk::zip::unzip @cmd -name punk::zip::unzip\ -summary\ "Extract a zip archive to a directory"\ -help\ "Extract all or part of a zip archive into targetdir, verifying each member's crc32 as it is written. Directory entries are created as directories; intermediate directories of a selected file are created whether or not the archive stores an entry for them. The archive may be a plain .zip or a zip attached to an executable or script prefix - see punk::zip::archive_info. Requires only stock Tcl: no zipfs, no vfs::zip, no tcllib. Every selected member is checked for extractability BEFORE anything is written, so an encrypted, zip64 or unknown-compression archive fails naming the reason instead of leaving a half-populated directory. A member whose path would escape targetdir is refused the same way. When the punkzip accelerator is available (see punk::zip::accelerator) and the call is one it serves identically - whole archive, default -overwrite/-mtime/-verify, ascii member names - the member data is written by the accelerator instead of the pure-Tcl loop, with mtimes re-stamped to this module's convention afterwards; preflight, member selection and the returned names always come from punk::zip's own reader, and any accelerator failure falls back to pure Tcl silently. punk::zip::last_unzip_engine records which engine ran. Returns the list of member names extracted (see -return). Examples: #whole archive punk::zip::unzip some.zip /tmp/out #lift a runtime's tcl_library out of the executable it is attached to punk::zip::unzip bin/punk91.exe /tmp/rt tcl_library/* #everything except the docs, keeping timestamps punk::zip::unzip -exclude {doc/*} -- some.zip /tmp/out " @opts -exclude -default {} -help\ "List of glob expressions matched against member names. A member matching any of them is not extracted." -overwrite -default 1 -type boolean -help\ "Whether an existing file in targetdir may be replaced. With -overwrite 0 an existing target is an error, raised before anything is written." -mtime -default 1 -type boolean -help\ "Whether to restore each member's stored modification time. Directory times are applied after their contents are written." -verify -default 1 -type boolean -help\ "Whether to check each extracted member against the crc32 stored in the archive. A mismatch is an error and the partial file is removed." -return -default list -choices {list pretty none} -help\ "list - return the extracted member names (default) pretty - format that list for terminal display none - return the empty string" -- -type none -optional 1 -help\ "End of options marker" @values -min 2 -max -1 zipfile -type existingfile -help\ "Path of a zip archive, or of a file with one attached" targetdir -type directory -help\ "Directory to extract into. Created if it does not exist." globs -default {*} -multiple 1 -help\ "Glob patterns matched against member names with 'string match'. A member is extracted if it matches any of them. The whole stored name is matched, so * spans path separators: *.txt selects sub/deeper/notes.txt as well as top.txt. Directory entries match with or without their trailing slash." } proc unzip {args} { set argd [punk::args::parse $args withid ::punk::zip::unzip] set zipfile [dict get $argd values zipfile] set targetdir [dict get $argd values targetdir] set globs [dict get $argd values globs] set excludes [dict get $argd opts -exclude] set overwrite [dict get $argd opts -overwrite] set restoremtime [dict get $argd opts -mtime] set verify [dict get $argd opts -verify] variable last_unzip_engine variable last_accelerator_note set last_unzip_engine tcl set last_accelerator_note "" set arc [Open_archive $zipfile 1] set chan [dict get $arc chan] set extracted [list] set dirtimes [list] try { set selected [Select_members [dict get $arc members] $globs $excludes] #preflight - nothing is written until every selected member is known good set targets [list] set names_ascii 1 foreach m $selected { set reason [Member_unsupported_reason $m] if {$reason ne ""} { error "punk::zip::unzip: cannot extract '[dict get $m name]' from $zipfile - $reason" } set target [file join $targetdir {*}[Safe_member_path [dict get $m name]]] if {!$overwrite && !([dict get $m isdirectory]) && [file exists $target]} { error "punk::zip::unzip: '$target' already exists and -overwrite is 0" } if {![string is ascii [dict get $m name]]} { set names_ascii 0 } lappend targets $target } file mkdir $targetdir #G-126 accelerator: hand whole-archive member WRITING to the vendored #punkzip binary when this call is one it serves identically. punk::zip's #own parse above stays authoritative for preflight refusals, member #selection and the returned names; the pure-Tcl loop below is the #always-available floor and any accelerator failure falls back to it #silently. Eligibility: whole archive (globs {*}, no excludes), the #default -overwrite/-mtime/-verify semantics punkzip matches (it always #overwrites, restores times and crc-verifies), and ascii member names #(punkzip's name handling is wtf-8; punk::zip decodes cp437/utf-8 flags). #punkzip stamps mtimes with its tz-free utc convention, so after a #successful run the members are re-stamped below with this module's #local-time convention - the two engines produce identical trees. set accelerated 0 if {$verify && $overwrite && $restoremtime && $names_ascii && [llength $excludes] == 0 && [llength $globs] == 1 && [lindex $globs 0] eq "*"} { set acc [accelerator] if {$acc eq ""} { set last_accelerator_note "no accelerator binary resolved (see punk::zip::accelerator)" } elseif {[catch {exec $acc extract -d $targetdir $zipfile 2>@1} accout]} { set last_accelerator_note "accelerator '$acc' failed - fell back to pure tcl: [string range $accout 0 300]" } else { set accelerated 1 } } else { set last_accelerator_note "call not accelerator-eligible (selective, non-default options, or non-ascii names) - pure tcl" } if {$accelerated} { set last_unzip_engine accelerated foreach m $selected target $targets { if {[dict get $m isdirectory]} { if {[dict get $m mtime]} { lappend dirtimes $target [dict get $m mtime] } } else { if {[dict get $m mtime]} { catch {file mtime $target [dict get $m mtime]} } } lappend extracted [dict get $m name] } } else { foreach m $selected target $targets { set name [dict get $m name] if {[dict get $m isdirectory]} { file mkdir $target if {$restoremtime && [dict get $m mtime]} { lappend dirtimes $target [dict get $m mtime] } } else { file mkdir [file dirname $target] Extract_member $chan $m $target $verify if {$restoremtime && [dict get $m mtime]} { catch {file mtime $target [dict get $m mtime]} } } lappend extracted $name } } } finally { close $chan } #directory times last - writing their contents would have reset them foreach {dir mtime} $dirtimes { catch {file mtime $dir $mtime} } switch -exact -- [dict get $argd opts -return] { pretty { if {[info commands showlist] ne ""} { return [plist -channel none extracted] } return $extracted } none { return "" } default { return $extracted } } } punk::args::define { @id -id ::punk::zip::Addentry @cmd -name punk::zip::Addentry\ -summary\ "Add zip-entry for file at 'path'"\ -help\ "Add a single file at 'path' to open channel 'zipchan' return a central directory file record" @opts -comment -default "" -help "An optional comment specific to the added file" @values -min 3 -max 4 zipchan -help "open file descriptor with cursor at position appropriate for writing a local file header" base -help "base path for entries" path -type file -help "path of file to add" zipdataoffset -default 0 -type integer -range {0 ""} -help "offset of start of zip-data - ie length of prefixing script/exe Can be specified as zero even if a prefix exists - which would make offsets 'file relative' as opposed to 'archive relative'" } # Addentry - was Mkzipfile -- # # FIX ME: should handle the current offset for non-seekable channels # proc Addentry {args} { #*** !doctools #[call [fun Addentry] [arg zipchan] [arg base] [arg path] [arg ?comment?]] #[para] Add a single file to a zip archive #[para] The zipchan channel should already be open and binary. #[para] You can provide a -comment for the file. #[para] The return value is the central directory record that will need to be used when finalizing the zip archive. set argd [punk::args::parse $args withid ::punk::zip::Addentry] set zipchan [dict get $argd values zipchan] set base [dict get $argd values base] set path [dict get $argd values path] set zipdataoffset [dict get $argd values zipdataoffset] set comment [dict get $argd opts -comment] set fullpath [file join $base $path] set mtime [Timet_to_dos [file mtime $fullpath]] set utfpath [encoding convertto utf-8 $path] set utfcomment [encoding convertto utf-8 $comment] set flags [expr {(1<<11)}] ;# utf-8 comment and path set method 0 ;# store 0, deflate 8 set attr 0 ;# text or binary (default binary) set version 20 ;# minumum version req'd to extract set extra "" set crc 0 set size 0 set csize 0 set data "" set seekable [expr {[tell $zipchan] != -1}] if {[file isdirectory $fullpath]} { set attrex 0x41ff0010 ;# 0o040777 (drwxrwxrwx) #set attrex 0x40000010 } elseif {[file executable $fullpath]} { set attrex 0x81ff0080 ;# 0o100777 (-rwxrwxrwx) } else { set attrex 0x81b60020 ;# 0o100666 (-rw-rw-rw-) if {[file extension $fullpath] in {".tcl" ".txt" ".c"}} { set attr 1 ;# text } } if {[file isfile $fullpath]} { set size [file size $fullpath] if {!$seekable} {set flags [expr {$flags | (1 << 3)}]} } set channeloffset [tell $zipchan] ;#position in the channel - this may include prefixing exe/zip set local [binary format a4sssiiiiss PK\03\04 \ $version $flags $method $mtime $crc $csize $size \ [string length $utfpath] [string length $extra]] append local $utfpath $extra puts -nonewline $zipchan $local if {[file isfile $fullpath]} { # If the file is under 2MB then zip in one chunk, otherwize we use # streaming to avoid requiring excess memory. This helps to prevent # storing re-compressed data that may be larger than the source when # handling PNG or JPEG or nested ZIP files. if {$size < 0x00200000} { set fin [open $fullpath rb] set data [read $fin] set crc [zlib crc32 $data] set cdata [zlib deflate $data] if {[string length $cdata] < $size} { set method 8 set data $cdata } close $fin set csize [string length $data] puts -nonewline $zipchan $data } else { set method 8 set fin [open $fullpath rb] set zlib [zlib stream deflate] while {![eof $fin]} { set data [read $fin 4096] set crc [zlib crc32 $data $crc] $zlib put $data if {[string length [set zdata [$zlib get]]]} { incr csize [string length $zdata] puts -nonewline $zipchan $zdata } } close $fin $zlib finalize set zdata [$zlib get] incr csize [string length $zdata] puts -nonewline $zipchan $zdata $zlib close } if {$seekable} { # update the header if the output is seekable set local [binary format a4sssiiii PK\03\04 \ $version $flags $method $mtime $crc $csize $size] set current [tell $zipchan] seek $zipchan $channeloffset puts -nonewline $zipchan $local seek $zipchan $current } else { # Write a data descriptor record set ddesc [binary format a4iii PK\7\8 $crc $csize $size] puts -nonewline $zipchan $ddesc } } #PK\x01\x02 Cdentral directory file header #set v1 0x0317 ;#upper byte 03 -> UNIX lower byte 23 -> 2.3 set v1 0x0017 ;#upper byte 00 -> MS_DOS and OS/2 (FAT/VFAT/FAT32 file systems) set hdr [binary format a4ssssiiiisssssii PK\01\02 $v1 \ $version $flags $method $mtime $crc $csize $size \ [string length $utfpath] [string length $extra]\ [string length $utfcomment] 0 $attr $attrex [expr {$channeloffset - $zipdataoffset}]] ;#zipdataoffset may be zero - either because it's a pure zip, or file-based offsets desired. append hdr $utfpath $extra $utfcomment return $hdr } #### REVIEW!!! #JMN - review - this looks to be offset relative to start of file - (same as 2024 Tcl 'mkzip mkimg') # we want to enable (optionally) offsets relative to start of archive for exe/script-prefixed zips.on windows (editability with 7z,peazip) #### punk::args::define { @id -id ::punk::zip::mkzip @cmd -name punk::zip::mkzip\ -summary\ "Create a zip archive in 'filename'."\ -help\ "Create a zip archive in 'filename'. When the punkzip accelerator is available (see punk::zip::accelerator) and the call is one it serves identically - directory-scan mode, archive-relative offsets, no -runtime/-zipkit prefix, whole-tree globs, ascii comment - the archive is written by punkzip's build (rooted at the same base, same exclusion set) instead of the pure-Tcl writer, and verified against this module's own walk result; any accelerator failure falls back to pure Tcl silently. punk::zip::last_write_engine records which engine ran." @opts -offsettype -default "archive" -choices {archive file}\ -help\ "zip offsets stored relative to start of entire file or relative to start of zip-archive Only relevant if the created file has a script/runtime prefix." -return -default "pretty" -choices {pretty list none}\ -help\ "mkzip can return a list of the files and folders added to the archive the option -return pretty is the default and uses the punk::lib pdict/plist system to return a formatted list for the terminal " -zipkit -default 0 -type none\ -help\ "whether to add mounting script mutually exclusive with -runtime option currently vfs::zip based - todo - autodetect zipfs/vfs with pref for zipfs" -runtime -default ""\ -help\ "specify a prefix file e.g punk::zip::mkzip -runtime unzipsfx.exe -directory subdir -base subdir output.zip will create a self-extracting zip archive from the subdir/ folder. Expects runtime with no existing vfs attached (review)" -comment -default ""\ -help "An optional comment for the archive" -directory -default ""\ -help "Scan for contents within this folder or current directory if not provided." -base -default ""\ -help\ "The new zip archive will be rooted in this directory if provided it must be a parent of -directory or the same path as -directory" -exclude -default {CVS/* */CVS/* *~ ".#*" "*/.#*"} -- -type none -help\ "End of options marker" @values -min 1 -max -1 filename -type file -default ""\ -help "name of zipfile to create" globs -default {*} -multiple 1\ -help\ "list of glob patterns to match. Only directories with matching files will be included in the archive." } # zip::mkzip -- # # eg: zip my.zip -directory Subdir -runtime unzipsfx.exe *.txt # proc mkzip {args} { #todo - doctools - [arg ?globs...?] syntax? #*** !doctools #[call [fun mkzip]\ # [opt "[option -offsettype] [arg offsettype]"]\ # [opt "[option -return] [arg returntype]"]\ # [opt "[option -zipkit] [arg 0|1]"]\ # [opt "[option -runtime] [arg preamble_filename]"]\ # [opt "[option -comment] [arg zipfilecomment]"]\ # [opt "[option -directory] [arg dir_to_zip]"]\ # [opt "[option -base] [arg archive_root]"]\ # [opt "[option -exclude] [arg globlist]"]\ # [arg zipfilename]\ # [arg ?glob...?]] #[para] Create a zip archive in 'zipfilename' #[para] If a file already exists, an error will be raised. #[para] Call 'punk::zip::mkzip' with no arguments for usage display. set argd [punk::args::parse $args withid ::punk::zip::mkzip] set filename [dict get $argd values filename] if {$filename eq ""} { error "mkzip filename cannot be empty string" } if {[regexp {[?*]} $filename]} { #catch a likely error where filename is omitted and first glob pattern is misinterpreted as zipfile name error "mkzip filename should not contain glob characters ? *" } if {[file exists $filename]} { error "mkzip filename:$filename already exists" } dict for {k v} [dict get $argd opts] { switch -- $k { -comment { dict set argd opts $k [encoding convertto utf-8 $v] } -directory - -base { dict set argd opts $k [file normalize $v] } } } array set opts [dict get $argd opts] if {$opts(-directory) ne ""} { if {$opts(-base) ne ""} { #-base and -directory have been normalized already if {![Path_a_atorbelow_b $opts(-directory) $opts(-base)]} { error "punk::zip::mkzip -base $opts(-base) must be above or the same as -directory $opts(-directory)" } set base $opts(-base) set relpath [Path_strip_alreadynormalized_prefixdepth $opts(-directory) $opts(-base)] } else { set base $opts(-directory) set relpath "" } #will pick up intermediary folders as paths (ending with trailing slash) set paths [walk -exclude $opts(-exclude) -subpath $relpath -- $base {*}[dict get $argd values globs]] set norm_filename [file normalize $filename] set norm_dir [file normalize $opts(-directory)] ;#we only care if filename below -directory (which is where we start scanning) if {[Path_a_atorbelow_b $norm_filename $norm_dir]} { #check that we aren't adding the zipfile to itself #REVIEW - now that we open zipfile after scanning - this isn't really a concern! #keep for now in case we can add an -update or a -force facility (or in case we modify to add to zip as we scan for members?) #In the case of -force - we may want to delay replacement of original until scan is done? #try to avoid looping on all paths and performing (somewhat) expensive file normalizations on each #1st step is to check the patterns and see if our zipfile is already excluded - in which case we need not check the paths set self_globs_match 0 foreach g [dict get $argd values globs] { if {[string match $g [file tail $filename]]} { set self_globs_match 1 break } } if {$self_globs_match} { #still dangerous set self_excluded 0 foreach e $opts(-exclude) { if {[string match $e [file tail $filename]]} { set self_excluded 1 break } } if {!$self_excluded} { #still dangerous - likely to be in resultset - check each path #puts stderr "zip file $filename is below directory $opts(-directory)" set self_is_matched 0 set i 0 foreach p $paths { set norm_p [file normalize [file join $opts(-directory) $p]] if {[Path_a_at_b $norm_filename $norm_p]} { set self_is_matched 1 break } incr i } if {$self_is_matched} { puts stderr "WARNING - zipfile being created '$filename' was matched. Excluding this file. Relocate the zip, or use -exclude patterns to avoid this message" set paths [lremove $paths $i] } } } } } else { #NOTE that we don't add intermediate folders when creating an archive without using the -directory flag! #ie - only the exact *files* matching the glob are stored. set paths [list] set dir [pwd] if {$opts(-base) ne ""} { if {![Path_a_atorbelow_b $dir $opts(-base)]} { error "punk::zip::mkzip -base $opts(-base) must be above current directory" } set relpath [Path_strip_alreadynormalized_prefixdepth [file normalize $dir] [file normalize $opts(-base)]] } else { set relpath "" } set base $opts(-base) set matches [glob -nocomplain -type f -- {*}[dict get $argd values globs]] foreach m $matches { if {$m eq $filename} { #puts stderr "--> excluding $filename" continue } set isok 1 foreach e [concat $opts(-exclude) $filename] { if {[string match $e $m]} { set isok 0 break } } if {$isok} { lappend paths [file join $relpath $m] } } } if {![llength $paths]} { return "" } #G-165 write-path acceleration (mirrors the G-126 read-path seam in #unzip): hand whole-tree archive WRITING to the vendored punkzip binary #when this call is one it serves identically - directory-scan mode, #archive-relative offsets, no -runtime/-zipkit prefix (prefix #composition is the caller's concern, e.g assemble_zipcat_image), #default whole-tree globs and an ascii comment. punk::zip's own walk #above stays authoritative for the member set and the returned names #(identical to the floor's, order included); punkzip's build re-walks #with -b rooting at the same base and the same -x exclusion patterns #(Tcl string match by entry name, both engines). The produced archive #is read back and its member names compared against the walk result - #any accelerator failure, unreadable output or mismatch deletes the #file and falls back to the pure-Tcl writer silently. #punk::zip::last_write_engine records which engine ran (tcl|accelerated) #and punk::zip::last_write_note why the accelerator was skipped. set accelerated 0 variable last_write_engine variable last_write_note set last_write_engine tcl set last_write_note "" if {$opts(-directory) ne "" && $opts(-offsettype) eq "archive" && $opts(-runtime) eq "" && !$opts(-zipkit) && [llength [dict get $argd values globs]] == 1 && [lindex [dict get $argd values globs] 0] eq "*" && [string is ascii $opts(-comment)]} { set acc [accelerator] if {$acc eq ""} { set last_write_note "no accelerator binary resolved (see punk::zip::accelerator)" } else { set acccmd [list $acc build] if {$opts(-comment) ne ""} { lappend acccmd -c $opts(-comment) } lappend acccmd -b $base foreach pat $opts(-exclude) { lappend acccmd -x $pat } lappend acccmd $filename $opts(-directory) if {[catch {exec {*}$acccmd 2>@1} accout]} { file delete -force $filename set last_write_note "accelerator '$acc' failed - fell back to pure tcl: [string range $accout 0 300]" } elseif {[catch {set accnames [lsort [lmap m [members $filename] {dict get $m name}]]} verr]} { file delete -force $filename set last_write_note "accelerator output unreadable - fell back to pure tcl: $verr" } elseif {$accnames ne [lsort $paths]} { file delete -force $filename set last_write_note "accelerator member set mismatch - fell back to pure tcl" } else { set accelerated 1 } } } else { set last_write_note "call not write-accelerator eligible (non-directory scan, -offsettype file, -runtime/-zipkit prefix, selective globs, or non-ascii comment) - pure tcl" } if {$accelerated} { set last_write_engine accelerated puts stderr "punk::zip::mkzip: accelerated write via $acc (punkzip build -b $base - [llength $paths] members)" set members $paths } else { set zf [open $filename wb] if {$opts(-runtime) ne ""} { #todo - strip any existing vfs - option to merge contents.. only if zip attached? set rt [open $opts(-runtime) rb] fcopy $rt $zf close $rt } elseif {$opts(-zipkit)} { #TODO - update to zipfs ? #see modpod set zkd "#!/usr/bin/env tclkit\n\# This is a zip-based Tcl Module\n" append zkd "package require vfs::zip\n" append zkd "vfs::zip::Mount \[info script\] \[info script\]\n" append zkd "if {\[file exists \[file join \[info script\] main.tcl\]\]} {\n" append zkd " source \[file join \[info script\] main.tcl\]\n" append zkd "}\n" append zkd \x1A puts -nonewline $zf $zkd } #todo - subtract this from the endrec offset if {$opts(-offsettype) eq "archive"} { set dataStartOffset [tell $zf] ;#the overall file offset of the start of archive-data //JMN 2024 } else { set dataStartOffset 0 ;#offsets relative to file - the (old) zipfs mkzip way :/ } set count 0 set cd "" set members [list] foreach path $paths { #puts $path lappend members $path append cd [Addentry $zf $base $path $dataStartOffset] ;#path already includes relpath incr count } set cdoffset [tell $zf] set endrec [binary format a4ssssiis PK\05\06 0 0 \ $count $count [string length $cd] [expr {$cdoffset - $dataStartOffset}]\ [string length $opts(-comment)]] append endrec $opts(-comment) puts -nonewline $zf $cd puts -nonewline $zf $endrec close $zf } set result "" switch -exact -- $opts(-return) { list { set result $members } pretty { if {[info commands showlist] ne ""} { set result [plist -channel none members] } else { set result $members } } none { set result "" } } return $result } #*** !doctools #[list_end] [comment {--- end definitions namespace punk::zip ---}] } # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # Secondary API namespace # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ tcl::namespace::eval punk::zip::lib { tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase tcl::namespace::path [tcl::namespace::parent] #*** !doctools #[subsection {Namespace punk::zip::lib}] #[para] Secondary functions that are part of the API #[list_begin definitions] #proc utility1 {p1 args} { # #*** !doctools # #[call lib::[fun utility1] [arg p1] [opt {?option value...?}]] # #[para]Description of utility1 # return 1 #} #*** !doctools #[list_end] [comment {--- end definitions namespace punk::zip::lib ---}] } # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ # ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ ## Ready package provide punk::zip [tcl::namespace::eval punk::zip { variable pkg punk::zip variable version set version 999999.0a1.0 }] return #*** !doctools #[manpage_end]