blob: b284068afe1faa64d6aa55010b2097fa1c35ff43 [file] [log] [blame]
#!/usr/bin/env python
"""%prog [options] -x|-t|-c marfile [files]
Utility for managing mar files"""
### The canonical location for this file is
### https://hg.mozilla.org/build/tools/file/default/buildfarm/utils/mar.py
###
### Please update the copy in puppet to deploy new changes to
### stage.mozilla.org, see
# https://wiki.mozilla.org/ReleaseEngineering/How_To/Modify_scripts_on_stage
import struct
import os
import bz2
import hashlib
import tempfile
from subprocess import Popen, PIPE
def rsa_sign(digest, keyfile):
proc = Popen(['openssl', 'pkeyutl', '-sign', '-inkey', keyfile],
stdin=PIPE, stdout=PIPE)
proc.stdin.write(digest)
proc.stdin.close()
sig = proc.stdout.read()
return sig
def rsa_verify(digest, signature, keyfile):
tmp = tempfile.NamedTemporaryFile()
tmp.write(signature)
tmp.flush()
proc = Popen(['openssl', 'pkeyutl', '-pubin', '-verify', '-sigfile',
tmp.name, '-inkey', keyfile], stdin=PIPE, stdout=PIPE)
proc.stdin.write(digest)
proc.stdin.close()
data = proc.stdout.read()
return "Signature Verified Successfully" in data
def packint(i):
return struct.pack(">L", i)
def unpackint(s):
return struct.unpack(">L", s)[0]
def generate_signature(fp, updatefunc):
fp.seek(0)
# Magic
updatefunc(fp.read(4))
# index_offset
updatefunc(fp.read(4))
# file size
updatefunc(fp.read(8))
# number of signatures
num_sigs = fp.read(4)
updatefunc(num_sigs)
num_sigs = unpackint(num_sigs)
for i in range(num_sigs):
# signature algo
updatefunc(fp.read(4))
# signature size
sigsize = fp.read(4)
updatefunc(sigsize)
sigsize = unpackint(sigsize)
# Read this, but don't update the signature with it
fp.read(sigsize)
# Read the rest of the file
while True:
block = fp.read(512 * 1024)
if not block:
break
updatefunc(block)
class MarSignature:
"""Represents a signature"""
size = None
sigsize = None
algo_id = None
signature = None
_offset = None # where in the file this signature is located
keyfile = None # what key to use
@classmethod
def from_fileobj(cls, fp):
_offset = fp.tell()
algo_id = unpackint(fp.read(4))
self = cls(algo_id)
self._offset = _offset
sigsize = unpackint(fp.read(4))
assert sigsize == self.sigsize
self.signature = fp.read(self.sigsize)
return self
def __init__(self, algo_id, keyfile=None):
self.algo_id = algo_id
self.keyfile = keyfile
if self.algo_id == 1:
self.sigsize = 256
self.size = self.sigsize + 4 + 4
self._hsh = hashlib.new('sha1')
else:
raise ValueError("Unsupported signature algorithm: %s" % algo_id)
def update(self, data):
self._hsh.update(data)
def verify_signature(self):
if self.algo_id == 1:
print "digest is", self._hsh.hexdigest()
assert self.keyfile
return rsa_verify(self._hsh.digest(), self.signature, self.keyfile)
def write_signature(self, fp):
assert self.keyfile
print "digest is", self._hsh.hexdigest()
self.signature = rsa_sign(self._hsh.digest(), self.keyfile)
assert len(self.signature) == self.sigsize
fp.seek(self._offset)
fp.write("%s%s%s" % (
packint(self.algo_id),
packint(self.sigsize),
self.signature))
print "wrote signature %s" % self.algo_id
class MarInfo:
"""Represents information about a member of a MAR file. The following
attributes are supported:
`size`: the file's size
`name`: the file's name
`flags`: file permission flags
`_offset`: where in the MAR file this member exists
"""
size = None
name = None
flags = None
_offset = None
# The member info is serialized as a sequence of 4-byte integers
# representing the offset, size, and flags of the file followed by a null
# terminated string representing the filename
_member_fmt = ">LLL"
@classmethod
def from_fileobj(cls, fp):
"""Return a MarInfo object by reading open file object `fp`"""
self = cls()
data = fp.read(12)
if not data:
# EOF
return None
if len(data) != 12:
raise ValueError("Malformed mar?")
self._offset, self.size, self.flags = struct.unpack(
cls._member_fmt, data)
name = ""
while True:
c = fp.read(1)
if c is None:
raise ValueError("Malformed mar?")
if c == "\x00":
break
name += c
self.name = name
return self
def __repr__(self):
return "<%s %o %s bytes starting at %i>" % (
self.name, self.flags, self.size, self._offset)
def to_bytes(self):
return struct.pack(self._member_fmt, self._offset, self.size, self.flags) + \
self.name + "\x00"
class MarFile:
"""Represents a MAR file on disk.
`name`: filename of MAR file
`mode`: either 'r' or 'w', depending on if you're reading or writing.
defaults to 'r'
"""
_longint_fmt = ">L"
def __init__(self, name, mode="r", signature_versions=[]):
if mode not in "rw":
raise ValueError("Mode must be either 'r' or 'w'")
self.name = name
self.mode = mode
if mode == 'w':
self.fileobj = open(name, 'wb')
else:
self.fileobj = open(name, 'rb')
self.members = []
# Current offset of our index in the file. This gets updated as we add
# files to the MAR. This also refers to the end of the file until we've
# actually written the index
self.index_offset = 8
# Flag to indicate that we need to re-write the index
self.rewrite_index = False
# Signatures, if any
self.signatures = []
self.signature_versions = signature_versions
if mode == "r":
# Read the file's index
self._read_index()
elif mode == "w":
# Create placeholder signatures
if signature_versions:
# Space for num_signatures and file size
self.index_offset += 4 + 8
# Write the magic and placeholder for the index
self.fileobj.write("MAR1" + packint(self.index_offset))
# Write placeholder for file size
self.fileobj.write(struct.pack(">Q", 0))
# Write num_signatures
self.fileobj.write(packint(len(signature_versions)))
for algo_id, keyfile in signature_versions:
sig = MarSignature(algo_id, keyfile)
sig._offset = self.index_offset
self.index_offset += sig.size
self.signatures.append(sig)
# algoid
self.fileobj.write(packint(algo_id))
# numbytes
self.fileobj.write(packint(sig.sigsize))
# space for signature
self.fileobj.write("\0" * sig.sigsize)
def _read_index(self):
fp = self.fileobj
fp.seek(0)
# Read the header
header = fp.read(8)
magic, self.index_offset = struct.unpack(">4sL", header)
if magic != "MAR1":
raise ValueError("Bad magic")
fp.seek(self.index_offset)
# Read the index_size, we don't use it though
# We just read all the info sections from here to the end of the file
fp.read(4)
self.members = []
while True:
info = MarInfo.from_fileobj(fp)
if not info:
break
self.members.append(info)
# Sort them by where they are in the file
self.members.sort(key=lambda info: info._offset)
# Read any signatures
first_offset = self.members[0]._offset
signature_offset = 16
if signature_offset < first_offset:
fp.seek(signature_offset)
num_sigs = unpackint(fp.read(4))
for i in range(num_sigs):
sig = MarSignature.from_fileobj(fp)
for algo_id, keyfile in self.signature_versions:
if algo_id == sig.algo_id:
sig.keyfile = keyfile
break
else:
print "no key specified to validate %i signature" % sig.algo_id
self.signatures.append(sig)
def verify_signatures(self):
if not self.signatures:
return
fp = self.fileobj
generate_signature(fp, self._update_signatures)
for sig in self.signatures:
if not sig.verify_signature():
raise IOError("Verification failed")
else:
print "Verification OK (%s)" % sig.algo_id
def _update_signatures(self, data):
for sig in self.signatures:
sig.update(data)
def add(self, path, name=None, fileobj=None, flags=None):
"""Adds `path` to this MAR file.
If `name` is set, the file is named with `name` in the MAR, otherwise
use the normalized version of `path`.
If `fileobj` is set, file data is read from it, otherwise the file
located at `path` will be opened and read.
If `flags` is set, it will be used as the permission flags in the MAR,
otherwise the permissions of `path` will be used.
"""
if self.mode != "w":
raise ValueError("File not opened for writing")
# If path refers to a directory, add all the files inside of it
if os.path.isdir(path):
self.add_dir(path)
return
info = MarInfo()
if not fileobj:
info.name = name or os.path.normpath(path)
info.size = os.path.getsize(path)
info.flags = flags or os.stat(path).st_mode & 0777
info._offset = self.index_offset
f = open(path, 'rb')
self.fileobj.seek(self.index_offset)
while True:
block = f.read(512 * 1024)
if not block:
break
self.fileobj.write(block)
else:
assert flags
info.name = name or path
info.size = 0
info.flags = flags
info._offset = self.index_offset
self.fileobj.seek(self.index_offset)
while True:
block = fileobj.read(512 * 1024)
if not block:
break
info.size += len(block)
self.fileobj.write(block)
# Shift our index, and mark that we have to re-write it on close
self.index_offset += info.size
self.rewrite_index = True
self.members.append(info)
def add_dir(self, path):
"""Add all of the files under `path` to the MAR file"""
for root, dirs, files in os.walk(path):
for f in files:
self.add(os.path.join(root, f))
def close(self):
"""Close the MAR file, writing out the new index if required.
Furthur modifications to the file are not allowed."""
if self.mode == "w" and self.rewrite_index:
self._write_index()
# Update file size
self.fileobj.seek(0, 2)
totalsize = self.fileobj.tell()
self.fileobj.seek(8)
# print "File size is", totalsize, repr(struct.pack(">Q", totalsize))
self.fileobj.write(struct.pack(">Q", totalsize))
if self.mode == "w" and self.signatures:
self.fileobj.flush()
fileobj = open(self.name, 'rb')
generate_signature(fileobj, self._update_signatures)
for sig in self.signatures:
# print sig._offset
sig.write_signature(self.fileobj)
self.fileobj.close()
self.fileobj = None
def __del__(self):
"""Close the file when we're garbage collected"""
if self.fileobj:
self.close()
def _write_index(self):
"""Writes the index of all members at the end of the file"""
self.fileobj.seek(self.index_offset + 4)
index_size = 0
for m in self.members:
member_bytes = m.to_bytes()
index_size += len(member_bytes)
for m in self.members:
member_bytes = m.to_bytes()
self.fileobj.write(member_bytes)
self.fileobj.seek(self.index_offset)
self.fileobj.write(packint(index_size))
# Update the offset to the index
self.fileobj.seek(4)
self.fileobj.write(packint(self.index_offset))
def extractall(self, path=".", members=None):
"""Extracts members into `path`. If members is None (the default), then
all members are extracted."""
if members is None:
members = self.members
for m in members:
self.extract(m, path)
def extract(self, member, path="."):
"""Extract `member` into `path` which defaults to the current
directory."""
dstpath = os.path.join(path, member.name)
dirname = os.path.dirname(dstpath)
if not os.path.exists(dirname):
os.makedirs(dirname)
self.fileobj.seek(member._offset)
open(dstpath, "wb").write(self.fileobj.read(member.size))
os.chmod(dstpath, member.flags)
class BZ2MarFile(MarFile):
"""Subclass of MarFile that compresses/decompresses members using BZ2.
BZ2 compression is used for most update MARs."""
def extract(self, member, path="."):
"""Extract and decompress `member` into `path` which defaults to the
current directory."""
dstpath = os.path.join(path, member.name)
dirname = os.path.dirname(dstpath)
if not os.path.exists(dirname):
os.makedirs(dirname)
self.fileobj.seek(member._offset)
decomp = bz2.BZ2Decompressor()
output = open(dstpath, "wb")
toread = member.size
while True:
thisblock = min(128 * 1024, toread)
block = self.fileobj.read(thisblock)
if not block:
break
toread -= len(block)
output.write(decomp.decompress(block))
output.close()
os.chmod(dstpath, member.flags)
def add(self, path, name=None, fileobj=None, mode=None):
"""Adds `path` compressed with BZ2 to this MAR file.
If `name` is set, the file is named with `name` in the MAR, otherwise
use the normalized version of `path`.
If `fileobj` is set, file data is read from it, otherwise the file
located at `path` will be opened and read.
If `flags` is set, it will be used as the permission flags in the MAR,
otherwise the permissions of `path` will be used.
"""
if self.mode != "w":
raise ValueError("File not opened for writing")
if os.path.isdir(path):
self.add_dir(path)
return
info = MarInfo()
info.name = name or os.path.normpath(path)
info.size = 0
if not fileobj:
info.flags = os.stat(path).st_mode & 0777
else:
info.flags = mode
info._offset = self.index_offset
if not fileobj:
f = open(path, 'rb')
else:
f = fileobj
comp = bz2.BZ2Compressor(9)
self.fileobj.seek(self.index_offset)
while True:
block = f.read(512 * 1024)
if not block:
break
block = comp.compress(block)
info.size += len(block)
self.fileobj.write(block)
block = comp.flush()
info.size += len(block)
self.fileobj.write(block)
self.index_offset += info.size
self.rewrite_index = True
self.members.append(info)
if __name__ == "__main__":
from optparse import OptionParser
parser = OptionParser(__doc__)
parser.set_defaults(
action=None,
bz2=False,
chdir=None,
keyfile=None,
verify=False,
)
parser.add_option("-x", "--extract", action="store_const", const="extract",
dest="action", help="extract MAR")
parser.add_option("-t", "--list", action="store_const", const="list",
dest="action", help="print out MAR contents")
parser.add_option("-c", "--create", action="store_const", const="create",
dest="action", help="create MAR")
parser.add_option("-j", "--bzip2", action="store_true", dest="bz2",
help="compress/decompress members with BZ2")
parser.add_option("-k", "--keyfile", dest="keyfile",
help="sign/verify with given key")
parser.add_option("-v", "--verify", dest="verify", action="store_true",
help="verify the marfile")
parser.add_option("-C", "--chdir", dest="chdir",
help="chdir to this directory before creating or extracing; location of marfile isn't affected by this option.")
options, args = parser.parse_args()
if not options.action:
parser.error("Must specify something to do (one of -x, -t, -c)")
if not args:
parser.error("You must specify at least a marfile to work with")
marfile, files = args[0], args[1:]
marfile = os.path.abspath(marfile)
if options.bz2:
mar_class = BZ2MarFile
else:
mar_class = MarFile
signatures = []
if options.keyfile:
signatures.append((1, options.keyfile))
# Move into the directory requested
if options.chdir:
os.chdir(options.chdir)
if options.action == "extract":
m = mar_class(marfile)
m.extractall()
elif options.action == "list":
m = mar_class(marfile, signature_versions=signatures)
if options.verify:
m.verify_signatures()
print "%-7s %-7s %-7s" % ("SIZE", "MODE", "NAME")
for m in m.members:
print "%-7i %04o %s" % (m.size, m.flags, m.name)
elif options.action == "create":
if not files:
parser.error("Must specify at least one file to add to marfile")
m = mar_class(marfile, "w", signature_versions=signatures)
for f in files:
m.add(f)
m.close()