From bae3a517f7703dc9c72ea5cca2d9c25fb4d29a4d Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Thu, 10 Sep 2026 22:40:41 +1000 Subject: [PATCH 1/2] Vendor zzlib for pure-Lua DEFLATE Add zzlib (codeberg.org/zerkman/zzlib @ 9c39321, WTFPL) under lua/arti-pack/vendor. Namespace its submodule requires and re-export crc32; the inflate backends are unmodified. Exclude the vendor directory from luacheck and stylua. --- .luacheckrc | 2 + .styluaignore | 1 + lua/arti-pack/vendor/COPYING | 13 ++ lua/arti-pack/vendor/inflate-bit32.lua | 286 +++++++++++++++++++++++++ lua/arti-pack/vendor/inflate-bwo.lua | 284 ++++++++++++++++++++++++ lua/arti-pack/vendor/zzlib.lua | 226 +++++++++++++++++++ 6 files changed, 812 insertions(+) create mode 100644 .styluaignore create mode 100644 lua/arti-pack/vendor/COPYING create mode 100644 lua/arti-pack/vendor/inflate-bit32.lua create mode 100644 lua/arti-pack/vendor/inflate-bwo.lua create mode 100644 lua/arti-pack/vendor/zzlib.lua diff --git a/.luacheckrc b/.luacheckrc index 39af3f7..9682203 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,3 +1,5 @@ std = "lua51" globals = { "vim" } max_line_length = 120 +-- Third-party sources are kept byte-identical to upstream where possible. +exclude_files = { "lua/arti-pack/vendor" } diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..cb5c2ba --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +lua/arti-pack/vendor/ diff --git a/lua/arti-pack/vendor/COPYING b/lua/arti-pack/vendor/COPYING new file mode 100644 index 0000000..5c93f45 --- /dev/null +++ b/lua/arti-pack/vendor/COPYING @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/lua/arti-pack/vendor/inflate-bit32.lua b/lua/arti-pack/vendor/inflate-bit32.lua new file mode 100644 index 0000000..4ef7369 --- /dev/null +++ b/lua/arti-pack/vendor/inflate-bit32.lua @@ -0,0 +1,286 @@ +-- Vendored unmodified from https://codeberg.org/zerkman/zzlib at revision +-- 9c39321fb217e271377d3774f9d947530a9fb396. Licence: WTFPL (see COPYING). + +-- zzlib-bit32 - zlib decompression in Lua - version using bit/bit32 libraries + +-- Copyright (c) 2016-2026 Francois Galea +-- This program is free software. It comes without any warranty, to +-- the extent permitted by applicable law. You can redistribute it +-- and/or modify it under the terms of the Do What The Fuck You Want +-- To Public License, Version 2, as published by Sam Hocevar. See +-- the COPYING file or http://www.wtfpl.net/ for more details. + + +local inflate = {} + +local bit = bit32 or bit + +inflate.band = bit.band +inflate.rshift = bit.rshift + +function inflate.bitstream_init(file) + local bs = { + file = file, -- the open file handle + buf = nil, -- character buffer + len = nil, -- length of character buffer + pos = 1, -- position in char buffer, next to be read + b = 0, -- bit buffer + n = 0, -- number of bits in buffer + } + -- get rid of n first bits + function bs:flushb(n) + self.n = self.n - n + self.b = bit.rshift(self.b,n) + end + -- returns the next byte from the stream, excluding any half-read bytes + function bs:next_byte() + if self.pos > self.len then + self.buf = self.file:read(4096) + self.len = self.buf:len() + self.pos = 1 + end + local pos = self.pos + self.pos = pos + 1 + return self.buf:byte(pos) + end + -- peek a number of n bits from stream + function bs:peekb(n) + while self.n < n do + self.b = self.b + bit.lshift(self:next_byte(),self.n) + self.n = self.n + 8 + end + return bit.band(self.b,bit.lshift(1,n)-1) + end + -- get a number of n bits from stream + function bs:getb(n) + local ret = bs:peekb(n) + self.n = self.n - n + self.b = bit.rshift(self.b,n) + return ret + end + -- get next variable-size of maximum size=n element from stream, according to Huffman table + function bs:getv(hufftable,n) + local e = hufftable[bs:peekb(n)] + local len = bit.band(e,15) + local ret = bit.rshift(e,4) + self.n = self.n - len + self.b = bit.rshift(self.b,len) + return ret + end + function bs:close() + if self.file then + self.file:close() + end + end + if type(file) == "string" then + bs.file = nil + bs.buf = file + else + bs.buf = file:read(4096) + end + bs.len = bs.buf:len() + return bs +end + +local function hufftable_create(depths) + local nvalues = #depths + local nbits = 1 + local bl_count = {} + local next_code = {} + for i=1,nvalues do + local d = depths[i] + if d > nbits then + nbits = d + end + bl_count[d] = (bl_count[d] or 0) + 1 + end + local table = {} + local code = 0 + bl_count[0] = 0 + for i=1,nbits do + code = (code + (bl_count[i-1] or 0)) * 2 + next_code[i] = code + end + for i=1,nvalues do + local len = depths[i] or 0 + if len > 0 then + local e = (i-1)*16 + len + local code = next_code[len] + local rcode = 0 + for j=1,len do + rcode = rcode + bit.lshift(bit.band(1,bit.rshift(code,j-1)),len-j) + end + for j=0,2^nbits-1,2^len do + table[j+rcode] = e + end + next_code[len] = next_code[len] + 1 + end + end + return table,nbits +end + +local function block_loop(out,bs,nlit,ndist,littable,disttable) + local lit + repeat + lit = bs:getv(littable,nlit) + if lit < 256 then + table.insert(out,lit) + elseif lit > 256 then + local nbits = 0 + local size = 3 + local dist = 1 + if lit < 265 then + size = size + lit - 257 + elseif lit < 285 then + nbits = bit.rshift(lit-261,2) + size = size + bit.lshift(bit.band(lit-261,3)+4,nbits) + else + size = 258 + end + if nbits > 0 then + size = size + bs:getb(nbits) + end + local v = bs:getv(disttable,ndist) + if v < 4 then + dist = dist + v + else + nbits = bit.rshift(v-2,1) + dist = dist + bit.lshift(bit.band(v,1)+2,nbits) + dist = dist + bs:getb(nbits) + end + local p = #out-dist+1 + while size > 0 do + table.insert(out,out[p]) + p = p + 1 + size = size - 1 + end + end + until lit == 256 +end + +local function block_dynamic(out,bs) + local order = { 17, 18, 19, 1, 9, 8, 10, 7, 11, 6, 12, 5, 13, 4, 14, 3, 15, 2, 16 } + local hlit = 257 + bs:getb(5) + local hdist = 1 + bs:getb(5) + local hclen = 4 + bs:getb(4) + local depths = {} + for i=1,hclen do + local v = bs:getb(3) + depths[order[i]] = v + end + for i=hclen+1,19 do + depths[order[i]] = 0 + end + local lengthtable,nlen = hufftable_create(depths) + local i=1 + while i<=hlit+hdist do + local v = bs:getv(lengthtable,nlen) + if v < 16 then + depths[i] = v + i = i + 1 + elseif v < 19 then + local nbt = {2,3,7} + local nb = nbt[v-15] + local c = 0 + local n = 3 + bs:getb(nb) + if v == 16 then + c = depths[i-1] + elseif v == 18 then + n = n + 8 + end + for j=1,n do + depths[i] = c + i = i + 1 + end + else + error("wrong entry in depth table for literal/length alphabet: "..v); + end + end + local litdepths = {} for i=1,hlit do table.insert(litdepths,depths[i]) end + local littable,nlit = hufftable_create(litdepths) + local distdepths = {} for i=hlit+1,#depths do table.insert(distdepths,depths[i]) end + local disttable,ndist = hufftable_create(distdepths) + block_loop(out,bs,nlit,ndist,littable,disttable) +end + +local function block_static(out,bs) + local cnt = { 144, 112, 24, 8 } + local dpt = { 8, 9, 7, 8 } + local depths = {} + for i=1,4 do + local d = dpt[i] + for j=1,cnt[i] do + table.insert(depths,d) + end + end + local littable,nlit = hufftable_create(depths) + depths = {} + for i=1,32 do + depths[i] = 5 + end + local disttable,ndist = hufftable_create(depths) + block_loop(out,bs,nlit,ndist,littable,disttable) +end + +local function block_uncompressed(out,bs) + bs:flushb(bit.band(bs.n,7)) + local len = bs:getb(16) + if bs.n > 0 then + error("Unexpected.. should be zero remaining bits in buffer.") + end + local nlen = bs:getb(16) + if bit.bxor(len,nlen) ~= 65535 then + error("LEN and NLEN don't match") + end + for i=1,len do + table.insert(out,bs:next_byte()) + end +end + +function inflate.main(bs) + local last,type + local output = {} + repeat + local block + last = bs:getb(1) + type = bs:getb(2) + if type == 0 then + block_uncompressed(output,bs) + elseif type == 1 then + block_static(output,bs) + elseif type == 2 then + block_dynamic(output,bs) + else + error("unsupported block type") + end + until last == 1 + bs:flushb(bit.band(bs.n,7)) + return output +end + +local crc32_table +function inflate.crc32(s,crc) + if not crc32_table then + crc32_table = {} + for i=0,255 do + local r=i + for j=1,8 do + r = bit.bxor(bit.rshift(r,1),bit.band(0xedb88320,bit.bnot(bit.band(r,1)-1))) + end + crc32_table[i] = r + end + end + crc = bit.bnot(crc or 0) + for i=1,#s do + local c = s:byte(i) + crc = bit.bxor(crc32_table[bit.bxor(c,bit.band(crc,0xff))],bit.rshift(crc,8)) + end + crc = bit.bnot(crc) + if crc<0 then + -- in Lua < 5.2, sign extension was performed + crc = crc + 4294967296 + end + return crc +end + +return inflate diff --git a/lua/arti-pack/vendor/inflate-bwo.lua b/lua/arti-pack/vendor/inflate-bwo.lua new file mode 100644 index 0000000..9b5be4e --- /dev/null +++ b/lua/arti-pack/vendor/inflate-bwo.lua @@ -0,0 +1,284 @@ +-- Vendored unmodified from https://codeberg.org/zerkman/zzlib at revision +-- 9c39321fb217e271377d3774f9d947530a9fb396. Licence: WTFPL (see COPYING). + +-- zzlib - zlib decompression in Lua - version using Lua 5.3 bitwise operators + +-- Copyright (c) 2016-2026 Francois Galea +-- This program is free software. It comes without any warranty, to +-- the extent permitted by applicable law. You can redistribute it +-- and/or modify it under the terms of the Do What The Fuck You Want +-- To Public License, Version 2, as published by Sam Hocevar. See +-- the COPYING file or http://www.wtfpl.net/ for more details. + + +local inflate = {} + +function inflate.band(x,y) return x & y end +function inflate.rshift(x,y) return x >> y end + +function inflate.bitstream_init(file) + local bs = { + file = file, -- the open file handle + buf = nil, -- character buffer + len = nil, -- length of character buffer + pos = 1, -- position in char buffer, next to be read + b = 0, -- bit buffer + n = 0, -- number of bits in buffer + } + -- get rid of n first bits + function bs:flushb(n) + self.n = self.n - n + self.b = self.b >> n + end + -- returns the next byte from the stream, excluding any half-read bytes + function bs:next_byte() + if self.pos > self.len then + self.buf = self.file:read(4096) + self.len = self.buf:len() + self.pos = 1 + end + local pos = self.pos + self.pos = pos + 1 + return self.buf:byte(pos) + end + -- peek a number of n bits from stream + function bs:peekb(n) + while self.n < n do + self.b = self.b + (self:next_byte()<> n + return ret + end + -- get next variable-size of maximum size=n element from stream, according to Huffman table + function bs:getv(hufftable,n) + local e = hufftable[bs:peekb(n)] + local len = e & 15 + local ret = e >> 4 + self.n = self.n - len + self.b = self.b >> len + return ret + end + function bs:close() + if self.file then + self.file:close() + end + end + if type(file) == "string" then + bs.file = nil + bs.buf = file + else + bs.buf = file:read(4096) + end + bs.len = bs.buf:len() + return bs +end + +local function hufftable_create(depths) + local nvalues = #depths + local nbits = 1 + local bl_count = {} + local next_code = {} + for i=1,nvalues do + local d = depths[i] + if d > nbits then + nbits = d + end + bl_count[d] = (bl_count[d] or 0) + 1 + end + local table = {} + local code = 0 + bl_count[0] = 0 + for i=1,nbits do + code = (code + (bl_count[i-1] or 0)) * 2 + next_code[i] = code + end + for i=1,nvalues do + local len = depths[i] or 0 + if len > 0 then + local e = (i-1)*16 + len + local code = next_code[len] + local rcode = 0 + for j=1,len do + rcode = rcode + ((1&(code>>(j-1))) << (len-j)) + end + for j=0,2^nbits-1,2^len do + table[j+rcode] = e + end + next_code[len] = next_code[len] + 1 + end + end + return table,nbits +end + +local function block_loop(out,bs,nlit,ndist,littable,disttable) + local lit + repeat + lit = bs:getv(littable,nlit) + if lit < 256 then + table.insert(out,lit) + elseif lit > 256 then + local nbits = 0 + local size = 3 + local dist = 1 + if lit < 265 then + size = size + lit - 257 + elseif lit < 285 then + nbits = (lit-261) >> 2 + size = size + ((((lit-261)&3)+4) << nbits) + else + size = 258 + end + if nbits > 0 then + size = size + bs:getb(nbits) + end + local v = bs:getv(disttable,ndist) + if v < 4 then + dist = dist + v + else + nbits = (v-2) >> 1 + dist = dist + (((v&1)+2) << nbits) + dist = dist + bs:getb(nbits) + end + local p = #out-dist+1 + while size > 0 do + table.insert(out,out[p]) + p = p + 1 + size = size - 1 + end + end + until lit == 256 +end + +local function block_dynamic(out,bs) + local order = { 17, 18, 19, 1, 9, 8, 10, 7, 11, 6, 12, 5, 13, 4, 14, 3, 15, 2, 16 } + local hlit = 257 + bs:getb(5) + local hdist = 1 + bs:getb(5) + local hclen = 4 + bs:getb(4) + local depths = {} + for i=1,hclen do + local v = bs:getb(3) + depths[order[i]] = v + end + for i=hclen+1,19 do + depths[order[i]] = 0 + end + local lengthtable,nlen = hufftable_create(depths) + local i=1 + while i<=hlit+hdist do + local v = bs:getv(lengthtable,nlen) + if v < 16 then + depths[i] = v + i = i + 1 + elseif v < 19 then + local nbt = {2,3,7} + local nb = nbt[v-15] + local c = 0 + local n = 3 + bs:getb(nb) + if v == 16 then + c = depths[i-1] + elseif v == 18 then + n = n + 8 + end + for j=1,n do + depths[i] = c + i = i + 1 + end + else + error("wrong entry in depth table for literal/length alphabet: "..v); + end + end + local litdepths = {} for i=1,hlit do table.insert(litdepths,depths[i]) end + local littable,nlit = hufftable_create(litdepths) + local distdepths = {} for i=hlit+1,#depths do table.insert(distdepths,depths[i]) end + local disttable,ndist = hufftable_create(distdepths) + block_loop(out,bs,nlit,ndist,littable,disttable) +end + +local function block_static(out,bs) + local cnt = { 144, 112, 24, 8 } + local dpt = { 8, 9, 7, 8 } + local depths = {} + for i=1,4 do + local d = dpt[i] + for j=1,cnt[i] do + table.insert(depths,d) + end + end + local littable,nlit = hufftable_create(depths) + depths = {} + for i=1,32 do + depths[i] = 5 + end + local disttable,ndist = hufftable_create(depths) + block_loop(out,bs,nlit,ndist,littable,disttable) +end + +local function block_uncompressed(out,bs) + bs:flushb(bs.n&7) + local len = bs:getb(16) + if bs.n > 0 then + error("Unexpected.. should be zero remaining bits in buffer.") + end + local nlen = bs:getb(16) + if len~nlen ~= 65535 then + error("LEN and NLEN don't match") + end + for i=1,len do + table.insert(out,bs:next_byte()) + end +end + +function inflate.main(bs) + local last,type + local output = {} + repeat + local block + last = bs:getb(1) + type = bs:getb(2) + if type == 0 then + block_uncompressed(output,bs) + elseif type == 1 then + block_static(output,bs) + elseif type == 2 then + block_dynamic(output,bs) + else + error("unsupported block type") + end + until last == 1 + bs:flushb(bs.n&7) + return output +end + +local crc32_table +function inflate.crc32(s,crc) + if not crc32_table then + crc32_table = {} + for i=0,255 do + local r=i + for j=1,8 do + r = (r >> 1) ~ (0xedb88320 & ~((r & 1) - 1)) + end + crc32_table[i] = r + end + end + crc = (crc or 0) ~ 0xffffffff + for i=1,#s do + local c = s:byte(i) + crc = crc32_table[c ~ (crc & 0xff)] ~ (crc >> 8) + end + crc = (crc or 0) ~ 0xffffffff + if crc<0 then + -- in Lua < 5.2, sign extension was performed + crc = crc + 4294967296 + end + return crc +end + +return inflate diff --git a/lua/arti-pack/vendor/zzlib.lua b/lua/arti-pack/vendor/zzlib.lua new file mode 100644 index 0000000..2fed208 --- /dev/null +++ b/lua/arti-pack/vendor/zzlib.lua @@ -0,0 +1,226 @@ + +-- Vendored from https://codeberg.org/zerkman/zzlib at revision +-- 9c39321fb217e271377d3774f9d947530a9fb396. Licence: WTFPL (see COPYING). +-- Local changes: submodule require paths are namespaced under arti-pack.vendor, +-- and infl.crc32 is re-exported so callers can checksum stored zip entries. + +-- zzlib - zlib decompression in Lua - Implementation-independent code + +-- Copyright (c) 2016-2026 Francois Galea +-- This program is free software. It comes without any warranty, to +-- the extent permitted by applicable law. You can redistribute it +-- and/or modify it under the terms of the Do What The Fuck You Want +-- To Public License, Version 2, as published by Sam Hocevar. See +-- the COPYING file or http://www.wtfpl.net/ for more details. + + +local unpack = table.unpack or unpack +local infl + +local lua_version = tonumber(_VERSION:match("^Lua (.*)")) +if not lua_version or lua_version < 5.3 then + -- older version of Lua or Luajit being used - use bit/bit32-based implementation + infl = require("arti-pack.vendor.inflate-bit32") +else + -- From Lua 5.3, use implementation based on bitwise operators + infl = require("arti-pack.vendor.inflate-bwo") +end + +local zzlib = {} + +zzlib.crc32 = infl.crc32 + +local function arraytostr(array) + local tmp = {} + local size = #array + local pos = 1 + local imax = 1 + while size > 0 do + local bsize = size>=2048 and 2048 or size + local s = string.char(unpack(array,pos,pos+bsize-1)) + pos = pos + bsize + size = size - bsize + local i = 1 + while tmp[i] do + s = tmp[i]..s + tmp[i] = nil + i = i + 1 + end + if i > imax then + imax = i + end + tmp[i] = s + end + local str = "" + for i=1,imax do + if tmp[i] then + str = tmp[i]..str + end + end + return str +end + +local function inflate_gzip(bs) + local id1,id2,cm,flg = bs.buf:byte(1,4) + if id1 ~= 31 or id2 ~= 139 then + error("invalid gzip header") + end + if cm ~= 8 then + error("only deflate format is supported") + end + bs.pos=11 + if infl.band(flg,4) ~= 0 then + local xl1,xl2 = bs.buf.byte(bs.pos,bs.pos+1) + local xlen = xl2*256+xl1 + bs.pos = bs.pos+xlen+2 + end + if infl.band(flg,8) ~= 0 then + local pos = bs.buf:find("\0",bs.pos) + bs.pos = pos+1 + end + if infl.band(flg,16) ~= 0 then + local pos = bs.buf:find("\0",bs.pos) + bs.pos = pos+1 + end + if infl.band(flg,2) ~= 0 then + -- TODO: check header CRC16 + bs.pos = bs.pos+2 + end + local result = arraytostr(infl.main(bs)) + local crc = bs:getb(8)+256*(bs:getb(8)+256*(bs:getb(8)+256*bs:getb(8))) + bs:close() + if crc ~= infl.crc32(result) then + error("checksum verification failed") + end + return result +end + +-- compute Adler-32 checksum +local function adler32(s) + local s1 = 1 + local s2 = 0 + for i=1,#s do + local c = s:byte(i) + s1 = (s1+c)%65521 + s2 = (s2+s1)%65521 + end + return s2*65536+s1 +end + +local function inflate_zlib(bs) + local cmf = bs.buf:byte(1) + local flg = bs.buf:byte(2) + if (cmf*256+flg)%31 ~= 0 then + error("zlib header check bits are incorrect") + end + if infl.band(cmf,15) ~= 8 then + error("only deflate format is supported") + end + if infl.rshift(cmf,4) ~= 7 then + error("unsupported window size") + end + if infl.band(flg,32) ~= 0 then + error("preset dictionary not implemented") + end + bs.pos=3 + local result = arraytostr(infl.main(bs)) + local adler = ((bs:getb(8)*256+bs:getb(8))*256+bs:getb(8))*256+bs:getb(8) + bs:close() + if adler ~= adler32(result) then + error("checksum verification failed") + end + return result +end + +local function inflate_raw(buf,offset,crc) + local bs = infl.bitstream_init(buf) + bs.pos = offset + local result = arraytostr(infl.main(bs)) + if crc and crc ~= infl.crc32(result) then + error("checksum verification failed") + end + return result +end + +function zzlib.gunzipf(filename) + local file,err = io.open(filename,"rb") + if not file then + return nil,err + end + return inflate_gzip(infl.bitstream_init(file)) +end + +function zzlib.gunzip(str) + return inflate_gzip(infl.bitstream_init(str)) +end + +function zzlib.inflate(str) + return inflate_zlib(infl.bitstream_init(str)) +end + +local function int2le(str,pos) + local a,b = str:byte(pos,pos+1) + return b*256+a +end + +local function int4le(str,pos) + local a,b,c,d = str:byte(pos,pos+3) + return ((d*256+c)*256+b)*256+a +end + +local function nextfile(buf,p) + if int4le(buf,p) ~= 0x02014b50 then + -- end of central directory list + return + end + -- local flag = int2le(buf,p+8) + local packed = int2le(buf,p+10)~=0 + local crc = int4le(buf,p+16) + local namelen = int2le(buf,p+28) + local name = buf:sub(p+46,p+45+namelen) + local offset = int4le(buf,p+42)+1 + p = p+46+namelen+int2le(buf,p+30)+int2le(buf,p+32) + if int4le(buf,offset) ~= 0x04034b50 then + error("invalid local header signature") + end + local size = int4le(buf,offset+18) + local extlen = int2le(buf,offset+28) + offset = offset+30+namelen+extlen + return p,name,offset,size,packed,crc +end + +function zzlib.files(buf) + local p = #buf-21 + if int4le(buf,p) ~= 0x06054b50 then + -- not sure there is a reliable way to locate the end of central directory record + -- if it has a variable sized comment field + error(".ZIP file comments not supported") + end + local cdoffset = int4le(buf,p+16)+1 + return nextfile,buf,cdoffset +end + +function zzlib.unzip(buf,arg1,arg2) + if type(arg1) == "number" then + -- mode 1: unpack data from specified position in zip file + return inflate_raw(buf,arg1,arg2) + end + -- mode 2: search and unpack file from zip file + local filename = arg1 + for _,name,offset,size,packed,crc in zzlib.files(buf) do + if name == filename then + local result + if not packed then + -- no compression + result = buf:sub(offset,offset+size-1) + else + -- DEFLATE compression + result = inflate_raw(buf,offset,crc) + end + return result + end + end + error("file '"..filename.."' not found in ZIP archive") +end + +return zzlib -- 2.47.3 From 5809a843c898a7a1c17a8667faacff6e1994f7dc Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Thu, 10 Sep 2026 22:48:25 +1000 Subject: [PATCH 2/2] Extract archives in pure Lua Installing a plugin no longer needs unzip or tar on PATH, and entry paths are vetted in Lua instead of trusting an external tool. Add arti-pack.archive: a zip reader over zzlib's inflate and a tar reader over its gunzip. Reject absolute, parent-walking, backslashed and NUL-bearing entry names, and any entry that is not a regular file or directory. Verify every zip entry's CRC32 and size, and fail the install on mismatch. Carry only the executable bit from archive modes. Replace fs.extract_cmd with fs.extract and call it synchronously. Drop unzip from the test pipeline and the stated requirements. --- .woodpecker/test.yaml | 3 +- README.md | 6 +- doc/arti-pack.txt | 4 +- lua/arti-pack/archive.lua | 373 ++++++++++++++++++++++++++++++++++++ lua/arti-pack/fs.lua | 11 +- lua/arti-pack/installer.lua | 19 +- tests/archive_spec.lua | 340 ++++++++++++++++++++++++++++++++ tests/fs_spec.lua | 15 +- tests/init_spec.lua | 4 +- tests/installer_spec.lua | 4 +- tests/run.lua | 2 +- 11 files changed, 753 insertions(+), 28 deletions(-) create mode 100644 lua/arti-pack/archive.lua create mode 100644 tests/archive_spec.lua diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml index a2f7f95..0771312 100644 --- a/.woodpecker/test.yaml +++ b/.woodpecker/test.yaml @@ -5,7 +5,8 @@ steps: - name: test image: alpine:3.22 commands: - - apk add --no-cache neovim curl unzip tar zip make + # zip and tar build fixture archives; nothing unpacks them but arti-pack. + - apk add --no-cache neovim curl tar zip make - make test backend_options: kubernetes: diff --git a/README.md b/README.md index ac1a191..e76cc9a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ from. ## Requirements - Neovim 0.10+ -- `curl`, and `unzip` (or `tar` when using `ext = 'tar.gz'`) +- `curl` + +Archives are unpacked in pure Lua, so no `unzip` or `tar` binary is needed. +Entry paths are validated and every entry's CRC32 is checked before it lands on +disk. ## Install diff --git a/doc/arti-pack.txt b/doc/arti-pack.txt index 841097f..601bd73 100644 --- a/doc/arti-pack.txt +++ b/doc/arti-pack.txt @@ -21,7 +21,9 @@ works — a JFrog Artifactory remote GitHub repository, or any caching proxy with the same path shape. Nothing is cloned and the editor never reaches GitHub itself. -Requires curl, and unzip or tar. +Requires curl. Zip and tar.gz archives are unpacked in pure Lua, so no +unzip or tar binary is needed. Entry paths are validated and every +entry's CRC32 is checked before it lands on disk. ============================================================================== 2. SETUP *arti-pack-setup* diff --git a/lua/arti-pack/archive.lua b/lua/arti-pack/archive.lua new file mode 100644 index 0000000..4403602 --- /dev/null +++ b/lua/arti-pack/archive.lua @@ -0,0 +1,373 @@ +local uv = vim.uv or vim.loop +local zzlib = require('arti-pack.vendor.zzlib') + +local M = {} + +local EOCD_SIG = 0x06054b50 +local EOCD64_LOCATOR_SIG = 0x07064b50 +local CD_SIG = 0x02014b50 +local LOCAL_SIG = 0x04034b50 +local MAX_EOCD_SCAN = 66000 + +local STORED, DEFLATED = 0, 8 +-- Unix file-type bits sit above the low 12 permission bits of the mode. +local S_IFMT_UNIT, S_IFREG, S_IFDIR = 0x1000, 0x8000, 0x4000 +local MODE_EXEC, MODE_FILE, MODE_EXEC_FILE = 0x40, 420, 493 +local TAR_BLOCK = 512 + +local function u16(buf, pos) + local a, b = buf:byte(pos, pos + 1) + if not b then + return nil + end + return b * 256 + a +end + +local function u32(buf, pos) + local a, b, c, d = buf:byte(pos, pos + 3) + if not d then + return nil + end + return ((d * 256 + c) * 256 + b) * 256 + a +end + +-- Every write goes through here. Entry names come from the archive, so they are +-- attacker-controlled: anything that is absolute, walks upwards, or carries a +-- Windows drive or separator is refused rather than sanitised, so a hostile +-- archive fails the install instead of quietly landing files somewhere else. +function M.safe_path(name) + if type(name) ~= 'string' or name == '' then + return nil, 'empty entry name' + end + if name:find('\\') then + return nil, 'backslash in entry name' + end + if name:find('%z') then + return nil, 'NUL in entry name' + end + if name:sub(1, 1) == '/' then + return nil, 'absolute entry name' + end + if name:match('^%a:') then + return nil, 'drive-qualified entry name' + end + + local parts = {} + for part in name:gmatch('[^/]+') do + if part == '..' then + return nil, 'entry name escapes the destination' + elseif part ~= '.' then + table.insert(parts, part) + end + end + if #parts == 0 then + return nil, 'entry name resolves to nothing' + end + return table.concat(parts, '/') +end + +local function reject(name, why) + return nil, string.format('%s: %s', name, why) +end + +local function mkdirp(path) + vim.fn.mkdir(path, 'p') +end + +local function write_file(dest, rel, data, executable) + local path = dest .. '/' .. rel + local parent = path:match('^(.*)/[^/]+$') + if parent then + mkdirp(parent) + end + local fd, err = io.open(path, 'wb') + if not fd then + return nil, string.format('%s: %s', rel, tostring(err)) + end + fd:write(data) + fd:close() + -- Archive modes are advisory; only the executable bit is carried over so a + -- setuid or world-writable entry cannot land on disk with those bits set. + uv.fs_chmod(path, executable and MODE_EXEC_FILE or MODE_FILE) + return true +end + +local function unix_mode(version_made_by, external_attr) + if math.floor(version_made_by / 256) ~= 3 then + return nil + end + return math.floor(external_attr / 65536) % 65536 +end + +-- Scanned backwards because the record is followed by a variable-length comment. +-- The signature can occur inside that comment, so a candidate only counts if its +-- central directory offset actually lands on a central directory. +local function find_eocd(buf) + local limit = math.min(#buf, MAX_EOCD_SCAN) + for back = 22, limit do + local pos = #buf - back + 1 + if u32(buf, pos) == EOCD_SIG then + local count = u16(buf, pos + 10) + local cd = u32(buf, pos + 16) + 1 + if count == 0 or u32(buf, cd) == CD_SIG then + return pos + end + end + end + return nil +end + +local function zip_entries(buf) + if #buf < 22 then + return nil, 'archive is too small to be a zip' + end + local eocd = find_eocd(buf) + if not eocd then + return nil, 'no end-of-central-directory record found' + end + if eocd >= 20 and u32(buf, eocd - 20) == EOCD64_LOCATOR_SIG then + return nil, 'zip64 archives are not supported' + end + + local count = u16(buf, eocd + 10) + local cd = u32(buf, eocd + 16) + 1 + if count == 0xffff or cd == 0x100000000 then + return nil, 'zip64 archives are not supported' + end + + local entries, pos = {}, cd + for _ = 1, count do + if u32(buf, pos) ~= CD_SIG then + return nil, 'malformed central directory' + end + local namelen = u16(buf, pos + 28) + local entry = { + version_made_by = u16(buf, pos + 4), + flags = u16(buf, pos + 8), + method = u16(buf, pos + 10), + crc = u32(buf, pos + 16), + csize = u32(buf, pos + 20), + usize = u32(buf, pos + 24), + external_attr = u32(buf, pos + 38), + local_offset = u32(buf, pos + 42), + name = buf:sub(pos + 46, pos + 45 + namelen), + } + if #entry.name ~= namelen then + return nil, 'truncated central directory' + end + table.insert(entries, entry) + pos = pos + 46 + namelen + u16(buf, pos + 30) + u16(buf, pos + 32) + end + return entries +end + +local function zip_data(buf, entry) + local lho = entry.local_offset + 1 + if u32(buf, lho) ~= LOCAL_SIG then + return reject(entry.name, 'invalid local header signature') + end + local start = lho + 30 + u16(buf, lho + 26) + u16(buf, lho + 28) + + local data + if entry.method == STORED then + data = buf:sub(start, start + entry.csize - 1) + if #data ~= entry.csize then + return reject(entry.name, 'truncated stored entry') + end + if zzlib.crc32(data) ~= entry.crc then + return reject(entry.name, 'CRC mismatch') + end + else + -- zzlib verifies the CRC itself and raises on mismatch. + local ok, result = pcall(zzlib.unzip, buf, start, entry.crc) + if not ok then + return reject(entry.name, tostring(result):gsub('^.-:%d+: ', '')) + end + data = result + end + + if #data ~= entry.usize then + return reject(entry.name, 'decompressed size does not match the directory') + end + return data +end + +function M.unzip(buf, dest) + local entries, err = zip_entries(buf) + if not entries then + return nil, err + end + + for _, entry in ipairs(entries) do + if entry.flags % 2 == 1 then + return reject(entry.name, 'encrypted entries are not supported') + end + if entry.method ~= STORED and entry.method ~= DEFLATED then + return reject(entry.name, string.format('unsupported compression method %d', entry.method)) + end + + local mode = unix_mode(entry.version_made_by, entry.external_attr) + local kind = mode and mode - mode % S_IFMT_UNIT + -- A symlink entry is how an archive smuggles a later write out of the + -- destination, so anything that is not a plain file or directory is fatal. + if kind and kind ~= 0 and kind ~= S_IFREG and kind ~= S_IFDIR then + return reject(entry.name, 'entry is not a regular file or directory') + end + + local rel, perr = M.safe_path(entry.name) + if not rel then + return reject(entry.name, perr) + end + + if entry.name:sub(-1) == '/' then + mkdirp(dest .. '/' .. rel) + else + local data, derr = zip_data(buf, entry) + if not data then + return nil, derr + end + local ok, werr = write_file(dest, rel, data, mode ~= nil and mode % (MODE_EXEC * 2) >= MODE_EXEC) + if not ok then + return nil, werr + end + end + end + return true +end + +local function octal(field) + local digits = field:match('^[ ]*([0-7]*)') + if not digits or digits == '' then + return 0 + end + return tonumber(digits, 8) +end + +local function tar_checksum_ok(header) + local stored = octal(header:sub(149, 156)) + local signed, unsigned = 0, 0 + for i = 1, TAR_BLOCK do + local c = i >= 149 and i <= 156 and 32 or header:byte(i) + unsigned = unsigned + c + signed = signed + (c > 127 and c - 256 or c) + end + return stored == unsigned or stored == signed +end + +local function pax_records(block) + local out, pos = {}, 1 + while pos <= #block do + local len, rest = block:match('^(%d+) ()', pos) + if not len then + break + end + len = tonumber(len) + local record = block:sub(rest, pos + len - 2) + local key, value = record:match('^([^=]+)=(.*)$') + if key then + out[key] = value + end + pos = pos + len + end + return out +end + +function M.untar(buf, dest) + local pos, zeros = 1, 0 + local override + + while pos + TAR_BLOCK - 1 <= #buf do + local header = buf:sub(pos, pos + TAR_BLOCK - 1) + pos = pos + TAR_BLOCK + + if header:match('^%z*$') then + zeros = zeros + 1 + if zeros == 2 then + return true + end + else + zeros = 0 + if not tar_checksum_ok(header) then + return nil, 'tar header checksum mismatch' + end + + local size_field = header:sub(125, 136) + if size_field:byte(1) and size_field:byte(1) >= 128 then + return nil, 'base-256 tar sizes are not supported' + end + local size = octal(size_field) + local kind = header:sub(157, 157) + local blocks = math.ceil(size / TAR_BLOCK) * TAR_BLOCK + local body = buf:sub(pos, pos + size - 1) + if #body ~= size then + return nil, 'truncated tar entry' + end + pos = pos + blocks + + local name = header:sub(1, 100):gsub('%z.*$', '') + local prefix = header:sub(346, 500):gsub('%z.*$', '') + if prefix ~= '' then + name = prefix .. '/' .. name + end + if override then + name, override = override, nil + end + + if kind == 'g' then -- global pax header, carries no per-entry path + override = nil + elseif kind == 'x' then + override = pax_records(body).path + elseif kind == 'L' then + override = body:gsub('%z.*$', '') + elseif kind == 'K' then + override = nil -- long link name; the link entry itself is rejected below + elseif kind == '0' or kind == '\0' or kind == '5' then + local rel, perr = M.safe_path(name) + if not rel then + return reject(name, perr) + end + if kind == '5' then + mkdirp(dest .. '/' .. rel) + else + local mode = octal(header:sub(101, 108)) + local ok, werr = write_file(dest, rel, body, mode % (MODE_EXEC * 2) >= MODE_EXEC) + if not ok then + return nil, werr + end + end + else + -- Symlinks, hardlinks and device nodes have no business in a plugin + -- archive and are the usual vehicle for escaping the destination. + return reject(name, string.format('unsupported tar entry type %q', kind)) + end + end + end + + return nil, 'tar archive ended without an end-of-archive marker' +end + +function M.extract(path, dest, ext) + local fd, oerr = io.open(path, 'rb') + if not fd then + return nil, tostring(oerr) + end + local buf = fd:read('*a') + fd:close() + if not buf or buf == '' then + return nil, 'archive is empty' + end + + mkdirp(dest) + + if ext == 'zip' then + return M.unzip(buf, dest) + end + + local ok, tar = pcall(zzlib.gunzip, buf) + if not ok then + return nil, 'gunzip failed: ' .. tostring(tar):gsub('^.-:%d+: ', '') + end + return M.untar(tar, dest) +end + +return M diff --git a/lua/arti-pack/fs.lua b/lua/arti-pack/fs.lua index f3834cd..ef94cc6 100644 --- a/lua/arti-pack/fs.lua +++ b/lua/arti-pack/fs.lua @@ -74,11 +74,14 @@ function M.verify_archive(path, ext) return true end -function M.extract_cmd(archive, dest, ext) - if ext == 'zip' then - return { 'unzip', '-q', '-o', archive, '-d', dest } +-- Extraction is pure Lua: no unzip or tar on PATH, and entry paths are vetted +-- here rather than trusted to an external tool. +function M.extract(archive, dest, ext) + local ok, res, err = pcall(require('arti-pack.archive').extract, archive, dest, ext) + if not ok then + return nil, tostring(res) end - return { 'tar', '-xzf', archive, '-C', dest } + return res, err end function M.rename(from, to) diff --git a/lua/arti-pack/installer.lua b/lua/arti-pack/installer.lua index 26b4c78..c9a285c 100644 --- a/lua/arti-pack/installer.lua +++ b/lua/arti-pack/installer.lua @@ -58,17 +58,14 @@ local function extract(ctx, spec, archive, done) fs.rm_rf(stage) fs.mkdirp(stage) - vim.system(fs.extract_cmd(archive, stage, spec.ext), { text = true }, function(out) - vim.schedule(function() - if out.code ~= 0 then - fs.rm_rf(stage) - -- A corrupt cache entry would fail forever; drop it so the next sync refetches. - fs.rm_rf(archive) - return done(result(spec, 'failed', shell_error('extract', out))) - end - place(ctx, spec, stage, done) - end) - end) + local ok, err = fs.extract(archive, stage, spec.ext) + if not ok then + fs.rm_rf(stage) + -- A corrupt cache entry would fail forever; drop it so the next sync refetches. + fs.rm_rf(archive) + return done(result(spec, 'failed', 'extract failed: ' .. tostring(err))) + end + place(ctx, spec, stage, done) end local function download(ctx, spec, archive, done) diff --git a/tests/archive_spec.lua b/tests/archive_spec.lua new file mode 100644 index 0000000..221c0e2 --- /dev/null +++ b/tests/archive_spec.lua @@ -0,0 +1,340 @@ +local t = require('tests.harness') +local archive = require('arti-pack.archive') +local zzlib = require('arti-pack.vendor.zzlib') + +local function tmpdir() + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, 'p') + return dir +end + +local function le(n, bytes) + local out = {} + for _ = 1, bytes do + table.insert(out, string.char(n % 256)) + n = math.floor(n / 256) + end + return table.concat(out) +end + +-- Fixtures are built here rather than shelled out to zip/tar so the hostile +-- entry names below are exactly the bytes under test. +local function zipf(entries) + local locals, central, offset = {}, {}, 0 + for _, e in ipairs(entries) do + local data = e.data or '' + local crc = zzlib.crc32(data) + local head = 'PK\3\4' + .. le(20, 2) + .. le(e.flags or 0, 2) + .. le(0, 2) + .. le(0, 4) + .. le(e.crc or crc, 4) + .. le(#data, 4) + .. le(#data, 4) + .. le(#e.name, 2) + .. le(0, 2) + .. e.name + table.insert(locals, head .. data) + table.insert( + central, + 'PK\1\2' + .. le(e.made_by or (3 * 256 + 20), 2) + .. le(20, 2) + .. le(e.flags or 0, 2) + .. le(0, 2) + .. le(0, 4) + .. le(e.crc or crc, 4) + .. le(#data, 4) + .. le(#data, 4) + .. le(#e.name, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 2) + .. le(0, 2) + .. le(e.attr or 0, 4) + .. le(offset, 4) + .. e.name + ) + offset = offset + #head + #data + end + local body = table.concat(locals) + local cd = table.concat(central) + local comment = entries.comment or '' + local eocd = 'PK\5\6' + .. le(0, 2) + .. le(0, 2) + .. le(#entries, 2) + .. le(#entries, 2) + .. le(#cd, 4) + .. le(#body, 4) + .. le(#comment, 2) + .. comment + return body .. cd .. eocd +end + +-- A gzip member whose DEFLATE payload is all stored blocks: valid, and buildable +-- without a compressor. +local function gz(data) + local blocks, pos = {}, 1 + repeat + local chunk = data:sub(pos, pos + 65534) + pos = pos + #chunk + local final = pos > #data and 1 or 0 + table.insert(blocks, string.char(final) .. le(#chunk, 2) .. le(65535 - #chunk, 2) .. chunk) + until pos > #data + return '\31\139\8\0\0\0\0\0\0\3' .. table.concat(blocks) .. le(zzlib.crc32(data), 4) .. le(#data, 4) +end + +local function tar_header(name, size, kind, mode) + local h = name .. string.rep('\0', 100 - #name) + h = h .. string.format('%07o\0', mode or 420) + h = h .. string.format('%07o\0', 0) .. string.format('%07o\0', 0) + h = h .. string.format('%011o\0', size) .. string.format('%011o\0', 0) + h = h .. string.rep(' ', 8) .. kind .. string.rep('\0', 100) + h = h .. 'ustar\0' .. '00' .. string.rep('\0', 32) .. string.rep('\0', 32) + h = h .. string.rep('\0', 8) .. string.rep('\0', 8) .. string.rep('\0', 155) + h = h .. string.rep('\0', 512 - #h) + local sum = 0 + for i = 1, 512 do + sum = sum + h:byte(i) + end + return h:sub(1, 148) .. string.format('%06o\0 ', sum) .. h:sub(157) +end + +local function tarf(entries) + local out = {} + for _, e in ipairs(entries) do + local data = e.data or '' + table.insert(out, tar_header(e.name, #data, e.kind or '0', e.mode)) + if #data > 0 then + table.insert(out, data .. string.rep('\0', (512 - #data % 512) % 512)) + end + end + table.insert(out, string.rep('\0', 1024)) + return table.concat(out) +end + +local function write(path, data) + local fd = assert(io.open(path, 'wb')) + fd:write(data) + fd:close() + return path +end + +local function read(path) + local fd = io.open(path, 'rb') + if not fd then + return nil + end + local data = fd:read('*a') + fd:close() + return data +end + +local function extract(bytes, ext) + local dir = tmpdir() + local src = write(dir .. '/archive', bytes) + local dest = dir .. '/out' + local ok, err = archive.extract(src, dest, ext or 'zip') + return ok, err, dest +end + +t.describe('archive.safe_path', function() + t.it('accepts an ordinary nested path', function() + t.eq('plugin/lua/init.lua', archive.safe_path('plugin/lua/init.lua')) + end) + + t.it('collapses redundant separators and dot components', function() + t.eq('a/b.lua', archive.safe_path('./a//./b.lua')) + end) + + t.it('rejects a parent-directory component', function() + local ok, err = archive.safe_path('a/../../etc/passwd') + t.falsy(ok) + t.matches('escapes', err) + end) + + t.it('rejects a leading parent-directory component', function() + t.falsy(archive.safe_path('../evil')) + end) + + t.it('rejects an absolute path', function() + local ok, err = archive.safe_path('/etc/passwd') + t.falsy(ok) + t.matches('absolute', err) + end) + + t.it('rejects a windows drive path', function() + local ok, err = archive.safe_path('C:/windows/system32') + t.falsy(ok) + t.matches('drive', err) + end) + + t.it('rejects a backslash separator', function() + t.falsy(archive.safe_path('a\\..\\..\\evil')) + end) + + t.it('rejects an embedded NUL', function() + t.falsy(archive.safe_path('a\0b')) + end) + + t.it('rejects a name that resolves to nothing', function() + t.falsy(archive.safe_path('./')) + end) +end) + +t.describe('archive.extract zip', function() + t.it('writes nested files, empty files and directories', function() + local ok, err, dest = extract(zipf({ + { name = 'root/' }, + { name = 'root/init.lua', data = 'return {}' }, + { name = 'root/empty.txt', data = '' }, + { name = 'root/lua/deep/mod.lua', data = 'deep' }, + { name = 'root/noext', data = 'x' }, + { name = 'root/with space.txt', data = 'y' }, + { name = 'root/ünïcødé.txt', data = 'z' }, + })) + t.truthy(ok, err) + t.eq('return {}', read(dest .. '/root/init.lua')) + t.eq('', read(dest .. '/root/empty.txt')) + t.eq('deep', read(dest .. '/root/lua/deep/mod.lua')) + t.eq('x', read(dest .. '/root/noext')) + t.eq('y', read(dest .. '/root/with space.txt')) + t.eq('z', read(dest .. '/root/ünïcødé.txt')) + end) + + t.it('reads an archive that carries a trailing comment', function() + local bytes = zipf({ { name = 'root/a.lua', data = 'hi' }, comment = 'built by CI PK\5\6 not really' }) + local ok, err, dest = extract(bytes) + t.truthy(ok, err) + t.eq('hi', read(dest .. '/root/a.lua')) + end) + + t.it('carries the executable bit but drops setuid', function() + local uv = vim.uv or vim.loop + local ok, err, dest = extract(zipf({ + { name = 'root/run.sh', data = '#!/bin/sh\n', attr = (0x8000 + 0x800 + 0x1ed) * 65536 }, + })) + t.truthy(ok, err) + t.eq(0x1ed, uv.fs_stat(dest .. '/root/run.sh').mode % 0x1000) + end) + + t.it('refuses an entry that walks out of the destination', function() + local ok, err = extract(zipf({ { name = '../evil.lua', data = 'pwned' } })) + t.falsy(ok) + t.matches('escapes', err) + end) + + t.it('refuses an absolute entry', function() + local ok, err = extract(zipf({ { name = '/tmp/evil.lua', data = 'pwned' } })) + t.falsy(ok) + t.matches('absolute', err) + end) + + t.it('refuses a symlink entry', function() + local ok, err = extract(zipf({ { name = 'root/link', data = '/etc', attr = (0xa000 + 0x1ff) * 65536 } })) + t.falsy(ok) + t.matches('not a regular file', err) + end) + + t.it('refuses an encrypted entry', function() + local ok, err = extract(zipf({ { name = 'root/a.lua', data = 'x', flags = 1 } })) + t.falsy(ok) + t.matches('encrypted', err) + end) + + t.it('fails loudly on a CRC mismatch', function() + local ok, err = extract(zipf({ { name = 'root/a.lua', data = 'hello', crc = 12345 } })) + t.falsy(ok) + t.matches('CRC mismatch', err) + end) + + t.it('fails on a truncated archive', function() + local bytes = zipf({ { name = 'root/a.lua', data = 'hello' } }) + local ok, err = extract(bytes:sub(1, #bytes - 40)) + t.falsy(ok) + t.matches('end%-of%-central%-directory', err) + end) + + t.it('fails on random bytes with a zip magic', function() + local ok = extract('PK\3\4' .. string.rep('\200', 400)) + t.falsy(ok) + end) +end) + +t.describe('archive.extract tar.gz', function() + t.it('writes files and directories', function() + local ok, err, dest = extract( + gz(tarf({ + { name = 'root/', kind = '5' }, + { name = 'root/init.lua', data = 'return {}' }, + { name = 'root/empty.txt', data = '' }, + { name = 'root/lua/deep/mod.lua', data = 'deep' }, + })), + 'tar.gz' + ) + t.truthy(ok, err) + t.eq('return {}', read(dest .. '/root/init.lua')) + t.eq('', read(dest .. '/root/empty.txt')) + t.eq('deep', read(dest .. '/root/lua/deep/mod.lua')) + end) + + t.it('skips a pax global header', function() + local ok, err, dest = extract( + gz(tarf({ + { name = 'pax_global_header', kind = 'g', data = '20 comment=deadbeef\n' }, + { name = 'root/init.lua', data = 'ok' }, + })), + 'tar.gz' + ) + t.truthy(ok, err) + t.eq('ok', read(dest .. '/root/init.lua')) + end) + + t.it('honours a pax path override', function() + local path = 'root/a-very-long-name.lua' + -- A pax record's length field counts itself, so it has to reach a fixed point. + local len = #path + 7 + while #tostring(len) + #path + 7 ~= len do + len = #tostring(len) + #path + 7 + end + local record = string.format('%d path=%s\n', len, path) + local ok, err, dest = extract( + gz(tarf({ + { name = 'short', kind = 'x', data = record }, + { name = 'short', data = 'long' }, + })), + 'tar.gz' + ) + t.truthy(ok, err) + t.eq('long', read(dest .. '/' .. path)) + end) + + t.it('refuses an entry that walks out of the destination', function() + local ok, err = extract(gz(tarf({ { name = '../evil.lua', data = 'pwned' } })), 'tar.gz') + t.falsy(ok) + t.matches('escapes', err) + end) + + t.it('refuses a symlink entry', function() + local ok, err = extract(gz(tarf({ { name = 'root/link', kind = '2' } })), 'tar.gz') + t.falsy(ok) + t.matches('unsupported tar entry type', err) + end) + + t.it('fails loudly on a corrupt gzip payload', function() + local bytes = gz(tarf({ { name = 'root/a.lua', data = 'hello' } })) + local flipped = bytes:sub(1, 40) .. string.char((bytes:byte(41) + 1) % 256) .. bytes:sub(42) + local ok, err = extract(flipped, 'tar.gz') + t.falsy(ok) + t.matches('gunzip failed', err) + end) + + t.it('fails on a corrupt tar header', function() + local tar = tarf({ { name = 'root/a.lua', data = 'hello' } }) + local ok, err = extract(gz('X' .. tar:sub(2)), 'tar.gz') + t.falsy(ok) + t.matches('checksum mismatch', err) + end) +end) diff --git a/tests/fs_spec.lua b/tests/fs_spec.lua index 98ad6d9..affb8cc 100644 --- a/tests/fs_spec.lua +++ b/tests/fs_spec.lua @@ -74,13 +74,18 @@ t.describe('fs.verify_archive', function() end) end) -t.describe('fs.extract_cmd', function() - t.it('uses unzip into the destination for zips', function() - t.eq({ 'unzip', '-q', '-o', '/a.zip', '-d', '/dest' }, fs.extract_cmd('/a.zip', '/dest', 'zip')) +t.describe('fs.extract', function() + t.it('reports a missing archive instead of raising', function() + local ok, err = fs.extract(tmpdir() .. '/absent.zip', tmpdir(), 'zip') + t.falsy(ok) + t.truthy(err) end) - t.it('uses tar for gzipped tarballs', function() - t.eq({ 'tar', '-xzf', '/a.tar.gz', '-C', '/dest' }, fs.extract_cmd('/a.tar.gz', '/dest', 'tar.gz')) + t.it('reports an empty archive', function() + local path = write(tmpdir() .. '/a.zip', '') + local ok, err = fs.extract(path, tmpdir() .. '/dest', 'zip') + t.falsy(ok) + t.matches('empty', err) end) end) diff --git a/tests/init_spec.lua b/tests/init_spec.lua index dfb4394..4801154 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -251,8 +251,8 @@ t.describe('arti-pack without a registry', function() end) t.describe('arti-pack.sync', function() - if not have('zip') or not have('unzip') then - t.it('SKIPPED: zip/unzip unavailable', function() end) + if not have('zip') then + t.it('SKIPPED: zip unavailable to build fixtures', function() end) return end diff --git a/tests/installer_spec.lua b/tests/installer_spec.lua index b1ec306..4582207 100644 --- a/tests/installer_spec.lua +++ b/tests/installer_spec.lua @@ -75,8 +75,8 @@ local function spec_for(source, tag, archive, extra) end t.describe('installer.run', function() - if not have('zip') or not have('unzip') then - t.it('SKIPPED: zip/unzip unavailable', function() end) + if not have('zip') or not have('tar') then + t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end) return end diff --git a/tests/run.lua b/tests/run.lua index ff6d6f5..49d18f2 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -8,7 +8,7 @@ package.path = table.concat({ local t = require('tests.harness') -for _, name in ipairs({ 'spec_spec', 'fs_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do +for _, name in ipairs({ 'spec_spec', 'fs_spec', 'archive_spec', 'lock_spec', 'installer_spec', 'init_spec' }) do require('tests.' .. name) end -- 2.47.3