Merge pull request 'Extract archives in pure Lua with a vendored zzlib' (#4) from benvin/vendor-zzlib into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-10 23:18:12 +10:00
17 changed files with 1565 additions and 28 deletions
+2
View File
@@ -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" }
+1
View File
@@ -0,0 +1 @@
lua/arti-pack/vendor/
+2 -1
View File
@@ -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:
+5 -1
View File
@@ -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
+3 -1
View File
@@ -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*
+373
View File
@@ -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
+7 -4
View File
@@ -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)
+8 -11
View File
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
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.
+286
View File
@@ -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 <fgalea at free.fr>
-- 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
+284
View File
@@ -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 <fgalea at free.fr>
-- 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()<<self.n)
self.n = self.n + 8
end
return self.b & ((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 = 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 = 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
+226
View File
@@ -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 <fgalea at free.fr>
-- 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
+340
View File
@@ -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)
+10 -5
View File
@@ -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)
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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