style: apply stylua formatter

This commit is contained in:
Erica Marigold 2024-12-30 11:13:57 +00:00
parent 792682e46d
commit c2e638cbec
Signed by: DevComp
GPG key ID: 429EF1C337871656
6 changed files with 911 additions and 774 deletions

View file

@ -1,35 +1,36 @@
local fs = require("@lune/fs") local fs = require("@lune/fs")
local zip = require("../lib") local zip = require("../lib")
local file = fs.readFile("test.zip") local file = fs.readFile("test.zip")
local reader = zip.load(buffer.fromstring(file)) local reader = zip.load(buffer.fromstring(file))
print("Directory structure:") print("Directory structure:")
reader:walk(function(entry, depth) reader:walk(function(entry, depth)
local prefix = string.rep(" ", depth) local prefix = string.rep(" ", depth)
local suffix = if not entry.isDirectory then string.format(" (%d bytes)", entry.size) else "" local suffix = if not entry.isDirectory then string.format(" (%d bytes)", entry.size) else ""
print(prefix .. entry.name .. suffix) print(prefix .. entry.name .. suffix)
end) end)
print("\nContents of `/`:") print("\nContents of `/`:")
local assets = reader:listDirectory("/") local assets = reader:listDirectory("/")
for _, entry in assets do for _, entry in assets do
print(entry.name, if entry.isDirectory then "DIR" else entry.size) print(entry.name, if entry.isDirectory then "DIR" else entry.size)
if not entry.isDirectory then if not entry.isDirectory then
local extracted = reader:extract(entry, { isString = true }) local extracted = reader:extract(entry, { isString = true })
print("Content:", extracted) print("Content:", extracted)
end end
end end
-- Get archive statistics -- Get archive statistics
local stats = reader:getStats() local stats = reader:getStats()
print(string.format([[ print(string.format(
[[
Archive stats:
Files: %d Archive stats:
Directories: %d Files: %d
Total size: %d bytes]], Directories: %d
stats.fileCount, Total size: %d bytes]],
stats.dirCount, stats.fileCount,
stats.totalSize stats.dirCount,
)) stats.totalSize
))

View file

@ -1,30 +1,30 @@
local CRC32_TABLE = table.create(256) local CRC32_TABLE = table.create(256)
-- Initialize the lookup table and lock it in place -- Initialize the lookup table and lock it in place
for i = 0, 255 do for i = 0, 255 do
local crc = i local crc = i
for _ = 1, 8 do for _ = 1, 8 do
if bit32.band(crc, 1) == 1 then if bit32.band(crc, 1) == 1 then
crc = bit32.bxor(bit32.rshift(crc, 1), 0xEDB88320) crc = bit32.bxor(bit32.rshift(crc, 1), 0xEDB88320)
else else
crc = bit32.rshift(crc, 1) crc = bit32.rshift(crc, 1)
end end
end end
CRC32_TABLE[i] = crc CRC32_TABLE[i] = crc
end end
table.freeze(CRC32_TABLE) table.freeze(CRC32_TABLE)
local function crc32(buf: buffer): number local function crc32(buf: buffer): number
local crc = 0xFFFFFFFF local crc = 0xFFFFFFFF
for i = 0, buffer.len(buf) - 1 do for i = 0, buffer.len(buf) - 1 do
local byte = buffer.readu8(buf, i) local byte = buffer.readu8(buf, i)
local index = bit32.band(bit32.bxor(crc, byte), 0xFF) local index = bit32.band(bit32.bxor(crc, byte), 0xFF)
crc = bit32.bxor(bit32.rshift(crc, 8), CRC32_TABLE[index]) crc = bit32.bxor(bit32.rshift(crc, 8), CRC32_TABLE[index])
end end
return bit32.bxor(crc, 0xFFFFFFFF) return bit32.bxor(crc, 0xFFFFFFFF)
end end
return crc32 return crc32

View file

