From 1db315c94397dce52d2cfbecfa383a3e6cca188d Mon Sep 17 00:00:00 2001 From: Erica Marigold Date: Wed, 8 Jan 2025 18:09:31 +0000 Subject: [PATCH] feat: add `versionMadeBy` to `ZipEntry` Also updates the metadata test suite to test for this. --- lib/init.luau | 72 +++++++++++++++++++++++++++++++++++++++++++-- tests/metadata.luau | 26 ++++++++++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/lib/init.luau b/lib/init.luau index fdb3903..23a5aa9 100644 --- a/lib/init.luau +++ b/lib/init.luau @@ -38,20 +38,50 @@ local DECOMPRESSION_ROUTINES: { [number]: { name: CompressionMethod, decompress: } local EMPTY_PROPERTIES: ZipEntryProperties = table.freeze({ + versionMadeBy = 0, size = 0, attributes = 0, timestamp = 0, crc = 0, }) +local MADE_BY_OS_LOOKUP: { [number]: MadeByOS } = { + [0x0] = "FAT", + [0x1] = "AMIGA", + [0x2] = "VMS", + [0x3] = "UNIX", + [0x4] = "VM/CMS", + [0x5] = "Atari ST", + [0x6] = "OS/2", + [0x7] = "MAC", + [0x8] = "Z-System", + [0x9] = "CP/M", + [0xa] = "NTFS", + [0xb] = "MVS", + [0xc] = "VSE", + [0xd] = "Acorn RISCOS", + [0xe] = "VFAT", + [0xf] = "Alternate MVS", + [0x10] = "BeOS", + [0x11] = "TANDEM", + [0x12] = "OS/400", + [0x13] = "OS/X", +} + -- TODO: ERROR HANDLING! local ZipEntry = {} export type ZipEntry = typeof(setmetatable({} :: ZipEntryInner, { __index = ZipEntry })) -- stylua: ignore type ZipEntryInner = { - name: string, -- File path within ZIP, '/' suffix indicates directory - size: number, -- Uncompressed size in bytes + name: string, -- File path within ZIP, '/' suffix indicates directory + + versionMadeBy: { -- Version of software and OS that created the ZIP + software: string, -- Software version used to create the ZIP + os: MadeByOS, -- Operating system used to create the ZIP + }, + + size: number, -- Uncompressed size in bytes offset: number, -- Absolute position of local header in ZIP timestamp: number, -- MS-DOS format timestamp method: CompressionMethod, -- Method used to compress the file @@ -63,8 +93,32 @@ type ZipEntryInner = { children: { ZipEntry }, -- The children of the entry } +-- stylua: ignore +export type MadeByOS = + | "FAT" -- 0x0; MS-DOS and OS/2 (FAT / VFAT / FAT32 file systems) + | "AMIGA" -- 0x1; Amiga + | "VMS" -- 0x2; OpenVMS + | "UNIX" -- 0x3; Unix + | "VM/CMS" -- 0x4; VM/CMS + | "Atari ST" -- 0x5; Atari ST + | "OS/2" -- 0x6; OS/2 HPFS + | "MAC" -- 0x7; Macintosh + | "Z-System" -- 0x8; Z-System + | "CP/M" -- 0x9; Original CP/M + | "NTFS" -- 0xa; Windows NTFS + | "MVS" -- 0xb; OS/390 & VM/ESA + | "VSE" -- 0xc; VSE + | "Acorn RISCOS" -- 0xd; Acorn RISCOS + | "VFAT" -- 0xe; VFAT + | "Alternate MVS" -- 0xf; Alternate MVS + | "BeOS" -- 0x10; BeOS + | "TANDEM" -- 0x11; Tandem + | "OS/400" -- 0x12; OS/400 + | "OS/X" -- 0x13; Darwin + | "Unknown" -- 0x14 - 0xff; Unused export type CompressionMethod = "STORE" | "DEFLATE" export type ZipEntryProperties = { + versionMadeBy: number, size: number, attributes: number, timestamp: number, @@ -73,9 +127,16 @@ export type ZipEntryProperties = { } function ZipEntry.new(offset: number, name: string, properties: ZipEntryProperties): ZipEntry + local versionMadeByOS = bit32.rshift(properties.versionMadeBy, 8) + local versionMadeByVersion = bit32.band(properties.versionMadeBy, 0x00ff) + return setmetatable( { name = name, + versionMadeBy = { + software = string.format("%d.%d", versionMadeByVersion / 10, versionMadeByVersion % 10), + os = MADE_BY_OS_LOOKUP[versionMadeByOS] :: MadeByOS, + }, size = properties.size, offset = offset, timestamp = properties.timestamp, @@ -89,7 +150,6 @@ function ZipEntry.new(offset: number, name: string, properties: ZipEntryProperti { __index = ZipEntry } ) end - function ZipEntry.isSymlink(self: ZipEntry): boolean return bit32.band(self.attributes, 0xA0000000) == 0xA0000000 end @@ -182,6 +242,7 @@ function ZipReader.parseCentralDirectory(self: ZipReader): () -- Central Directory Entry format: -- Offset Bytes Description -- 0 4 Central directory entry signature + -- 4 2 Version made by -- 8 2 General purpose bitflags -- 10 2 Compression method (8 = DEFLATE) -- 12 4 Last mod time/date @@ -197,6 +258,7 @@ function ZipReader.parseCentralDirectory(self: ZipReader): () -- 46+n m Extra field -- 46+n+m k Comment + local versionMadeBy = buffer.readu16(self.data, pos + 4) local _bitflags = buffer.readu16(self.data, pos + 8) local timestamp = buffer.readu32(self.data, pos + 12) local compressionMethod = buffer.readu16(self.data, pos + 10) @@ -213,6 +275,7 @@ function ZipReader.parseCentralDirectory(self: ZipReader): () table.insert( self.entries, ZipEntry.new(offset, name, { + versionMadeBy = versionMadeBy, size = size, crc = crc, method = DECOMPRESSION_ROUTINES[compressionMethod].name :: CompressionMethod, @@ -264,12 +327,15 @@ function ZipReader.buildDirectoryTree(self: ZipReader): () -- Create new directory entry for intermediate paths or undefined -- parent directories in the ZIP local dir = ZipEntry.new(0, path .. "/", { + versionMadeBy = 0, size = 0, crc = 0, compressionMethod = "STORED", timestamp = entry.timestamp, attributes = entry.attributes, }) + + dir.versionMadeBy = entry.versionMadeBy dir.isDirectory = true dir.parent = current self.directories[path] = dir diff --git a/tests/metadata.luau b/tests/metadata.luau index a37bc45..f21dd37 100644 --- a/tests/metadata.luau +++ b/tests/metadata.luau @@ -23,6 +23,14 @@ local METHOD_NAME_TRANSFORMATIONS: { [string]: unzip.CompressionMethod } = { ["Stored"] = "STORE", } +-- Non conclusive translations from host OS zipinfo field and MadeByOS union +local OS_NAME_TRANSFORMATIONS: { [string]: unzip.MadeByOS } = { + ["unx"] = "UNIX", + ["hpf"] = "OS/2", + ["mac"] = "MAC", + ["ntfs"] = "NTFS", +} + local function timestampToValues(dosTimestamp: number): DateTime.DateTimeValues local time = bit32.band(dosTimestamp, 0xFFFF) local date = bit32.band(bit32.rshift(dosTimestamp, 16), 0xFFFF) @@ -93,12 +101,12 @@ return function(test: typeof(frktest.test)) local zip = unzip.load(buffer.fromstring(data)) -- Get sizes from unzip command - local result = process.spawn("unzip", { "-v", file }) + local unzipResult = process.spawn("unzip", { "-v", file }) -- HACK: We use assert here since we don't know if we expect false or true - assert(result.ok) + assert(unzipResult.ok) -- Parse unzip output - for line in string.gmatch(result.stdout, "[^\r\n]+") do + for line in string.gmatch(unzipResult.stdout, "[^\r\n]+") do if not string.match(line, "^Archive:") and not string.match(line, "^%s+Length") @@ -114,6 +122,18 @@ return function(test: typeof(frktest.test)) local entry = assert(zip:findEntry(assert(name))) + local ok, zipinfoResult = pcall(process.spawn, "zipinfo", { file, name }) + if ok then + -- Errors can only occur when there is a non utf-8 file name, in which case + -- we skip that file + assert(zipinfoResult.ok) + local versionMadeBySoftware, versionMadeByOS = + string.match(zipinfoResult.stdout, "^.*%s+(%d+%.%d+)%s+(%S+).*$") + + check.equal(versionMadeBySoftware, entry.versionMadeBy.software) + check.equal(OS_NAME_TRANSFORMATIONS[assert(versionMadeByOS)], entry.versionMadeBy.os) + end + local gotDateTime = DateTime.fromLocalTime( timestampToValues(entry.timestamp) :: DateTime.DateTimeValueArguments )