files/lua/tar.lua
-----------------------------------------------------------------------------
-- tar - Lua Tape ARchive module
--
-- Code for managing tar files
--
-- Version 0.1 (21 AUG 2007)
--
-- Author: Judge Maygarden (jmaygarden@computer.org)
--
-- Copyright (c) 2007 Judge Maygarden
-----------------------------------------------------------------------------

local public = {}
local private = {}
local TarFile = {}
local TarInternalFile = {}
tar = public
TarFile.__index = TarFile
TarInternalFile.__index = TarInternalFile

-----------------------------------------------------------------------------
-- Closes file
-----------------------------------------------------------------------------
function TarInternalFile:close()
end

-----------------------------------------------------------------------------
-- UNSUPPORTED: Saves any written data to file
-----------------------------------------------------------------------------
function TarInternalFile:flush()
	error('Tar file output flushing is unsupported.')
end

-----------------------------------------------------------------------------
-- Returns an iterator function that returns a new line from the file
-- @return String repressenting a line from the file without the newline
-----------------------------------------------------------------------------
function TarInternalFile:lines()
	return function(s, var)
		return self:read("*l")
	end, self, nil
end

-----------------------------------------------------------------------------
-- Reads the file according to the given formats as follows:
-- "*n": reads a number
-- "*a": reads the whole file
-- "*l" reads the next line
-- number: reads a string with up to this number of characters
-- @param variadic list of formats (default "*l")
-- @return list of strings (or numbers) or nil if end of file or format error
-----------------------------------------------------------------------------
function TarInternalFile:read(...)
	local eof = self.offset + self.size
	if self.pointer >= eof then return nil end -- eof
	local t = {}
	self.archive.file:seek("set", self.pointer)
	for i, v in ipairs{...} do
		local s
		if "*a" == v then
			s = self.archive.file:read(eof - self.pointer)
		elseif "*l" == v or "*n" == v or "number" == type(v) then
			s = self.archive.file:read(v)
		else
			error('bad argument %d to %s (%s)', i
				'TarInternalFile:read', 'invalid format')
			return nil
		end
		local last = self.pointer
		self.pointer = self.archive.file:seek()
		if s and self.pointer < eof then
			table.insert(t, s)
		elseif s then
			table.insert(t, string.sub(s, 1, eof - last - 1))
			self.pointer = self.offset + self.size
			break
		else
			break
		end
	end
	return unpack(t)
end

-----------------------------------------------------------------------------
-- Sets and gets the file position measured from the given base as follows:
-- "set": base is position 0 (beginning of the file)
-- "cur": base is current position
-- "end": base is end of file
-- @param base position (default "cur")
-- @param offset in bytes (default 0)
-- @return final position
-----------------------------------------------------------------------------
function TarInternalFile:seek(whence, offset)
	local whence = whence or "cur"
	local offset = offset or 0
	local eof = self.offset + self.size
	local final
	local err

	if "cur" == whence then
		final, err = self.archive.file:seek("set",
			offset + self.pointer) - self.offset

	elseif "set" == whence then
		final, err =  self.archive.file:seek("set",
			offset + self.offset) - self.offset

	elseif "end" == whence then
		final, err =  self.archive.file:seek("set",
			offset + eof - 1) - self.offset

	else
		final, err =  self.archive.file:seek(whence, offset)
	end

	if not final then
		return nil, err
	elseif final < 0 then
		final = 0
	elseif final > self.size then
		final = self.size
	end
	self.pointer = final + self.offset
	
	return final
end

-----------------------------------------------------------------------------
-- UNSUPPORTED: Sets the buffering mode for an output file
-- @param "no" - no buffering, "full" - full buffering, "line" - line buffering
-- @param size of the buffer for "full" and "line" modes
-----------------------------------------------------------------------------
function TarInternalFile:setvbuf(mode, size)
	error('Tar file output buffering is unsupported.')
end

-----------------------------------------------------------------------------
-- UNSUPPORTED: Writes the value of each of its arguments to the file
-- @param string or numbers values only
-----------------------------------------------------------------------------
function TarInternalFile:write(...)
	error('Tar file writes are unsupported.')
end

-----------------------------------------------------------------------------
-- Opens a file inside the tar file archive
-- @param pathname of a file inside the tar file
-- @return file handle or nil if the file is not found
-----------------------------------------------------------------------------
function TarFile:open(filename)
	if not self.list[filename] then return nil end
	local file = {}
	file.archive = self
	file.filename = filename
	file.offset = self.list[filename].offset
	file.pointer = file.offset
	file.size = self.list[filename].size
	setmetatable(file, TarInternalFile)
	return file
end

-----------------------------------------------------------------------------
-- Returns an iterator over all files in the tar archive
-- @return interator function, tar file handle
-----------------------------------------------------------------------------
function TarFile:files()
	return next, self.list, nil
end

-----------------------------------------------------------------------------
-- Data for parsing tar header fields
-----------------------------------------------------------------------------
private.HEADER_DATA = {
	-- { field, offset, size, octal }
	{ "name", 0, 100 },
	{ "mode", 100, 8, true },
	{ "uid", 108, 8, true },
	{ "gid", 116, 8, true },
	{ "size", 124, 12, true },
	{ "mtime", 136, 12, true },
	{ "chksum", 148, 8, true },
	{ "typeflag", 156, 1, true },
	{ "linkname", 157, 100 },
	{ "magic", 257, 6 },
	{ "version", 263, 2 },
	{ "uname", 265, 32 },
	{ "gname", 297, 32 },
	{ "devmajor", 329, 8, true },
	{ "devminor", 337, 8, true },
	{ "prefix", 345, 155 },
}

-----------------------------------------------------------------------------
-- Converts an octal string into a number
-- @param string of octal digits
-- @return number or nil if an invalid string was passed
-----------------------------------------------------------------------------
private.octal = function(s)
	if string.match(s, "[^0-7]") then return nil end
	local n, m = 0, 0
	for i = string.len(s), 1, -1 do
		n = n + (string.byte(s, i) - 48) * 8 ^ m
		m = m + 1
	end
	return n
end

-----------------------------------------------------------------------------
-- Decodes a tar file header
-- @param 512-byte string containing a tar file header block
-- @return table containg the header fields
-----------------------------------------------------------------------------
private.decode = function(header)
	local t = {}
	for _, field in ipairs(private.HEADER_DATA) do
		local s = string.sub(header, field[2] + 1, field[2] + field[3])
		local s = string.match(s, "[^%z]*")
		if field[4] then
			t[field[1]] = private.octal(s)
		else
			t[field[1]] = s
		end
	end
	if 0 < string.len(t.prefix) then
		t.pathname = t.prefix..'/'..t.name
	else
		t.pathname = t.name
	end
	return t
end

-----------------------------------------------------------------------------
-- Opens a tar archive by attaching to the provided Lua file handle.
-- Ownership of the file handle is transferred to the returned table and
-- should no longer be referenced by the user.
-- @param open file handle
-- @return tar file handle
-----------------------------------------------------------------------------
public.open = function(file)
	assert(file, 'invalid file handle')
	local archive = {}
	archive.file = file
	archive.list = {}
	local p = file:seek("set")
	while true do
		local block = file:read(512)
		if not block or not string.match(block, "[^%z]") then break end
		local header = private.decode(block)
		if 0 == header.typeflag then
			archive.list[header.pathname] = {
				["offset"] = file:seek(),
				["size"] = header.size
			}
		end
		local p = file:seek("cur", 512 * math.ceil(header.size / 512))
	end
	setmetatable(archive, TarFile)
	return archive
end

return tar