@ -1,61 +1,61 @@
local Tree = {} local Tree = {}
export type Tree = typeof(setmetatable({} :: TreeInner, { __index = Tree })) export type Tree = typeof(setmetatable({} :: TreeInner, { __index = Tree }))
type TreeInner = { type TreeInner = {
table: { number }, -- len: 16 (🏳️‍⚧️❓) table: { number }, -- len: 16 (🏳️‍⚧️❓)
trans: { number }, -- len: 288 trans: { number }, -- len: 288
} }
function Tree.new(): Tree function Tree.new(): Tree
return setmetatable( return setmetatable(
{ {
table = table.create(16, 0), table = table.create(16, 0),
trans = table.create(288, 0), trans = table.create(288, 0),
} :: TreeInner, } :: TreeInner,
{ __index = Tree } { __index = Tree }
) )
end end
local Data = {} local Data = {}
export type Data = typeof(setmetatable({} :: DataInner, { __index = Data })) export type Data = typeof(setmetatable({} :: DataInner, { __index = Data }))
export type DataInner = { export type DataInner = {
source: buffer, source: buffer,
sourceIndex: number, sourceIndex: number,
tag: number, tag: number,
bitcount: number, bitcount: number,
dest: buffer, dest: buffer,
destLen: number, destLen: number,
ltree: Tree, ltree: Tree,
dtree: Tree, dtree: Tree,
} }
function Data.new(source: buffer, dest: buffer): Data function Data.new(source: buffer, dest: buffer): Data
return setmetatable( return setmetatable(
{ {
source = source, source = source,
sourceIndex = 0, sourceIndex = 0,
tag = 0, tag = 0,
bitcount = 0, bitcount = 0,
dest = dest, dest = dest,
destLen = 0, destLen = 0,
ltree = Tree.new(), ltree = Tree.new(),
dtree = Tree.new(), dtree = Tree.new(),
} :: DataInner, } :: DataInner,
{ __index = Data } { __index = Data }
) )
end end
-- Static structures -- Static structures
local staticLengthTree = Tree.new() local staticLengthTree = Tree.new()
local staticDistTree = Tree.new() local staticDistTree = Tree.new()
-- Extra bits and base tables -- Extra bits and base tables
local lengthBits = table.create(30, 0) local lengthBits = table.create(30, 0)
local lengthBase = table.create(30, 0) local lengthBase = table.create(30, 0)
local distBits = table.create(30, 0) local distBits = table.create(30, 0)
local distBase = table.create(30, 0) local distBase = table.create(30, 0)
-- Special ordering of code length codes -- Special ordering of code length codes
-- stylua: ignore -- stylua: ignore
@ -64,282 +64,282 @@ local clcIndex = {
10, 5, 11, 4, 12, 3, 13, 2, 10, 5, 11, 4, 12, 3, 13, 2,
14, 1, 15 14, 1, 15
} }
local codeTree = Tree.new() local codeTree = Tree.new()
local lengths = table.create(288 + 32, 0) local lengths = table.create(288 + 32, 0)
local function buildBitsBase(bits: { number }, base: { number }, delta: number, first: number) local function buildBitsBase(bits: { number }, base: { number }, delta: number, first: number)
local sum = first local sum = first
-- build bits table -- build bits table
for i = 0, delta - 1 do for i = 0, delta - 1 do
bits[i] = 0 bits[i] = 0
end end
for i = 0, 29 - delta do for i = 0, 29 - delta do
bits[i + delta] = math.floor(i / delta) bits[i + delta] = math.floor(i / delta)
end end
-- build base table -- build base table
for i = 0, 29 do for i = 0, 29 do
base[i] = sum base[i] = sum
sum = sum + bit32.lshift(1, bits[i]) sum = sum + bit32.lshift(1, bits[i])
end end
end end
local function buildFixedTrees(lengthTree: Tree, distTree: Tree) local function buildFixedTrees(lengthTree: Tree, distTree: Tree)
-- build fixed length tree -- build fixed length tree
for i = 0, 6 do for i = 0, 6 do
lengthTree.table[i] = 0 lengthTree.table[i] = 0
end end
lengthTree.table[7] = 24 lengthTree.table[7] = 24
lengthTree.table[8] = 152 lengthTree.table[8] = 152
lengthTree.table[9] = 112 lengthTree.table[9] = 112
for i = 0, 23 do for i = 0, 23 do
lengthTree.trans[i] = 256 + i lengthTree.trans[i] = 256 + i
end end
for i = 0, 143 do for i = 0, 143 do
lengthTree.trans[24 + i] = i lengthTree.trans[24 + i] = i
end end
for i = 0, 7 do for i = 0, 7 do
lengthTree.trans[24 + 144 + i] = 280 + i lengthTree.trans[24 + 144 + i] = 280 + i
end end
for i = 0, 111 do for i = 0, 111 do
lengthTree.trans[24 + 144 + 8 + i] = 144 + i lengthTree.trans[24 + 144 + 8 + i] = 144 + i
end end
-- build fixed distance tree -- build fixed distance tree
for i = 0, 4 do for i = 0, 4 do
distTree.table[i] = 0 distTree.table[i] = 0
end end
distTree.table[5] = 32 distTree.table[5] = 32
for i = 0, 31 do for i = 0, 31 do
distTree.trans[i] = i distTree.trans[i] = i
end end
end end
local offs = table.create(16, 0) local offs = table.create(16, 0)
local function buildTree(t: Tree, lengths: { number }, off: number, num: number) local function buildTree(t: Tree, lengths: { number }, off: number, num: number)
-- clear code length count table -- clear code length count table
for i = 0, 15 do for i = 0, 15 do
t.table[i] = 0 t.table[i] = 0
end end
-- scan symbol lengths, and sum code length counts -- scan symbol lengths, and sum code length counts
for i = 0, num - 1 do for i = 0, num - 1 do
t.table[lengths[off + i]] += 1 t.table[lengths[off + i]] += 1
end end
t.table[0] = 0 t.table[0] = 0
-- compute offset table for distribution sort -- compute offset table for distribution sort
local sum = 0 local sum = 0
for i = 0, 15 do for i = 0, 15 do
offs[i] = sum offs[i] = sum
sum = sum + t.table[i] sum = sum + t.table[i]
end end
-- create code->symbol translation table -- create code->symbol translation table
for i = 0, num - 1 do for i = 0, num - 1 do
local len = lengths[off + i] local len = lengths[off + i]
if len > 0 then if len > 0 then
t.trans[offs[len]] = i t.trans[offs[len]] = i
offs[len] += 1 offs[len] += 1
end end
end end
end end
local function getBit(d: Data): number local function getBit(d: Data): number
if d.bitcount <= 0 then if d.bitcount <= 0 then
d.tag = buffer.readu8(d.source, d.sourceIndex) d.tag = buffer.readu8(d.source, d.sourceIndex)
d.sourceIndex += 1 d.sourceIndex += 1
d.bitcount = 8 d.bitcount = 8
end end
local bit = bit32.band(d.tag, 1) local bit = bit32.band(d.tag, 1)
d.tag = bit32.rshift(d.tag, 1) d.tag = bit32.rshift(d.tag, 1)
d.bitcount -= 1 d.bitcount -= 1
return bit return bit
end end
local function readBits(d: Data, num: number?, base: number): number local function readBits(d: Data, num: number?, base: number): number
if not num then if not num then
return base return base
end end
while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do
d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount)) d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount))
d.sourceIndex += 1 d.sourceIndex += 1
d.bitcount += 8 d.bitcount += 8
end end
local val = bit32.band(d.tag, bit32.rshift(0xffff, 16 - num)) local val = bit32.band(d.tag, bit32.rshift(0xffff, 16 - num))
d.tag = bit32.rshift(d.tag, num) d.tag = bit32.rshift(d.tag, num)
d.bitcount -= num d.bitcount -= num
return val + base return val + base
end end
local function decodeSymbol(d: Data, t: Tree): number local function decodeSymbol(d: Data, t: Tree): number
while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do
d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount)) d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount))
d.sourceIndex += 1 d.sourceIndex += 1
d.bitcount += 8 d.bitcount += 8
end end
local sum, cur, len = 0, 0, 0 local sum, cur, len = 0, 0, 0
local tag = d.tag local tag = d.tag
repeat repeat
cur = 2 * cur + bit32.band(tag, 1) cur = 2 * cur + bit32.band(tag, 1)
tag = bit32.rshift(tag, 1) tag = bit32.rshift(tag, 1)
len += 1 len += 1
sum += t.table[len] sum += t.table[len]
cur -= t.table[len] cur -= t.table[len]
until cur < 0 until cur < 0
d.tag = tag d.tag = tag
d.bitcount -= len d.bitcount -= len
return t.trans[sum + cur] return t.trans[sum + cur]
end end
local function decodeTrees(d: Data, lengthTree: Tree, distTree: Tree) local function decodeTrees(d: Data, lengthTree: Tree, distTree: Tree)
local hlit = readBits(d, 5, 257) local hlit = readBits(d, 5, 257)
local hdist = readBits(d, 5, 1) local hdist = readBits(d, 5, 1)
local hclen = readBits(d, 4, 4) local hclen = readBits(d, 4, 4)
for i = 0, 18 do for i = 0, 18 do
lengths[i] = 0 lengths[i] = 0
end end
for i = 0, hclen - 1 do for i = 0, hclen - 1 do
lengths[clcIndex[i + 1]] = readBits(d, 3, 0) lengths[clcIndex[i + 1]] = readBits(d, 3, 0)
end end
buildTree(codeTree, lengths, 0, 19) buildTree(codeTree, lengths, 0, 19)
local num = 0 local num = 0
while num < hlit + hdist do while num < hlit + hdist do
local sym = decodeSymbol(d, codeTree) local sym = decodeSymbol(d, codeTree)
if sym == 16 then if sym == 16 then
local prev = lengths[num - 1] local prev = lengths[num - 1]
for _ = 1, readBits(d, 2, 3) do for _ = 1, readBits(d, 2, 3) do
lengths[num] = prev lengths[num] = prev
num += 1 num += 1
end end
elseif sym == 17 then elseif sym == 17 then
for _ = 1, readBits(d, 3, 3) do for _ = 1, readBits(d, 3, 3) do
lengths[num] = 0 lengths[num] = 0
num += 1 num += 1
end end
elseif sym == 18 then elseif sym == 18 then
for _ = 1, readBits(d, 7, 11) do for _ = 1, readBits(d, 7, 11) do
lengths[num] = 0 lengths[num] = 0
num += 1 num += 1
end end
else else
lengths[num] = sym lengths[num] = sym
num += 1 num += 1
end end
end end
buildTree(lengthTree, lengths, 0, hlit) buildTree(lengthTree, lengths, 0, hlit)
buildTree(distTree, lengths, hlit, hdist) buildTree(distTree, lengths, hlit, hdist)
end end
local function inflateBlockData(d: Data, lengthTree: Tree, distTree: Tree) local function inflateBlockData(d: Data, lengthTree: Tree, distTree: Tree)
while true do while true do
local sym = decodeSymbol(d, lengthTree) local sym = decodeSymbol(d, lengthTree)
if sym == 256 then if sym == 256 then
return return
end end
if sym < 256 then if sym < 256 then
buffer.writeu8(d.dest, d.destLen, sym) buffer.writeu8(d.dest, d.destLen, sym)
d.destLen += 1 d.destLen += 1
else else
sym -= 257 sym -= 257
local length = readBits(d, lengthBits[sym], lengthBase[sym]) local length = readBits(d, lengthBits[sym], lengthBase[sym])
local dist = decodeSymbol(d, distTree) local dist = decodeSymbol(d, distTree)
local offs = d.destLen - readBits(d, distBits[dist], distBase[dist]) local offs = d.destLen - readBits(d, distBits[dist], distBase[dist])
for i = offs, offs + length - 1 do for i = offs, offs + length - 1 do
buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.dest, i)) buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.dest, i))
d.destLen += 1 d.destLen += 1
end end
end end
end end
end end
local function inflateUncompressedBlock(d: Data) local function inflateUncompressedBlock(d: Data)
while d.bitcount > 8 do while d.bitcount > 8 do
d.sourceIndex -= 1 d.sourceIndex -= 1
d.bitcount -= 8 d.bitcount -= 8
end end
local length = buffer.readu8(d.source, d.sourceIndex + 1) local length = buffer.readu8(d.source, d.sourceIndex + 1)
length = 256 * length + buffer.readu8(d.source, d.sourceIndex) length = 256 * length + buffer.readu8(d.source, d.sourceIndex)
local invlength = buffer.readu8(d.source, d.sourceIndex + 3) local invlength = buffer.readu8(d.source, d.sourceIndex + 3)
invlength = 256 * invlength + buffer.readu8(d.source, d.sourceIndex + 2) invlength = 256 * invlength + buffer.readu8(d.source, d.sourceIndex + 2)
if length ~= bit32.bxor(invlength, 0xffff) then if length ~= bit32.bxor(invlength, 0xffff) then
error("Invalid block length") error("Invalid block length")
end end
d.sourceIndex += 4 d.sourceIndex += 4
for _ = 1, length do for _ = 1, length do
buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.source, d.sourceIndex)) buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.source, d.sourceIndex))
d.destLen += 1 d.destLen += 1
d.sourceIndex += 1 d.sourceIndex += 1
end end
d.bitcount = 0 d.bitcount = 0
end end
local function uncompress(source: buffer): buffer local function uncompress(source: buffer): buffer
local dest = buffer.create(buffer.len(source) * 4) local dest = buffer.create(buffer.len(source) * 4)
local d = Data.new(source, dest) local d = Data.new(source, dest)
repeat repeat
local bfinal = getBit(d) local bfinal = getBit(d)
local btype = readBits(d, 2, 0) local btype = readBits(d, 2, 0)
if btype == 0 then if btype == 0 then
inflateUncompressedBlock(d) inflateUncompressedBlock(d)
elseif btype == 1 then elseif btype == 1 then
inflateBlockData(d, staticLengthTree, staticDistTree) inflateBlockData(d, staticLengthTree, staticDistTree)
elseif btype == 2 then elseif btype == 2 then
decodeTrees(d, d.ltree, d.dtree) decodeTrees(d, d.ltree, d.dtree)
inflateBlockData(d, d.ltree, d.dtree) inflateBlockData(d, d.ltree, d.dtree)
else else
error("Invalid block type") error("Invalid block type")
end end
until bfinal == 1 until bfinal == 1
if d.destLen < buffer.len(dest) then if d.destLen < buffer.len(dest) then
local result = buffer.create(d.destLen) local result = buffer.create(d.destLen)
buffer.copy(result, 0, dest, 0, d.destLen) buffer.copy(result, 0, dest, 0, d.destLen)
return result return result
end end
return dest return dest
end end
-- Initialize static trees and tables -- Initialize static trees and tables
buildFixedTrees(staticLengthTree, staticDistTree) buildFixedTrees(staticLengthTree, staticDistTree)
buildBitsBase(lengthBits, lengthBase, 4, 3) buildBitsBase(lengthBits, lengthBase, 4, 3)
buildBitsBase(distBits, distBase, 2, 1) buildBitsBase(distBits, distBase, 2, 1)
lengthBits[28] = 0 lengthBits[28] = 0
lengthBase[28] = 258 lengthBase[28] = 258
return uncompress return uncompress

