Only process each <package-implementation> once, even if several distributions match...
[zeroinstall/solver.git] / zeroinstall / injector / fetch.py
blob4586f7701791e1dd324c2d78de1a9c84106f5f97
1 """
2 Downloads feeds, keys, packages and icons.
3 """
5 # Copyright (C) 2009, Thomas Leonard
6 # See the README file for details, or visit http://0install.net.
8 from zeroinstall import _, NeedDownload
9 import os
10 from logging import info, debug, warn
12 from zeroinstall import support
13 from zeroinstall.support import tasks, basedir, portable_rename
14 from zeroinstall.injector.namespaces import XMLNS_IFACE, config_site
15 from zeroinstall.injector import model
16 from zeroinstall.injector.model import DownloadSource, Recipe, SafeException, escape, DistributionSource
17 from zeroinstall.injector.iface_cache import PendingFeed, ReplayAttack
18 from zeroinstall.injector.handler import NoTrustedKeys
19 from zeroinstall.injector import download
21 def _escape_slashes(path):
22 return path.replace('/', '%23')
24 def _get_feed_dir(feed):
25 """The algorithm from 0mirror."""
26 if '#' in feed:
27 raise SafeException(_("Invalid URL '%s'") % feed)
28 scheme, rest = feed.split('://', 1)
29 assert '/' in rest, "Missing / in %s" % feed
30 domain, rest = rest.split('/', 1)
31 for x in [scheme, domain, rest]:
32 if not x or x.startswith('.'):
33 raise SafeException(_("Invalid URL '%s'") % feed)
34 return '/'.join(['feeds', scheme, domain, _escape_slashes(rest)])
36 class KeyInfoFetcher:
37 """Fetches information about a GPG key from a key-info server.
38 See L{Fetcher.fetch_key_info} for details.
39 @since: 0.42
41 Example:
43 >>> kf = KeyInfoFetcher(fetcher, 'https://server', fingerprint)
44 >>> while True:
45 print kf.info
46 if kf.blocker is None: break
47 print kf.status
48 yield kf.blocker
49 """
50 def __init__(self, fetcher, server, fingerprint):
51 self.fingerprint = fingerprint
52 self.info = []
53 self.blocker = None
55 if server is None: return
57 self.status = _('Fetching key information from %s...') % server
59 dl = fetcher.download_url(server + '/key/' + fingerprint)
61 from xml.dom import minidom
63 @tasks.async
64 def fetch_key_info():
65 try:
66 tempfile = dl.tempfile
67 yield dl.downloaded
68 self.blocker = None
69 tasks.check(dl.downloaded)
70 tempfile.seek(0)
71 doc = minidom.parse(tempfile)
72 if doc.documentElement.localName != 'key-lookup':
73 raise SafeException(_('Expected <key-lookup>, not <%s>') % doc.documentElement.localName)
74 self.info += doc.documentElement.childNodes
75 except Exception as ex:
76 doc = minidom.parseString('<item vote="bad"/>')
77 root = doc.documentElement
78 root.appendChild(doc.createTextNode(_('Error getting key information: %s') % ex))
79 self.info.append(root)
81 self.blocker = fetch_key_info()
83 class Fetcher(object):
84 """Downloads and stores various things.
85 @ivar config: used to get handler, iface_cache and stores
86 @type config: L{config.Config}
87 @ivar key_info: caches information about GPG keys
88 @type key_info: {str: L{KeyInfoFetcher}}
89 """
90 __slots__ = ['config', 'key_info', '_scheduler', 'external_store']
92 def __init__(self, config):
93 assert config.handler, "API change!"
94 self.config = config
95 self.key_info = {}
96 self._scheduler = None
97 self.external_store = os.environ.get('ZEROINSTALL_EXTERNAL_STORE')
99 @property
100 def handler(self):
101 return self.config.handler
103 @property
104 def scheduler(self):
105 if self._scheduler is None:
106 from . import scheduler
107 self._scheduler = scheduler.DownloadScheduler()
108 return self._scheduler
110 # (force is deprecated and ignored)
111 @tasks.async
112 def cook(self, required_digest, recipe, stores, force = False, impl_hint = None):
113 """Follow a Recipe.
114 @param impl_hint: the Implementation this is for (if any) as a hint for the GUI
115 @see: L{download_impl} uses this method when appropriate"""
116 # Maybe we're taking this metaphor too far?
118 # Start a download for each ingredient
119 blockers = []
120 steps = []
121 for stepdata in recipe.steps:
122 cls = StepRunner.class_for(stepdata)
123 step = cls(stepdata, impl_hint=impl_hint)
124 step.prepare(self, blockers)
125 steps.append(step)
127 while blockers:
128 yield blockers
129 tasks.check(blockers)
130 blockers = [b for b in blockers if not b.happened]
133 if self.external_store:
134 # Note: external_store will not yet work with non-<archive> steps.
135 streams = [step.stream for step in steps]
136 self._add_to_external_store(required_digest, recipe.steps, streams)
137 else:
138 # Create an empty directory for the new implementation
139 store = stores.stores[0]
140 tmpdir = store.get_tmp_dir_for(required_digest)
141 try:
142 # Unpack each of the downloaded archives into it in turn
143 for step in steps:
144 step.apply(tmpdir)
145 # Check that the result is correct and store it in the cache
146 store.check_manifest_and_rename(required_digest, tmpdir)
147 tmpdir = None
148 finally:
149 # If unpacking fails, remove the temporary directory
150 if tmpdir is not None:
151 support.ro_rmtree(tmpdir)
153 def get_feed_mirror(self, url):
154 """Return the URL of a mirror for this feed."""
155 if self.config.feed_mirror is None:
156 return None
157 import urlparse
158 if urlparse.urlparse(url).hostname == 'localhost':
159 return None
160 return '%s/%s/latest.xml' % (self.config.feed_mirror, _get_feed_dir(url))
162 @tasks.async
163 def get_packagekit_feed(self, feed_url):
164 """Send a query to PackageKit (if available) for information about this package.
165 On success, the result is added to iface_cache.
167 assert feed_url.startswith('distribution:'), feed_url
168 master_feed = self.config.iface_cache.get_feed(feed_url.split(':', 1)[1])
169 if master_feed:
170 fetch = self.config.iface_cache.distro.fetch_candidates(master_feed)
171 if fetch:
172 yield fetch
173 tasks.check(fetch)
175 # Force feed to be regenerated with the new information
176 self.config.iface_cache.get_feed(feed_url, force = True)
178 def download_and_import_feed(self, feed_url, iface_cache = None):
179 """Download the feed, download any required keys, confirm trust if needed and import.
180 @param feed_url: the feed to be downloaded
181 @type feed_url: str
182 @param iface_cache: (deprecated)"""
183 from .download import DownloadAborted
185 assert iface_cache is None or iface_cache is self.config.iface_cache
187 self.config.iface_cache.mark_as_checking(feed_url)
189 debug(_("download_and_import_feed %(url)s"), {'url': feed_url})
190 assert not os.path.isabs(feed_url)
192 if feed_url.startswith('distribution:'):
193 return self.get_packagekit_feed(feed_url)
195 primary = self._download_and_import_feed(feed_url, use_mirror = False)
197 @tasks.named_async("monitor feed downloads for " + feed_url)
198 def wait_for_downloads(primary):
199 # Download just the upstream feed, unless it takes too long...
200 timeout = tasks.TimeoutBlocker(5, 'Mirror timeout') # 5 seconds
202 yield primary, timeout
203 tasks.check(timeout)
205 try:
206 tasks.check(primary)
207 if primary.happened:
208 return # OK, primary succeeded!
209 # OK, maybe it's just being slow...
210 info("Feed download from %s is taking a long time.", feed_url)
211 primary_ex = None
212 except NoTrustedKeys as ex:
213 raise # Don't bother trying the mirror if we have a trust problem
214 except ReplayAttack as ex:
215 raise # Don't bother trying the mirror if we have a replay attack
216 except DownloadAborted as ex:
217 raise # Don't bother trying the mirror if the user cancelled
218 except SafeException as ex:
219 # Primary failed
220 primary = None
221 primary_ex = ex
222 warn(_("Feed download from %(url)s failed: %(exception)s"), {'url': feed_url, 'exception': ex})
224 # Start downloading from mirror...
225 mirror = self._download_and_import_feed(feed_url, use_mirror = True)
227 # Wait until both mirror and primary tasks are complete...
228 while True:
229 blockers = filter(None, [primary, mirror])
230 if not blockers:
231 break
232 yield blockers
234 if primary:
235 try:
236 tasks.check(primary)
237 if primary.happened:
238 primary = None
239 # No point carrying on with the mirror once the primary has succeeded
240 if mirror:
241 info(_("Primary feed download succeeded; aborting mirror download for %s") % feed_url)
242 mirror.dl.abort()
243 except SafeException as ex:
244 primary = None
245 primary_ex = ex
246 info(_("Feed download from %(url)s failed; still trying mirror: %(exception)s"), {'url': feed_url, 'exception': ex})
248 if mirror:
249 try:
250 tasks.check(mirror)
251 if mirror.happened:
252 mirror = None
253 if primary_ex:
254 # We already warned; no need to raise an exception too,
255 # as the mirror download succeeded.
256 primary_ex = None
257 except ReplayAttack as ex:
258 info(_("Version from mirror is older than cached version; ignoring it: %s"), ex)
259 mirror = None
260 primary_ex = None
261 except SafeException as ex:
262 info(_("Mirror download failed: %s"), ex)
263 mirror = None
265 if primary_ex:
266 raise primary_ex
268 return wait_for_downloads(primary)
270 def _download_and_import_feed(self, feed_url, use_mirror):
271 """Download and import a feed.
272 @param use_mirror: False to use primary location; True to use mirror."""
273 if use_mirror:
274 url = self.get_feed_mirror(feed_url)
275 if url is None: return None
276 info(_("Trying mirror server for feed %s") % feed_url)
277 else:
278 url = feed_url
280 dl = self.download_url(url, hint = feed_url)
281 stream = dl.tempfile
283 @tasks.named_async("fetch_feed " + url)
284 def fetch_feed():
285 yield dl.downloaded
286 tasks.check(dl.downloaded)
288 pending = PendingFeed(feed_url, stream)
290 if use_mirror:
291 # If we got the feed from a mirror, get the key from there too
292 key_mirror = self.config.feed_mirror + '/keys/'
293 else:
294 key_mirror = None
296 keys_downloaded = tasks.Task(pending.download_keys(self, feed_hint = feed_url, key_mirror = key_mirror), _("download keys for %s") % feed_url)
297 yield keys_downloaded.finished
298 tasks.check(keys_downloaded.finished)
300 if not self.config.iface_cache.update_feed_if_trusted(pending.url, pending.sigs, pending.new_xml):
301 blocker = self.config.trust_mgr.confirm_keys(pending)
302 if blocker:
303 yield blocker
304 tasks.check(blocker)
305 if not self.config.iface_cache.update_feed_if_trusted(pending.url, pending.sigs, pending.new_xml):
306 raise NoTrustedKeys(_("No signing keys trusted; not importing"))
308 task = fetch_feed()
309 task.dl = dl
310 return task
312 def fetch_key_info(self, fingerprint):
313 try:
314 return self.key_info[fingerprint]
315 except KeyError:
316 self.key_info[fingerprint] = key_info = KeyInfoFetcher(self,
317 self.config.key_info_server, fingerprint)
318 return key_info
320 # (force is deprecated and ignored)
321 def download_impl(self, impl, retrieval_method, stores, force = False):
322 """Download an implementation.
323 @param impl: the selected implementation
324 @type impl: L{model.ZeroInstallImplementation}
325 @param retrieval_method: a way of getting the implementation (e.g. an Archive or a Recipe)
326 @type retrieval_method: L{model.RetrievalMethod}
327 @param stores: where to store the downloaded implementation
328 @type stores: L{zerostore.Stores}
329 @rtype: L{tasks.Blocker}"""
330 assert impl
331 assert retrieval_method
333 if isinstance(retrieval_method, DistributionSource):
334 return retrieval_method.install(self.handler)
336 from zeroinstall.zerostore import manifest
337 best = None
338 for digest in impl.digests:
339 alg_name = digest.split('=', 1)[0]
340 alg = manifest.algorithms.get(alg_name, None)
341 if alg and (best is None or best.rating < alg.rating):
342 best = alg
343 required_digest = digest
345 if best is None:
346 if not impl.digests:
347 raise SafeException(_("No <manifest-digest> given for '%(implementation)s' version %(version)s") %
348 {'implementation': impl.feed.get_name(), 'version': impl.get_version()})
349 raise SafeException(_("Unknown digest algorithms '%(algorithms)s' for '%(implementation)s' version %(version)s") %
350 {'algorithms': impl.digests, 'implementation': impl.feed.get_name(), 'version': impl.get_version()})
352 @tasks.async
353 def download_impl():
354 if isinstance(retrieval_method, DownloadSource):
355 blocker, stream = self.download_archive(retrieval_method, impl_hint = impl)
356 yield blocker
357 tasks.check(blocker)
359 stream.seek(0)
360 if self.external_store:
361 self._add_to_external_store(required_digest, [retrieval_method], [stream])
362 else:
363 self._add_to_cache(required_digest, stores, retrieval_method, stream)
364 elif isinstance(retrieval_method, Recipe):
365 blocker = self.cook(required_digest, retrieval_method, stores, impl_hint = impl)
366 yield blocker
367 tasks.check(blocker)
368 else:
369 raise Exception(_("Unknown download type for '%s'") % retrieval_method)
371 self.handler.impl_added_to_store(impl)
372 return download_impl()
374 def _add_to_cache(self, required_digest, stores, retrieval_method, stream):
375 assert isinstance(retrieval_method, DownloadSource)
376 stores.add_archive_to_cache(required_digest, stream, retrieval_method.url, retrieval_method.extract,
377 type = retrieval_method.type, start_offset = retrieval_method.start_offset or 0)
379 def _add_to_external_store(self, required_digest, steps, streams):
380 from zeroinstall.zerostore.unpack import type_from_url
382 # combine archive path, extract directory and MIME type arguments in an alternating fashion
383 paths = map(lambda stream: stream.name, streams)
384 extracts = map(lambda step: step.extract or "", steps)
385 types = map(lambda step: step.type or type_from_url(step.url), steps)
386 args = [None]*(len(paths)+len(extracts)+len(types))
387 args[::3] = paths
388 args[1::3] = extracts
389 args[2::3] = types
391 # close file handles to allow external processes access
392 for stream in streams:
393 stream.close()
395 # delegate extracting archives to external tool
396 import subprocess
397 subprocess.call([self.external_store, "add", required_digest] + args)
399 # delete temp files
400 for path in paths:
401 os.remove(path)
403 # (force is deprecated and ignored)
404 def download_archive(self, download_source, force = False, impl_hint = None):
405 """Fetch an archive. You should normally call L{download_impl}
406 instead, since it handles other kinds of retrieval method too."""
407 from zeroinstall.zerostore import unpack
409 url = download_source.url
410 if not (url.startswith('http:') or url.startswith('https:') or url.startswith('ftp:')):
411 raise SafeException(_("Unknown scheme in download URL '%s'") % url)
413 mime_type = download_source.type
414 if not mime_type:
415 mime_type = unpack.type_from_url(download_source.url)
416 if not mime_type:
417 raise SafeException(_("No 'type' attribute on archive, and I can't guess from the name (%s)") % download_source.url)
418 if not self.external_store:
419 unpack.check_type_ok(mime_type)
420 dl = self.download_url(download_source.url, hint = impl_hint)
421 dl.expected_size = download_source.size + (download_source.start_offset or 0)
422 return (dl.downloaded, dl.tempfile)
424 # (force is deprecated and ignored)
425 def download_icon(self, interface, force = False):
426 """Download an icon for this interface and add it to the
427 icon cache. If the interface has no icon do nothing.
428 @return: the task doing the import, or None
429 @rtype: L{tasks.Task}"""
430 debug("download_icon %(interface)s", {'interface': interface})
432 modification_time = None
433 existing_icon = self.config.iface_cache.get_icon_path(interface)
434 if existing_icon:
435 file_mtime = os.stat(existing_icon).st_mtime
436 from email.utils import formatdate
437 modification_time = formatdate(timeval = file_mtime, localtime = False, usegmt = True)
439 # Find a suitable icon to download
440 for icon in interface.get_metadata(XMLNS_IFACE, 'icon'):
441 type = icon.getAttribute('type')
442 if type != 'image/png':
443 debug(_('Skipping non-PNG icon'))
444 continue
445 source = icon.getAttribute('href')
446 if source:
447 break
448 warn(_('Missing "href" attribute on <icon> in %s'), interface)
449 else:
450 info(_('No PNG icons found in %s'), interface)
451 return
453 dl = self.download_url(source, hint = interface, modification_time = modification_time)
455 @tasks.async
456 def download_and_add_icon():
457 stream = dl.tempfile
458 yield dl.downloaded
459 try:
460 tasks.check(dl.downloaded)
461 if dl.unmodified: return
462 stream.seek(0)
464 import shutil, tempfile
465 icons_cache = basedir.save_cache_path(config_site, 'interface_icons')
467 tmp_file = tempfile.NamedTemporaryFile(dir = icons_cache, delete = False)
468 shutil.copyfileobj(stream, tmp_file)
469 tmp_file.close()
471 icon_file = os.path.join(icons_cache, escape(interface.uri))
472 portable_rename(tmp_file.name, icon_file)
473 except Exception as ex:
474 self.handler.report_error(ex)
475 finally:
476 stream.close()
478 return download_and_add_icon()
480 def download_impls(self, implementations, stores):
481 """Download the given implementations, choosing a suitable retrieval method for each.
482 If any of the retrieval methods are DistributionSources and
483 need confirmation, handler.confirm is called to check that the
484 installation should proceed.
486 unsafe_impls = []
488 to_download = []
489 for impl in implementations:
490 debug(_("start_downloading_impls: for %(feed)s get %(implementation)s"), {'feed': impl.feed, 'implementation': impl})
491 source = self.get_best_source(impl)
492 if not source:
493 raise SafeException(_("Implementation %(implementation_id)s of interface %(interface)s"
494 " cannot be downloaded (no download locations given in "
495 "interface!)") % {'implementation_id': impl.id, 'interface': impl.feed.get_name()})
496 to_download.append((impl, source))
498 if isinstance(source, DistributionSource) and source.needs_confirmation:
499 unsafe_impls.append(source.package_id)
501 @tasks.async
502 def download_impls():
503 if unsafe_impls:
504 confirm = self.handler.confirm_install(_('The following components need to be installed using native packages. '
505 'These come from your distribution, and should therefore be trustworthy, but they also '
506 'run with extra privileges. In particular, installing them may run extra services on your '
507 'computer or affect other users. You may be asked to enter a password to confirm. The '
508 'packages are:\n\n') + ('\n'.join('- ' + x for x in unsafe_impls)))
509 yield confirm
510 tasks.check(confirm)
512 blockers = []
514 for impl, source in to_download:
515 blockers.append(self.download_impl(impl, source, stores))
517 # Record the first error log the rest
518 error = []
519 def dl_error(ex, tb = None):
520 if error:
521 self.handler.report_error(ex)
522 else:
523 error.append((ex, tb))
524 while blockers:
525 yield blockers
526 tasks.check(blockers, dl_error)
528 blockers = [b for b in blockers if not b.happened]
529 if error:
530 from zeroinstall import support
531 support.raise_with_traceback(*error[0])
533 if not to_download:
534 return None
536 return download_impls()
538 def get_best_source(self, impl):
539 """Return the best download source for this implementation.
540 @rtype: L{model.RetrievalMethod}"""
541 if impl.download_sources:
542 return impl.download_sources[0]
543 return None
545 def download_url(self, url, hint = None, modification_time = None, expected_size = None):
546 """The most low-level method here; just download a raw URL.
547 @param url: the location to download from
548 @param hint: user-defined data to store on the Download (e.g. used by the GUI)
549 @param modification_time: don't download unless newer than this
550 @rtype: L{download.Download}
551 @since: 1.5
553 if self.handler.dry_run:
554 raise NeedDownload(url)
556 dl = download.Download(url, hint = hint, modification_time = modification_time, expected_size = expected_size, auto_delete = not self.external_store)
557 self.handler.monitor_download(dl)
558 dl.downloaded = self.scheduler.download(dl)
559 return dl
561 class StepRunner(object):
562 """The base class of all step runners"""
563 def __init__(self, stepdata, impl_hint):
564 self.stepdata = stepdata
565 self.impl_hint = impl_hint
567 def prepare(self, fetcher, blockers):
568 pass
570 @classmethod
571 def class_for(cls, model):
572 for subcls in cls.__subclasses__():
573 if subcls.model_type == type(model):
574 return subcls
575 assert False, "Couldn't find step runner for %s" % (type(model),)
577 class RenameStepRunner(StepRunner):
578 """A step runner for the <rename> step"""
580 model_type = model.RenameStep
582 def apply(self, basedir):
583 source = native_path_within_base(basedir, self.stepdata.source)
584 dest = native_path_within_base(basedir, self.stepdata.dest)
585 os.rename(source, dest)
587 class DownloadStepRunner(StepRunner):
588 """A step runner for the <archive> step"""
590 model_type = model.DownloadSource
592 def prepare(self, fetcher, blockers):
593 self.blocker, self.stream = fetcher.download_archive(self.stepdata, impl_hint = self.impl_hint)
594 assert self.stream
595 blockers.append(self.blocker)
597 def apply(self, basedir):
598 from zeroinstall.zerostore import unpack
599 assert self.blocker.happened
600 unpack.unpack_archive_over(self.stepdata.url, self.stream, basedir,
601 extract = self.stepdata.extract,
602 type=self.stepdata.type,
603 start_offset = self.stepdata.start_offset or 0)
605 def native_path_within_base(base, crossplatform_path):
606 """Takes a cross-platform relative path (i.e using forward slashes, even on windows)
607 and returns the absolute, platform-native version of the path.
608 If the path does not resolve to a location within `base`, a SafeError is raised.
610 assert os.path.isabs(base)
611 if crossplatform_path.startswith("/"):
612 raise SafeException("path %r is not within the base directory" % (crossplatform_path,))
613 native_path = os.path.join(*crossplatform_path.split("/"))
614 fullpath = os.path.realpath(os.path.join(base, native_path))
615 base = os.path.realpath(base)
616 if not fullpath.startswith(base + os.path.sep):
617 raise SafeException("path %r is not within the base directory" % (crossplatform_path,))
618 return fullpath