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:
@@ -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" }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
lua/arti-pack/vendor/
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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*
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+13
@@ -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
@@ -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
|
||||
Vendored
+284
@@ -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
|
||||
Vendored
+226
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user