--- /dev/null
+--- Message digest module.
+-- Read, verify and write message digest files like those generated by
+-- sha1sum or md5sum.
+--
+-- @module local.digest
+
+--[[
+ e2factory, the emlix embedded build system
+
+ Copyright (C) 2012 Tobias Ulmer <tu@emlix.com>, emlix GmbH
+
+ For more information have a look at http://www.e2factory.org
+
+ e2factory is a registered trademark by emlix GmbH.
+
+ This file is part of e2factory, the emlix embedded build system.
+
+ e2factory is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+]]
+
+local digest = {}
+
+local e2lib = require("e2lib")
+local hash = require("hash")
+local err = require("err")
+local strict = require("strict")
+
+--- Digest table.
+-- Contains an array of digest entries.
+--
+-- @table dt
+-- @see dt_entry
+
+--- MD5 digest type.
+-- Used to denote the checksum type.
+-- @see dt_entry
+digest.MD5 = 1
+
+--- Length of an alphanumeric MD5 sum string.
+digest.MD5_LEN = 32
+
+--- SHA1 digest type.
+-- Used to denote the checksum type.
+-- @see dt_entry
+digest.SHA1 = 2
+
+--- Length of an alphanumeric SHA1 sum string.
+digest.SHA1_LEN = 40
+
+--- Digest entry table.
+-- Holds the actual fields of a message digest entry.
+--
+-- @table dt_entry
+-- @field digest Digest type of the checksum.
+-- @field checksum The checksum (string).
+-- @field name File name (string). Should be a relative path.
+-- @field name2check File name (string) that should be checked instead of
+-- name by verify(). This field is optional, and not part of the message
+-- digest file. Must be an absolute path.
+-- @see dt
+-- @see verify()
+
+--- Parse a digest file.
+-- Returns a new digest table filled with digest entries.
+--
+-- @param filename The name of the file that should be parsed.
+-- @return A digest table; false when an error is encountered.
+-- @return An error object on failure.
+-- @see dt
+-- @see dt_entry
+function digest.parse(filename)
+ assert(type(filename) == "string")
+
+ local e = err.new("error parsing message digest file '%s'", filename)
+
+ local fd, re = io.open(filename, "r")
+ if not fd then
+ return false, e:append("%s", e)
+ end
+
+ local dt = {}
+ local linenr = 0
+
+ while true do
+ linenr = linenr + 1
+ local line = fd:read("*l")
+ if not line then
+ break
+ end
+
+ -- XXX: This is a pretty naive way to parse the file format.
+ -- Replace it with something more robust.
+ local checksum, filenm = line:match("^([0-9a-z]+) (%S+)$")
+ if not checksum or not filenm then
+ fd:close()
+ return false, e:append("could not parse file format in line %d",
+ linenr)
+ end
+
+ local entry = {}
+ entry.checksum = checksum
+ entry.name = filenm
+
+ if string.len(checksum) == digest.MD5_LEN then
+ entry.digest = digest.MD5
+ elseif string.len(checksum) == digest.SHA1_LEN then
+ entry.digest = digest.SHA1
+ else
+ fd:close()
+ return false, e:append("unknown digest type in line %d", linenr)
+ end
+
+ table.insert(dt, entry)
+ end
+
+ return dt
+end
+
+--- Create empty digest table.
+-- This is just a helper to improve code clarity.
+-- @return An empty digest table.
+function digest.new()
+ return {}
+end
+
+--- Create a new digest entry.
+-- Adds a new digest entry to the digest table. Returns the new digest entry for
+-- easy modification. This function does not check any arguments except for
+-- "dt". It is legal to set digest entry fields to nil. It does not fail under
+-- normal circumstances.
+-- See dt_entry for a better description of the arguments.
+-- @param dt A digest table
+-- @param digest Digest type
+-- @param checksum Checksum string
+-- @param name Filename string
+-- @param name2check Optional filename string.
+-- return A new digest entry, filled with all the arguments.
+-- @see dt_entry
+function digest.new_entry(dt, digest, checksum, name, name2check)
+ assert(type(dt) == "table")
+
+ local entry = {
+ digest = digest,
+ checksum = checksum,
+ name = name,
+ name2check = name2check,
+ }
+
+ table.insert(dt, entry)
+ return entry
+end
+
+local function compute_checksum_entry(pos, entry, directory, verify)
+ local rc, re
+ local filename, computedcs
+
+ if directory then
+ filename = e2lib.join("/", directory, entry.name)
+ else
+ if not entry.name2check then
+ return false, err.new("internal error: name2check requested but "..
+ "unset")
+ end
+ filename = e2lib.join("/", entry.name2check)
+ end
+
+ if entry.digest == digest.SHA1 then
+ -- XXX: We assume the hash module returns SHA1 checksums. Not nice.
+ local hc, re = hash.hash_start()
+ if not hc then
+ return false, re
+ end
+
+ rc, re = hc:hash_file(filename)
+ if not rc then
+ return false, re
+ end
+
+ computedcs, re = hc:hash_finish()
+ if not computedcs then
+ return false, re
+ end
+ elseif entry.digest.MD5 then
+ -- XXX: Fix this for backwards compat.
+ return false, err.new("Computing MD5 checksums is currently not "..
+ "supported")
+ end
+
+ if verify then
+ if string.lower(entry.checksum) ~= computedcs then
+ return false, err.new("checksum mismatch for file '%s'",
+ entry.name)
+ end
+ else
+ entry.checksum = computedcs
+ end
+
+ return true
+end
+
+--- Compute checksums in a digest table.
+--
+-- @param dt A digest table filled with partial digest entries. Fields "digest",
+-- "name", and optionally "name2check" must be filled in.
+-- @param directory A directory (string) containing the files in "name" which
+-- should be checksum'ed. Directory may be false, in which case
+-- the name2check field is used.
+-- @return True on success, false on error.
+-- @return An error object on failure.
+function digest.checksum(dt, directory)
+ assert(type(dt) == "table")
+ assert(type(directory) == "string" or directory == false)
+
+ local e = err.new("checksuming failed")
+ local rc, re
+
+ for pos, entry in ipairs(dt) do
+ rc, re = compute_checksum_entry(pos, entry, directory, false)
+ if not rc then
+ return false, e:cat(re)
+ end
+ end
+
+ return true
+end
+
+--- Verify a digest table.
+--
+-- @param dt A digest table.
+-- @param directory A directory containing the files which should be verified.
+-- Directory may be false, in which case the digest entry name2check field is
+-- used.
+-- @return True on success, false on error.
+-- @return An error object on failure.
+function digest.verify(dt, directory)
+ assert(type(dt) == "table")
+ assert(type(directory) == "string" or directory == false)
+
+ local e = err.new("checksum verification failed")
+ local rc, re
+
+ for pos, entry in ipairs(dt) do
+ rc, re = compute_checksum_entry(pos, entry, directory, true)
+ if not rc then
+ return false, e:cat(re)
+ end
+ end
+
+ return true
+end
+
+--- Count of digest entries in a digest table.
+--
+-- @param dt A digest table.
+-- @return The number of digest entries in the table. This function should not
+-- fail.
+function digest.count(dt)
+ assert(type(dt) == "table")
+
+ return #dt
+end
+
+--- Sanity check a digest table before writing.
+-- There must be at least one digest entry, all relevant fields (checksum,
+-- digest) must be set. In addition, "digest" needs to be set and must be of
+-- one type for all entries, only. All failures of this function indicate
+-- internal inconsistencies. Ideally the errors should not be passed to the user.
+--
+-- @param dt A digest table with at least one digest entry.
+-- @return True on success, false on error.
+-- @return An error object describing the issue on failure.
+function digest.sanity_check(dt)
+ assert(type(dt) == "table")
+
+ if digest.count(dt) == 0 then
+ return false, err.new("digest table is empty")
+ end
+
+ local digest_type
+ for pos, entry in ipairs(dt) do
+ if not digest_type then
+ digest_type = entry.digest
+ end
+
+ if entry.digest ~= digest.MD5 and entry.digest ~= digest.SHA1 then
+ return false,
+ err.new("digest entry %d has unknown digest type", pos)
+ end
+
+ if digest_type ~= entry.digest then
+ return false, err.new("digest table may not contain a mixture of" ..
+ " digest types")
+ end
+
+ local len = string.len(entry.checksum)
+ if len ~= digest.MD5_LEN and len ~= digest.SHA1_LEN then
+ return false, err.new("digest entry %d has unknown checksum "..
+ "length (%d)", pos, len)
+ end
+
+ if string.len(entry.name) == 0 then
+ return false, err.new("digest entry %d has no name", pos)
+ end
+ end
+
+ return true
+end
+
+--- Write out a digest file.
+-- The specified file will be overwritten. In case of an error, it is the
+-- callers responsibility to delete the potentially incomplete output file.
+--
+-- @param dt A digest table with digest table entries.
+-- @param filename Filename to write digest entries into. The file will be
+-- created or overwritten in case it already exists.
+function digest.write(dt, filename)
+ assert(type(dt) == "table")
+ assert(type(filename) == "string")
+
+ local e = err.new("error writing message digest file '%s'", filename)
+
+ local rc, re = digest.sanity_check(dt)
+ if not rc then
+ return false, e:cat(re)
+ end
+
+ local fd, re = io.open(filename, "w")
+ if not fd then
+ return false, e:append("%s", re)
+ end
+
+ local line = ""
+ for pos, entry in ipairs(dt) do
+ line = string.format("%s %s\n", entry.checksum, entry.name)
+ rc, re = fd:write(line)
+ if not rc then
+ fd:close()
+ return false, e:append("%s", re)
+ end
+ end
+
+ rc, re = fd:close()
+ if not rc then
+ return false, e:append("%s", re)
+ end
+
+ return true
+end
+
+return strict.lock(digest)
+
+-- vim:sw=4:sts=4:et: