123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496 |
- #!/usr/bin/python
-
- # Gitian Downloader - download/update and verify a gitian distribution package
-
- # This library is free software; you can redistribute it and/or
- # modify it under the terms of the GNU Library General Public
- # License as published by the Free Software Foundation; either
- # version 2 of the License, or (at your option) any later version.
- #
- # This library 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
- # Library General Public License for more details.
- #
- # You should have received a copy of the GNU Library General Public
- # License along with this library; if not, write to the Free Software
- # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
-
- import sys, os, subprocess
- from os import path
- import shutil
- import re
- import tempfile
- import atexit
- import urllib2
- import argparse
- import yaml
- import time
- from hashlib import sha256
- from zipfile import ZipFile
- from distutils.version import LooseVersion
-
- """downloader config sample:
-
- ---
- name: foo
- waiting_period: 24
- urls:
- - url: https://foo.org/gitian/foo.zip
- version_url: https://foo.org/gitian/foo.ver
- rss:
- - url: https://foo.org/gitian/foo.rss
- xpath: //item/link/text()
- pattern: foo-(\d+.\d+.\d+)-linux.zip
- signers:
- 0A82509767C7D4A5D14DA2301AE1D35043E08E54:
- weight: 40
- name: BlueMatt
- key: bluematt
-
- """
-
- inject_config_string = "INJECT" + "CONFIG"
- injected_config = """INJECTCONFIG"""
- have_injected_config = injected_config != inject_config_string
-
- quiet = 0
-
- def check_name_and_version(out_manifest, old_manifest):
- if out_manifest['name'] != old_manifest['name']:
- print>>sys.stderr, "The old directory has a manifest for a different package"
- sys.exit(3)
- if LooseVersion(out_manifest['release']) < LooseVersion(old_manifest['release']):
- if quiet <= 1:
- print>>sys.stderr, "This is a downgrade from version %s to %s"%(old_manifest['release'],out_manifest['release'])
- if not args.force:
- print>>sys.stderr, "Use --force if you really want to downgrade"
- sys.exit(4)
- elif LooseVersion(out_manifest['release']) == LooseVersion(old_manifest['release']):
- if quiet <= 1:
- print>>sys.stderr, "This is a reinstall of version %s"%(old_manifest['release'])
- else:
- if quiet == 0:
- print>>sys.stderr, "Upgrading from version %s to %s"%(old_manifest['release'],out_manifest['release'])
-
- def copy_to_destination(from_path, to_path, out_manifest, old_manifest):
- for root, dirs, files in os.walk(from_path, topdown = True):
- rel = path.relpath(root, from_path)
- if not path.exists(path.join(to_path, rel)):
- os.mkdir(path.normpath(path.join(to_path, rel)))
- for f in files:
- shutil.copy2(path.join(root, f), path.join(to_path, rel, f))
-
- if old_manifest:
- removed = set(old_manifest['sums'].keys()).difference(out_manifest['sums'].keys())
- for f in removed:
- if path.exists(path.join(to_path, f)):
- os.unlink(path.join(to_path, f))
-
- f = file(path.join(to_path, '.gitian-manifest'), 'w')
- yaml.dump(out_manifest, f)
- f.close()
-
- def sha256sum(path):
- h = sha256()
- f = open(path)
- while True:
- buf = f.read(10240)
- if buf == "":
- break
- h.update(buf)
- return h.hexdigest()
-
- def sanitize_path(dir_name, name, where):
- if re.search(r'[^/\w.-]', name):
- raise ValueError, "unsanitary path in %s"%(where)
- full_name = path.normpath(path.join(dir_name, name))
- if full_name.find(dir_name + os.sep) != 0:
- raise ValueError, "unsafe path in %s"%(where)
-
- def remove_temp(tdir):
- shutil.rmtree(tdir)
-
- def download(url, dest):
- if quiet == 0:
- print "Downloading from %s"%(url)
- file_name = url.split('/')[-1]
- u = urllib2.urlopen(url)
- f = open(dest, 'wb')
- meta = u.info()
- file_size = int(meta.getheaders("Content-Length")[0])
- if quiet == 0:
- print "Downloading: %s Bytes: %s"%(file_name, file_size)
-
- file_size_dl = 0
- block_sz = 65536
- while True:
- buffer = u.read(block_sz)
- if not buffer:
- break
-
- file_size_dl += len(buffer)
- f.write(buffer)
- status = r"%10d [%3.2f%%]" % (file_size_dl, file_size_dl * 100. / file_size)
- status = status + chr(8)*(len(status)+1)
- if quiet == 0:
- print status,
-
- if quiet == 0:
- print
- f.close()
-
- def extract(dir_name, zip_path):
- zipfile = ZipFile(zip_path, 'r')
- files = []
- for name in zipfile.namelist():
- sanitize_path(dir_name, name, "zip file")
- zipfile.extractall(dir_name)
- zipfile.close()
-
- for name in zipfile.namelist():
- if path.isfile(path.join(dir_name, name)):
- files.append(path.normpath(name))
- return files
-
- def get_assertions(gpg_path, temp_dir, unpack_dir, file_names):
- assertions = {"build" : {}}
- sums = {}
- name = None
- release = None
- to_check = {}
- for file_name in file_names:
- sums[file_name] = sha256sum(os.path.join(unpack_dir, file_name))
- to_check[file_name] = 1
-
- out_manifest = False
- error = False
- optionals = None
-
- for file_name in file_names:
- if file_name.startswith("gitian"):
- del to_check[file_name]
- if file_name.endswith(".assert"):
- popen = subprocess.Popen([gpg_path, '--status-fd', '1', '--homedir', path.join(temp_dir, 'gpg'), '--verify', os.path.join(unpack_dir, file_name + '.sig'), os.path.join(unpack_dir, file_name)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- gpgout = popen.communicate()[0]
- retcode = popen.wait()
- if retcode != 0:
- if quiet <= 1:
- print>>sys.stderr, 'PGP verify failed for %s' %(file_name)
- error = True
- continue
- match = re.search(r'^\[GNUPG:\] VALIDSIG ([A-F0-9]+)', gpgout, re.M)
- assertions['build'][match.group(1)] = 1
- f = file(os.path.join(unpack_dir, file_name), 'r')
- assertion = yaml.load(f, OrderedDictYAMLLoader)
- f.close()
- if assertion['out_manifest']:
- if out_manifest:
- if out_manifest != assertion['out_manifest'] or release != assertion['release'] or name != assertion['name'] or optionals != assertion.get('optionals', []):
- print>>sys.stderr, 'not all out manifests/releases/names/optionals are identical'
- error = True
- continue
- else:
- out_manifest = assertion['out_manifest']
- release = assertion['release']
- name = assertion['name']
- optionals = assertion.get('optionals', [])
-
- if out_manifest:
- for line in out_manifest.split("\n"):
- if line != "":
- shasum = line[0:64]
- summed_file = line[66:]
- if not sums.has_key(summed_file):
- if not summed_file in optionals:
- print>>sys.stderr, "missing file %s" %(summed_file)
- error = True
- elif sums[summed_file] != shasum:
- print>>sys.stderr, "sha256sum mismatch on %s" %(summed_file)
- error = True
- else:
- del to_check[summed_file]
- if len(to_check) > 0 and quiet == 0:
- print>>sys.stderr, "Some of the files were not checksummed:"
- for key in to_check:
- print>>sys.stderr, " ", key
- else:
- print>>sys.stderr, 'No build assertions found'
-
- manifest = { 'sums' : sums, 'release' : release, 'name': name, 'optionals': optionals }
- return (not error, assertions, manifest)
-
- def import_keys(gpg_path, temp_dir, config):
- gpg_dir = path.join(temp_dir, 'gpg')
- os.mkdir(gpg_dir, 0700)
- signers = config['signers']
- for keyid in signers:
- key_path = path.join('gitian', signers[keyid]['key'] + '-key.pgp')
- popen = subprocess.Popen([gpg_path, '--status-fd', '1', '--homedir', gpg_dir, '--import', path.join(temp_dir, 'unpack', key_path)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- gpgout = popen.communicate(signers[keyid]['key'])[0]
- if popen.wait() != 0:
- print>>sys.stderr, 'Key %s failed to import'%(keyid)
- continue
- expected_keyid = keyid
- if signers[keyid].has_key('keyid'):
- expected_keyid = signers[keyid]['keyid']
- if gpgout.count(expected_keyid) == 0:
- print>>sys.stderr, 'Key file %s did not contain the key %s'%(key_path, keyid)
- if gpgout.count('IMPORT_OK') != 1 and quiet <= 1:
- print>>sys.stderr, 'Key file %s contained more than one key'%(key_path)
-
- def check_assertions(config, assertions):
- total_weight = 0
- signers = config['signers']
- if quiet == 0:
- print 'Signatures from:'
- for key in assertions['build']:
- if not signers.has_key(key):
- if quiet <= 1:
- print>>sys.stderr, 'key %s is not in config, skipping'%(key)
- continue
- if quiet == 0:
- print ' %s : weight %d'%(signers[key]['name'], signers[key]['weight'])
- total_weight += signers[key]['weight']
- if total_weight < config['minimum_weight']:
- print>>sys.stderr, "The total weight of signatures is %d, which is less than the minimum required %d"%(total_weight, config['minimum_weight'])
- return None
- return total_weight
-
-
- class OrderedDictYAMLLoader(yaml.Loader):
- """
- A YAML loader that loads ordered yaml maps into a dictionary.
- """
-
- def __init__(self, *args, **kwargs):
- yaml.Loader.__init__(self, *args, **kwargs)
-
- self.add_constructor(u'!omap', type(self).construct_yaml_map)
-
- def construct_yaml_map(self, node):
- data = dict()
- yield data
- for mapping in node.value:
- for key, value in mapping.value:
- key = self.construct_object(key)
- value = self.construct_object(value)
- data[key] = value
-
- def run():
- full_prog = sys.argv[0]
-
- prog = os.path.basename(full_prog)
-
- parser = argparse.ArgumentParser(description='Download a verify a gitian package')
- parser.add_argument('-u', '--url', metavar='URL', type=str, nargs='+', required=False,
- help='one or more URLs where the package can be found')
- parser.add_argument('-c', '--config', metavar='CONF', type=str, required=not have_injected_config,
- help='a configuration file')
- parser.add_argument('-d', '--dest', metavar='DEST', type=str, required=False,
- help='the destination directory for unpacking')
- parser.add_argument('-q', '--quiet', action='append_const', const=1, default=[], help='be quiet')
- parser.add_argument('-f', '--force', action='store_true', help='force downgrades and such')
- parser.add_argument('-n', '--dryrun', action='store_true', help='do not actually copy to destination')
- parser.add_argument('-m', '--customize', metavar='OUTPUT', type=str, help='generate a customized version of the script with the given config')
- parser.add_argument('-w', '--wait', type=float, metavar='HOURS', help='observe a waiting period or use zero for no waiting')
- parser.add_argument('-g', '--gpg', metavar='GPG', type=str, help='path to GnuPG')
- parser.add_argument('-p', '--post', metavar='COMMAND', type=str, help='Run after a successful install')
-
- args = parser.parse_args()
-
- quiet = len(args.quiet)
-
- if args.config:
- f = file(args.config, 'r')
- if args.customize:
- s = file(full_prog, 'r')
- script = s.read()
- s.close()
- config = f.read()
- script = script.replace(inject_config_string, config)
- s = file(args.customize, 'w')
- s.write(script)
- s.close()
- os.chmod(args.customize, 0750)
- sys.exit(0)
-
- config = yaml.safe_load(f)
- f.close()
- else:
- config = yaml.safe_load(injected_config)
-
- dest_path = args.dest
-
- if not dest_path:
- parser.error('argument -d/--dest is required unless -m is specified')
-
- if args.wait is not None:
- config['waiting_period'] = args.wait
-
-
- gpg_path = args.gpg
-
- if not gpg_path:
- gpg_path = 'gpg'
-
- rsses = []
-
- if args.url:
- urls = [{ 'url' : url, 'version_url' : None} for url in args.url]
- else:
- urls = config.get('urls')
- if not urls:
- parser.error('argument -u/--url is required since config does not specify it')
- if config.has_key('rss'):
- rsses = config['rss']
-
- # TODO: rss, atom, etc.
-
- old_manifest = None
-
- if path.exists(dest_path):
- files = os.listdir(dest_path)
- if path.dirname(full_prog) == dest_path:
- files.remove(prog)
-
- if not files.count('.gitian-manifest') and len(files) > 0:
- print>>sys.stderr, "destination already exists, no .gitian-manifest and directory not empty. Please empty destination."
- sys.exit(1)
- f = file(os.path.join(dest_path,'.gitian-manifest'), 'r')
- old_manifest = yaml.load(f, OrderedDictYAMLLoader)
- f.close()
-
- if config.get('waiting_period', 0) > 0:
- waiting_file = path.join(dest_path, '.gitian-waiting')
- if path.exists(waiting_file):
- f = file(waiting_file, 'r')
- waiting = yaml.load(f)
- f.close()
- wait_start = waiting['time']
- out_manifest = waiting['out_manifest']
- waiting_path = waiting['waiting_path']
- wait_time = wait_start + config['waiting_period'] * 3600 - time.time()
- if wait_time > 0:
- print>>sys.stderr, "Waiting another %.2f hours before applying update in %s"%(wait_time / 3600, waiting_path)
- sys.exit(100)
- os.remove(waiting_file)
- if args.dryrun:
- print>>sys.stderr, "Dry run, not copying"
- else:
- copy_to_destination(path.join(waiting_path, 'unpack'), dest_path, out_manifest, old_manifest)
- if args.post:
- os.system(args.post)
- if quiet == 0:
- print>>sys.stderr, "Copied from waiting area to destination"
- shutil.rmtree(waiting_path)
- sys.exit(0)
-
- temp_dir = tempfile.mkdtemp('', prog)
-
- atexit.register(remove_temp, temp_dir)
-
- package_file = path.join(temp_dir, 'package')
-
- downloaded = False
- checked = False
-
- if rsses:
- import libxml2
- for rss in rsses:
- try:
- feed = libxml2.parseDoc(urllib2.urlopen(rss['url']).read())
- url = None
- release = None
-
- # Find the first matching node
- for node in feed.xpathEval(rss['xpath']):
- m = re.search(rss['pattern'], str(node))
- if m:
- if len(m.groups()) > 0:
- release = m.group(1)
- url = str(node)
- break
-
- # Make sure it's a new release
- if old_manifest and release == old_manifest['release'] and not args.force:
- checked = True
- else:
- try:
- download(url, package_file)
- downloaded = True
- break
- except:
- print>>sys.stderr, "could not download from %s, trying next rss"%(url)
- pass
- except:
- print>>sys.stderr, "could read not from rss %s"%(rss)
- pass
-
- if not downloaded:
- for url in urls:
- try:
- release = None
- if url['version_url']:
- f = urllib2.urlopen(url['version_url'])
- release = f.read(100).strip()
- f.close()
- if old_manifest and release == old_manifest['release'] and not args.force:
- checked = True
- else:
- download(url['url'], package_file)
- downloaded = True
- except:
- print>>sys.stderr, "could not download from %s, trying next url"%(url)
- raise
-
- if not downloaded:
- if checked:
- if quiet == 0:
- print>>sys.stderr, "same release, not downloading"
- else:
- print>>sys.stderr, "out of places to try downloading from, try later"
- sys.exit(2)
-
- unpack_dir = path.join(temp_dir, 'unpack')
- files = extract(unpack_dir, package_file)
-
- import_keys(gpg_path, temp_dir, config)
-
- (success, assertions, out_manifest) = get_assertions(gpg_path, temp_dir, unpack_dir, files)
-
- if old_manifest:
- check_name_and_version(out_manifest, old_manifest)
-
- if not success and quiet <= 1:
- print>>sys.stderr, "There were errors getting assertions"
-
- total_weight = check_assertions(config, assertions)
- if total_weight is None:
- print>>sys.stderr, "There were errors checking assertions, build is untrusted, aborting"
- sys.exit(5)
-
- if quiet == 0:
- print>>sys.stderr, "Successful with signature weight %d"%(total_weight)
-
- if config.get('waiting_period', 0) > 0 and path.exists(dest_path):
- waiting_path = tempfile.mkdtemp('', prog)
- shutil.copytree(unpack_dir, path.join(waiting_path, 'unpack'))
- f = file(path.join(dest_path, '.gitian-waiting'), 'w')
- yaml.dump({'time': time.time(), 'out_manifest': out_manifest, 'waiting_path': waiting_path}, f)
- f.close()
- if quiet == 0:
- print>>sys.stderr, "Started waiting period"
- else:
- if args.dryrun:
- print>>sys.stderr, "Dry run, not copying"
- else:
- copy_to_destination(unpack_dir, dest_path, out_manifest, old_manifest)
-
-
- if args.post:
- os.system(args.post)
-
-
- if __name__ == '__main__':
- run()
|