View file

@ -1,51 +1,51 @@
local inflate = require("./inflate") local inflate = require("./inflate")
local crc32 = require("./crc") local crc32 = require("./crc")
-- Little endian constant signatures used in the ZIP file format -- Little endian constant signatures used in the ZIP file format
local SIGNATURES = table.freeze({ local SIGNATURES = table.freeze({
-- Marks the beginning of each file in the ZIP -- Marks the beginning of each file in the ZIP
LOCAL_FILE = 0x04034b50, LOCAL_FILE = 0x04034b50,
-- Marks entries in the central directory -- Marks entries in the central directory
CENTRAL_DIR = 0x02014b50, CENTRAL_DIR = 0x02014b50,
-- Marks the end of the central directory -- Marks the end of the central directory
END_OF_CENTRAL_DIR = 0x06054b50, END_OF_CENTRAL_DIR = 0x06054b50,
}) })
type CrcValidationOptions = { type CrcValidationOptions = {
skip: boolean, skip: boolean,
expected: number, expected: number,
} }
local function validateCrc(decompressed: buffer, validation: CrcValidationOptions) local function validateCrc(decompressed: buffer, validation: CrcValidationOptions)
-- Unless skipping validation is requested, we verify the checksum -- Unless skipping validation is requested, we verify the checksum
if validation.skip then if validation.skip then
local computed = crc32(decompressed) local computed = crc32(decompressed)
assert( assert(
validation.expected == computed, validation.expected == computed,
`Validation failed; CRC checksum does not match: {string.format("%x", computed)} ~= {string.format( `Validation failed; CRC checksum does not match: {string.format("%x", computed)} ~= {string.format(
"%x", "%x",
computed computed
)} (expected ~= got)` )} (expected ~= got)`
) )
end end
end end
local DECOMPRESSION_ROUTINES: { [number]: (buffer, validation: CrcValidationOptions) -> buffer } = table.freeze({ local DECOMPRESSION_ROUTINES: { [number]: (buffer, validation: CrcValidationOptions) -> buffer } = table.freeze({
[0x00] = function(buf, validation) [0x00] = function(buf, validation)
validateCrc(buf, validation) validateCrc(buf, validation)
return buf return buf
end, end,
[0x08] = function(buf, validation) [0x08] = function(buf, validation)
local decompressed = inflate(buf) local decompressed = inflate(buf)
validateCrc(decompressed, validation) validateCrc(decompressed, validation)
return decompressed return decompressed
end, end,
}) })
-- TODO: ERROR HANDLING! -- TODO: ERROR HANDLING!
local ZipEntry = {} local ZipEntry = {}
export type ZipEntry = typeof(setmetatable({} :: ZipEntryInner, { __index = ZipEntry })) export type ZipEntry = typeof(setmetatable({} :: ZipEntryInner, { __index = ZipEntry }))
-- stylua: ignore -- stylua: ignore
type ZipEntryInner = { type ZipEntryInner = {
name: string, -- File path within ZIP, '/' suffix indicates directory name: string, -- File path within ZIP, '/' suffix indicates directory
@ -57,37 +57,37 @@ type ZipEntryInner = {
parent: ZipEntry?, -- The parent of the current entry, nil for root parent: ZipEntry?, -- The parent of the current entry, nil for root
children: { ZipEntry }, -- The children of the entry children: { ZipEntry }, -- The children of the entry
} }
function ZipEntry.new(name: string, size: number, offset: number, timestamp: number, crc: number): ZipEntry function ZipEntry.new(name: string, size: number, offset: number, timestamp: number, crc: number): ZipEntry
return setmetatable( return setmetatable(
{ {
name = name, name = name,
size = size, size = size,
offset = offset, offset = offset,
timestamp = timestamp, timestamp = timestamp,
crc = crc, crc = crc,
isDirectory = string.sub(name, -1) == "/", isDirectory = string.sub(name, -1) == "/",
parent = nil, parent = nil,
children = {}, children = {},
} :: ZipEntryInner, } :: ZipEntryInner,
{ __index = ZipEntry } { __index = ZipEntry }
) )
end end
function ZipEntry.getPath(self: ZipEntry): string function ZipEntry.getPath(self: ZipEntry): string
local path = self.name local path = self.name
local current = self.parent local current = self.parent
while current and current.name ~= "/" do while current and current.name ~= "/" do
path = current.name .. path path = current.name .. path
current = current.parent current = current.parent
end end
return path return path
end end
local ZipReader = {} local ZipReader = {}
export type ZipReader = typeof(setmetatable({} :: ZipReaderInner, { __index = ZipReader })) export type ZipReader = typeof(setmetatable({} :: ZipReaderInner, { __index = ZipReader }))
-- stylua: ignore -- stylua: ignore
type ZipReaderInner = { type ZipReaderInner = {
data: buffer, -- The buffer containing the raw bytes of the ZIP data: buffer, -- The buffer containing the raw bytes of the ZIP
@ -95,296 +95,298 @@ type ZipReaderInner = {
directories: { [string]: ZipEntry }, -- The directories and their respective entries directories: { [string]: ZipEntry }, -- The directories and their respective entries
root: ZipEntry, -- The entry of the root directory root: ZipEntry, -- The entry of the root directory
} }
function ZipReader.new(data): ZipReader function ZipReader.new(data): ZipReader
local root = ZipEntry.new("/", 0, 0, 0, 0) local root = ZipEntry.new("/", 0, 0, 0, 0)
root.isDirectory = true root.isDirectory = true
local this = setmetatable( local this = setmetatable(
{ {
data = data, data = data,
entries = {}, entries = {},
directories = {}, directories = {},
root = root, root = root,
} :: ZipReaderInner, } :: ZipReaderInner,
{ __index = ZipReader } { __index = ZipReader }
) )
this:parseCentralDirectory() this:parseCentralDirectory()
this:buildDirectoryTree() this:buildDirectoryTree()
return this return this
end end
function ZipReader.parseCentralDirectory(self: ZipReader): () function ZipReader.parseCentralDirectory(self: ZipReader): ()
-- ZIP files are read from the end, starting with the End of Central Directory record -- ZIP files are read from the end, starting with the End of Central Directory record
-- The EoCD is at least 22 bytes and contains pointers to the rest of the ZIP structure -- The EoCD is at least 22 bytes and contains pointers to the rest of the ZIP structure
local bufSize = buffer.len(self.data) local bufSize = buffer.len(self.data)
local pos = bufSize - 22 local pos = bufSize - 22
-- Search backwards for the EoCD signature -- Search backwards for the EoCD signature
while pos > 0 do while pos > 0 do
-- Read 4 bytes as uint32 in little-endian format -- Read 4 bytes as uint32 in little-endian format
if buffer.readu32(self.data, pos) == SIGNATURES.END_OF_CENTRAL_DIR then if buffer.readu32(self.data, pos) == SIGNATURES.END_OF_CENTRAL_DIR then
break break
end end
pos = pos - 1 pos = pos - 1
end end
-- Central Directory offset is stored 16 bytes into the EoCD record -- Central Directory offset is stored 16 bytes into the EoCD record
local cdOffset = buffer.readu32(self.data, pos + 16) local cdOffset = buffer.readu32(self.data, pos + 16)
-- Number of entries is stored 10 bytes into the EoCD record -- Number of entries is stored 10 bytes into the EoCD record
local cdEntries = buffer.readu16(self.data, pos + 10) local cdEntries = buffer.readu16(self.data, pos + 10)
-- Process each entry in the Central Directory -- Process each entry in the Central Directory
pos = cdOffset pos = cdOffset
for i = 1, cdEntries do for i = 1, cdEntries do
-- Central Directory Entry format: -- Central Directory Entry format:
-- Offset Bytes Description -- Offset Bytes Description
-- ------------------------------------------------ -- ------------------------------------------------
-- 0 4 Central directory entry signature -- 0 4 Central directory entry signature
-- 28 2 File name length (n) -- 28 2 File name length (n)
-- 30 2 Extra field length (m) -- 30 2 Extra field length (m)
-- 32 2 Comment length (k) -- 32 2 Comment length (k)
-- 12 4 Last mod time/date -- 12 4 Last mod time/date
-- 16 4 CRC-32 -- 16 4 CRC-32
-- 24 4 Uncompressed size -- 24 4 Uncompressed size
-- 42 4 Local header offset -- 42 4 Local header offset
-- 46 n File name -- 46 n File name
-- 46+n m Extra field -- 46+n m Extra field
-- 46+n+m k Comment -- 46+n+m k Comment
local nameLength = buffer.readu16(self.data, pos + 28) local nameLength = buffer.readu16(self.data, pos + 28)
local extraLength = buffer.readu16(self.data, pos + 30) local extraLength = buffer.readu16(self.data, pos + 30)
local commentLength = buffer.readu16(self.data, pos + 32) local commentLength = buffer.readu16(self.data, pos + 32)
local timestamp = buffer.readu32(self.data, pos + 12) local timestamp = buffer.readu32(self.data, pos + 12)
local crc = buffer.readu32(self.data, pos + 16) local crc = buffer.readu32(self.data, pos + 16)
local size = buffer.readu32(self.data, pos + 24) local size = buffer.readu32(self.data, pos + 24)
local offset = buffer.readu32(self.data, pos + 42) local offset = buffer.readu32(self.data, pos + 42)
local nameBuffer = buffer.create(nameLength) local nameBuffer = buffer.create(nameLength)
buffer.copy(nameBuffer, 0, self.data, pos + 46, nameLength) buffer.copy(nameBuffer, 0, self.data, pos + 46, nameLength)
local name = buffer.tostring(nameBuffer) local name = buffer.tostring(nameBuffer)
local entry = ZipEntry.new(name, size, offset, timestamp, crc) local entry = ZipEntry.new(name, size, offset, timestamp, crc)
table.insert(self.entries, entry) table.insert(self.entries, entry)
pos = pos + 46 + nameLength + extraLength + commentLength pos = pos + 46 + nameLength + extraLength + commentLength
end end
end end
function ZipReader.buildDirectoryTree(self: ZipReader): () function ZipReader.buildDirectoryTree(self: ZipReader): ()
for _, entry in self.entries do for _, entry in self.entries do
local parts = {} local parts = {}
-- Split entry path into individual components -- Split entry path into individual components
-- e.g. "folder/subfolder/file.txt" -> {"folder", "subfolder", "file.txt"} -- e.g. "folder/subfolder/file.txt" -> {"folder", "subfolder", "file.txt"}
for part in string.gmatch(entry.name, "([^/]+)/?") do for part in string.gmatch(entry.name, "([^/]+)/?") do
table.insert(parts, part) table.insert(parts, part)
end end
-- Start from root directory -- Start from root directory
local current = self.root local current = self.root
local path = "" local path = ""
-- Process each path component -- Process each path component
for i, part in parts do for i, part in parts do
path ..= part path ..= part
if i < #parts then if i < #parts then
-- Create missing directory entries for intermediate paths -- Create missing directory entries for intermediate paths
if not self.directories[path] then if not self.directories[path] then
local dir = ZipEntry.new(path, 0, 0, entry.timestamp, 0) local dir = ZipEntry.new(path, 0, 0, entry.timestamp, 0)
dir.isDirectory = true dir.isDirectory = true
dir.parent = current dir.parent = current
-- Track directory in both lookup table and parent's children -- Track directory in both lookup table and parent's children
self.directories[path] = dir self.directories[path] = dir
table.insert(current.children, dir) table.insert(current.children, dir)
end end
-- Move deeper into the tree -- Move deeper into the tree
current = self.directories[path] current = self.directories[path]
continue continue
end end
-- Link file entry to its parent directory -- Link file entry to its parent directory
entry.parent = current entry.parent = current
table.insert(current.children, entry) table.insert(current.children, entry)
end end
end end
end end
function ZipReader.findEntry(self: ZipReader, path: string): ZipEntry function ZipReader.findEntry(self: ZipReader, path: string): ZipEntry
if path == "/" then if path == "/" then
-- If the root directory's entry was requested we do not -- If the root directory's entry was requested we do not
-- need to do any additional work -- need to do any additional work
return self.root return self.root
end end
-- Normalize path by removing leading and trailing slashes -- Normalize path by removing leading and trailing slashes
-- This ensures consistent lookup regardless of input format -- This ensures consistent lookup regardless of input format
-- e.g., "/folder/file.txt/" -> "folder/file.txt" -- e.g., "/folder/file.txt/" -> "folder/file.txt"
path = string.gsub(path, "^/", ""):gsub("/$", "") path = string.gsub(path, "^/", ""):gsub("/$", "")
-- First check regular files and explicit directories -- First check regular files and explicit directories
for _, entry in self.entries do for _, entry in self.entries do
-- Compare normalized paths -- Compare normalized paths
if string.gsub(entry.name, "/$", "") == path then if string.gsub(entry.name, "/$", "") == path then
return entry return entry
end end
end end
-- If not found, check virtual directory entries -- If not found, check virtual directory entries
-- These are directories that were created implicitly -- These are directories that were created implicitly
return self.directories[path] return self.directories[path]
end end
type ExtractionOptions = { type ExtractionOptions = {
decompress: boolean?, decompress: boolean?,
isString: boolean?, isString: boolean?,
skipValidation: boolean?, skipValidation: boolean?,
} }
function ZipReader.extract(self: ZipReader, entry: ZipEntry, options: ExtractionOptions?): buffer | string function ZipReader.extract(self: ZipReader, entry: ZipEntry, options: ExtractionOptions?): buffer | string
-- Local File Header format: -- Local File Header format:
-- Offset Bytes Description -- Offset Bytes Description
-- 0 4 Local file header signature -- 0 4 Local file header signature
-- 8 2 Compression method (8 = DEFLATE) -- 8 2 Compression method (8 = DEFLATE)
-- 14 4 CRC32 checksume -- 14 4 CRC32 checksume
-- 18 4 Compressed size -- 18 4 Compressed size
-- 22 4 Uncompressed size -- 22 4 Uncompressed size
-- 26 2 File name length (n) -- 26 2 File name length (n)
-- 28 2 Extra field length (m) -- 28 2 Extra field length (m)
-- 30 n File name -- 30 n File name
-- 30+n m Extra field -- 30+n m Extra field
-- 30+n+m - File data -- 30+n+m - File data
if entry.isDirectory then if entry.isDirectory then
error("Cannot extract directory") error("Cannot extract directory")
end end
local defaultOptions: ExtractionOptions = { local defaultOptions: ExtractionOptions = {
decompress = true, decompress = true,
isString = false, isString = false,
skipValidation = false, skipValidation = false,
} }
-- TODO: Use a `Partial` type function for this in the future! -- TODO: Use a `Partial` type function for this in the future!
local optionsOrDefault: { local optionsOrDefault: {
decompress: boolean, decompress: boolean,
isString: boolean, isString: boolean,
skipValidation: boolean skipValidation: boolean,
} = if options then setmetatable(options, { __index = defaultOptions }) :: any else defaultOptions } = if options
then setmetatable(options, { __index = defaultOptions }) :: any
local pos = entry.offset else defaultOptions
if buffer.readu32(self.data, pos) ~= SIGNATURES.LOCAL_FILE then
error("Invalid local file header") local pos = entry.offset
end if buffer.readu32(self.data, pos) ~= SIGNATURES.LOCAL_FILE then
error("Invalid local file header")
local crcChecksum = buffer.readu32(self.data, pos + 14) end
local compressedSize = buffer.readu32(self.data, pos + 18)
local uncompressedSize = buffer.readu32(self.data, pos + 22) local crcChecksum = buffer.readu32(self.data, pos + 14)
local nameLength = buffer.readu16(self.data, pos + 26) local compressedSize = buffer.readu32(self.data, pos + 18)
local extraLength = buffer.readu16(self.data, pos + 28) local uncompressedSize = buffer.readu32(self.data, pos + 22)
local nameLength = buffer.readu16(self.data, pos + 26)
pos = pos + 30 + nameLength + extraLength local extraLength = buffer.readu16(self.data, pos + 28)
local content = buffer.create(compressedSize) pos = pos + 30 + nameLength + extraLength
buffer.copy(content, 0, self.data, pos, compressedSize)
local content = buffer.create(compressedSize)
if optionsOrDefault.decompress then buffer.copy(content, 0, self.data, pos, compressedSize)
local compressionMethod = buffer.readu16(self.data, entry.offset + 8)
local decompress = DECOMPRESSION_ROUTINES[compressionMethod] if optionsOrDefault.decompress then
if decompress == nil then local compressionMethod = buffer.readu16(self.data, entry.offset + 8)
error(`Unsupported compression, ID: {compressionMethod}`) local decompress = DECOMPRESSION_ROUTINES[compressionMethod]
end if decompress == nil then
error(`Unsupported compression, ID: {compressionMethod}`)
content = decompress(content, { end
expected = crcChecksum,
skip = optionsOrDefault.skipValidation, content = decompress(content, {
}) expected = crcChecksum,
skip = optionsOrDefault.skipValidation,
-- Unless skipping validation is requested, we make sure the uncompressed size matches })
assert(
optionsOrDefault.skipValidation or uncompressedSize == buffer.len(content), -- Unless skipping validation is requested, we make sure the uncompressed size matches
"Validation failed; uncompressed size does not match" assert(
) optionsOrDefault.skipValidation or uncompressedSize == buffer.len(content),
end "Validation failed; uncompressed size does not match"
)
return if optionsOrDefault.isString then buffer.tostring(content) else content end
end
return if optionsOrDefault.isString then buffer.tostring(content) else content
function ZipReader.extractDirectory( end
self: ZipReader,
path: string, function ZipReader.extractDirectory(
options: ExtractionOptions self: ZipReader,
): { [string]: buffer } | { [string]: string } path: string,
local files: { [string]: buffer } | { [string]: string } = {} options: ExtractionOptions
-- Normalize path by removing leading slash for consistent prefix matching ): { [string]: buffer } | { [string]: string }
path = string.gsub(path, "^/", "") local files: { [string]: buffer } | { [string]: string } = {}
-- Normalize path by removing leading slash for consistent prefix matching
-- Iterate through all entries to find files within target directory path = string.gsub(path, "^/", "")
for _, entry in self.entries do
-- Check if entry is a file (not directory) and its path starts with target directory -- Iterate through all entries to find files within target directory
if not entry.isDirectory and string.sub(entry.name, 1, #path) == path then for _, entry in self.entries do
-- Store extracted content mapped to full path -- Check if entry is a file (not directory) and its path starts with target directory
files[entry.name] = self:extract(entry, options) if not entry.isDirectory and string.sub(entry.name, 1, #path) == path then
end -- Store extracted content mapped to full path
end files[entry.name] = self:extract(entry, options)
end
-- Return a map of file to contents end
return files
end -- Return a map of file to contents
return files
function ZipReader.listDirectory(self: ZipReader, path: string): { ZipEntry } end
-- Locate the entry with the path
local entry = self:findEntry(path) function ZipReader.listDirectory(self: ZipReader, path: string): { ZipEntry }
if not entry or not entry.isDirectory then -- Locate the entry with the path
-- If an entry was not found, we error local entry = self:findEntry(path)
error("Not a directory") if not entry or not entry.isDirectory then
end -- If an entry was not found, we error
error("Not a directory")
-- Return the children of our discovered entry end
return entry.children
end -- Return the children of our discovered entry
return entry.children
function ZipReader.walk(self: ZipReader, callback: (entry: ZipEntry, depth: number) -> ()): () end
-- Wrapper function which recursively calls callback for every child
-- in an entry function ZipReader.walk(self: ZipReader, callback: (entry: ZipEntry, depth: number) -> ()): ()
local function walkEntry(entry: ZipEntry, depth: number) -- Wrapper function which recursively calls callback for every child
callback(entry, depth) -- in an entry
local function walkEntry(entry: ZipEntry, depth: number)
for _, child in entry.children do callback(entry, depth)
-- ooo spooky recursion... blame this if shit go wrong
walkEntry(child, depth + 1) for _, child in entry.children do
end -- ooo spooky recursion... blame this if shit go wrong
end walkEntry(child, depth + 1)
end
walkEntry(self.root, 0) end
end
walkEntry(self.root, 0)
export type ZipStatistics = { fileCount: number, dirCount: number, totalSize: number } end
function ZipReader.getStats(self: ZipReader): ZipStatistics
local stats: ZipStatistics = { export type ZipStatistics = { fileCount: number, dirCount: number, totalSize: number }
fileCount = 0, function ZipReader.getStats(self: ZipReader): ZipStatistics
dirCount = 0, local stats: ZipStatistics = {
totalSize = 0, fileCount = 0,
} dirCount = 0,
totalSize = 0,
-- Iterate through the entries, updating stats }
for _, entry in self.entries do
if entry.isDirectory then -- Iterate through the entries, updating stats
stats.dirCount = stats.dirCount + 1 for _, entry in self.entries do
continue if entry.isDirectory then
end stats.dirCount = stats.dirCount + 1
continue
stats.fileCount = stats.fileCount + 1 end
stats.totalSize = stats.totalSize + entry.size
end stats.fileCount = stats.fileCount + 1
stats.totalSize = stats.totalSize + entry.size
return stats end
end
return stats
return { end
-- Creates a `ZipReader` from a `buffer` of ZIP data.
load = function(data: buffer) return {
return ZipReader.new(data) -- Creates a `ZipReader` from a `buffer` of ZIP data.
end, load = function(data: buffer)
} return ZipReader.new(data)
end,
}

View file

@ -2,6 +2,80 @@ name = "0x5eal/unzip"
version = "0.1.0" version = "0.1.0"
target = "luau" target = "luau"
[graph."jiwonz/dirs"."0.1.2 lune"]
resolved_ty = "standard"
[graph."jiwonz/dirs"."0.1.2 lune".target]
environment = "lune"
lib = "src/init.luau"
[graph."jiwonz/dirs"."0.1.2 lune".dependencies]
"jiwonz/pathfs" = ["0.1.0 lune", "pathfs"]
[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref]
ref_ty = "pesde"
name = "jiwonz/dirs"
version = "0.1.2"
index_url = "https://github.com/daimond113/pesde-index"
[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref.dependencies]
pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index" }, "standard"]
[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref.target]
environment = "lune"
lib = "src/init.luau"
[graph."jiwonz/pathfs"."0.1.0 lune"]
resolved_ty = "standard"
[graph."jiwonz/pathfs"."0.1.0 lune".target]
environment = "lune"
lib = "init.luau"
[graph."jiwonz/pathfs"."0.1.0 lune".pkg_ref]
ref_ty = "pesde"
name = "jiwonz/pathfs"
version = "0.1.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."jiwonz/pathfs"."0.1.0 lune".pkg_ref.target]
environment = "lune"
lib = "init.luau"
[graph."lukadev_0/option"."1.2.0 lune"]
resolved_ty = "standard"
[graph."lukadev_0/option"."1.2.0 lune".target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/option"."1.2.0 lune".pkg_ref]
ref_ty = "pesde"
name = "lukadev_0/option"
version = "1.2.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."lukadev_0/option"."1.2.0 lune".pkg_ref.target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 lune"]
resolved_ty = "standard"
[graph."lukadev_0/result"."1.2.0 lune".target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 lune".pkg_ref]
ref_ty = "pesde"
name = "lukadev_0/result"
version = "1.2.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."lukadev_0/result"."1.2.0 lune".pkg_ref.target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 luau"] [graph."lukadev_0/result"."1.2.0 luau"]
direct = ["result", { name = "lukadev_0/result", version = "^1.2.0" }, "standard"] direct = ["result", { name = "lukadev_0/result", version = "^1.2.0" }, "standard"]
resolved_ty = "standard" resolved_ty = "standard"
@ -19,3 +93,60 @@ index_url = "https://github.com/pesde-pkg/index"
[graph."lukadev_0/result"."1.2.0 luau".pkg_ref.target] [graph."lukadev_0/result"."1.2.0 luau".pkg_ref.target]
environment = "luau" environment = "luau"
lib = "lib/init.luau" lib = "lib/init.luau"
[graph."pesde/stylua"."2.0.2 lune"]
direct = ["stylua", { name = "pesde/stylua", version = "^2.0.2", target = "lune" }, "dev"]
resolved_ty = "dev"
[graph."pesde/stylua"."2.0.2 lune".target]
environment = "lune"
bin = "init.luau"
[graph."pesde/stylua"."2.0.2 lune".dependencies]
"lukadev_0/option" = ["1.2.0 lune", "option"]
"lukadev_0/result" = ["1.2.0 lune", "result"]
"pesde/toolchainlib" = ["0.1.7 lune", "core"]
[graph."pesde/stylua"."2.0.2 lune".pkg_ref]
ref_ty = "pesde"
name = "pesde/stylua"
version = "2.0.2"
index_url = "https://github.com/pesde-pkg/index"
[graph."pesde/stylua"."2.0.2 lune".pkg_ref.dependencies]
core = [{ name = "pesde/toolchainlib", version = "^0.1.3", index = "https://github.com/daimond113/pesde-index", target = "lune" }, "standard"]
option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "standard"]
result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "standard"]
[graph."pesde/stylua"."2.0.2 lune".pkg_ref.target]
environment = "lune"
bin = "init.luau"
[graph."pesde/toolchainlib"."0.1.7 lune"]
resolved_ty = "standard"
[graph."pesde/toolchainlib"."0.1.7 lune".target]
environment = "lune"
lib = "src/init.luau"
[graph."pesde/toolchainlib"."0.1.7 lune".dependencies]
"jiwonz/dirs" = ["0.1.2 lune", "dirs"]
"jiwonz/pathfs" = ["0.1.0 lune", "pathfs"]
"lukadev_0/option" = ["1.2.0 lune", "option"]
"lukadev_0/result" = ["1.2.0 lune", "result"]
[graph."pesde/toolchainlib"."0.1.7 lune".pkg_ref]
ref_ty = "pesde"
name = "pesde/toolchainlib"
version = "0.1.7"
index_url = "https://github.com/daimond113/pesde-index"
[graph."pesde/toolchainlib"."0.1.7 lune".pkg_ref.dependencies]
dirs = [{ name = "jiwonz/dirs", version = "^0.1.1", index = "https://github.com/daimond113/pesde-index" }, "standard"]
option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"]
pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index" }, "standard"]
result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"]
[graph."pesde/toolchainlib"."0.1.7 lune".pkg_ref.target]
environment = "lune"
lib = "src/init.luau"

View file

@ -13,3 +13,6 @@ default = "https://github.com/pesde-pkg/index"
[dependencies] [dependencies]
result = { name = "lukadev_0/result", version = "^1.2.0" } result = { name = "lukadev_0/result", version = "^1.2.0" }
[dev_dependencies]
stylua = { name = "pesde/stylua", version = "^2.0.2", target = "lune" }