Tighten security when following recipes.
[zeroinstall.git] / zeroinstall / injector / policy.py
blobd2d1d77fc976d488862b8febee1a38885a2ff030
1 """
2 Chooses a set of implementations based on a policy.
3 """
5 # Copyright (C) 2007, Thomas Leonard
6 # See the README file for details, or visit http://0install.net.
8 import time
9 import sys, os, sets
10 from logging import info, debug, warn
11 import arch
13 from model import *
14 import basedir
15 from namespaces import *
16 import ConfigParser
17 import reader
18 from zeroinstall import NeedDownload
19 from zeroinstall.injector.iface_cache import iface_cache, PendingFeed
20 from zeroinstall.injector.trust import trust_db
22 # If we started a check within this period, don't start another one:
23 FAILED_CHECK_DELAY = 60 * 60 # 1 Hour
25 class _Cook:
26 """A Cook follows a Recipe."""
27 # Maybe we're taking this metaphor too far?
29 def __init__(self, policy, required_digest, recipe, force = False):
30 """Start downloading all the ingredients."""
31 self.recipe = recipe
32 self.required_digest = required_digest
33 self.downloads = {} # Downloads that are not yet successful
34 self.streams = {} # Streams collected from successful downloads
36 # Start a download for each ingredient
37 for step in recipe.steps:
38 dl = policy.begin_archive_download(step, success_callback =
39 lambda stream, step=step: self.ingredient_ready(step, stream),
40 force = force)
41 self.downloads[step] = dl
42 self.test_done() # Needed for empty recipes
44 # Note: the only references to us are held by the on_success callback
45 # in each Download. On error this is removed, which will cause us
46 # to be destoryed, which will release all the temporary files we hold.
48 def ingredient_ready(self, step, stream):
49 # Called when one archive has been fetched. Store it until the other
50 # archives arrive.
51 assert step not in self.streams
52 self.streams[step] = stream
53 del self.downloads[step]
54 self.test_done()
56 def test_done(self):
57 # On success, a download is removed from here. If empty, it means that
58 # all archives have successfully been downloaded.
59 if self.downloads: return
61 from zeroinstall.zerostore import unpack
63 # Create an empty directory for the new implementation
64 store = iface_cache.stores.stores[0]
65 tmpdir = store.get_tmp_dir_for(self.required_digest)
66 try:
67 # Unpack each of the downloaded archives into it in turn
68 for step in self.recipe.steps:
69 unpack.unpack_archive_over(step.url, self.streams[step], tmpdir, step.extract)
70 # Check that the result is correct and store it in the cache
71 store.check_manifest_and_rename(self.required_digest, tmpdir)
72 tmpdir = None
73 finally:
74 # If unpacking fails, remove the temporary directory
75 if tmpdir is not None:
76 from zeroinstall import support
77 support.ro_rmtree(tmpdir)
79 class Policy(object):
80 """Chooses a set of implementations based on a policy.
81 Typical use:
82 1. Create a Policy object, giving it the URI of the program to be run and a handler.
83 2. Call L{recalculate}. If more information is needed, the handler will be used to download it.
84 3. When all downloads are complete, the L{implementation} map contains the chosen versions.
85 4. Use L{get_uncached_implementations} to find where to get these versions and download them
86 using L{begin_impl_download}.
88 @ivar root: URI of the root interface
89 @ivar implementation: chosen implementations
90 @type implementation: {model.Interface: model.Implementation or None}
91 @ivar watchers: callbacks to invoke after recalculating
92 @ivar help_with_testing: default stability policy
93 @type help_with_testing: bool
94 @ivar network_use: one of the model.network_* values
95 @ivar freshness: seconds allowed since last update
96 @type freshness: int
97 @ivar ready: whether L{implementation} is complete enough to run the program
98 @type ready: bool
99 @ivar handler: handler for main-loop integration
100 @type handler: L{handler.Handler}
101 @ivar restrictions: Currently known restrictions for each interface.
102 @type restrictions: {model.Interface -> [model.Restriction]}
103 @ivar src: whether we are looking for source code
104 @type src: bool
105 @ivar stale_feeds: set of feeds which are present but haven't been checked for a long time
106 @type stale_feeds: set
108 __slots__ = ['root', 'implementation', 'watchers',
109 'help_with_testing', 'network_use',
110 'freshness', 'ready', 'handler', '_warned_offline',
111 'restrictions', 'src', 'root_restrictions', 'stale_feeds']
113 def __init__(self, root, handler = None, src = False):
115 @param root: The URI of the root interface (the program we want to run).
116 @param handler: A handler for main-loop integration.
117 @type handler: L{zeroinstall.injector.handler.Handler}
118 @param src: Whether we are looking for source code.
119 @type src: bool
121 self.watchers = []
122 self.help_with_testing = False
123 self.network_use = network_full
124 self.freshness = 60 * 60 * 24 * 30
125 self.ready = False
126 self.src = src # Root impl must be a "src" machine type
127 self.restrictions = {}
128 self.stale_feeds = sets.Set()
130 # This is used in is_unusable() to check whether the impl is
131 # for the root interface when looking for source. It is also
132 # used to add restrictions to the root (e.g. --before and --not-before)
133 self.root_restrictions = []
135 # If we need to download something but can't because we are offline,
136 # warn the user. But only the first time.
137 self._warned_offline = False
139 # (allow self for backwards compat)
140 self.handler = handler or self
142 debug("Supported systems: '%s'", arch.os_ranks)
143 debug("Supported processors: '%s'", arch.machine_ranks)
145 path = basedir.load_first_config(config_site, config_prog, 'global')
146 if path:
147 try:
148 config = ConfigParser.ConfigParser()
149 config.read(path)
150 self.help_with_testing = config.getboolean('global',
151 'help_with_testing')
152 self.network_use = config.get('global', 'network_use')
153 self.freshness = int(config.get('global', 'freshness'))
154 assert self.network_use in network_levels
155 except Exception, ex:
156 warn("Error loading config: %s", ex)
158 self.set_root(root)
160 # Probably need weakrefs here...
161 iface_cache.add_watcher(self)
162 trust_db.watchers.append(self.process_pending)
164 def set_root(self, root):
165 """Change the root interface URI."""
166 assert isinstance(root, (str, unicode))
167 self.root = root
168 self.implementation = {} # Interface -> [Implementation | None]
170 def save_config(self):
171 """Write global settings."""
172 config = ConfigParser.ConfigParser()
173 config.add_section('global')
175 config.set('global', 'help_with_testing', self.help_with_testing)
176 config.set('global', 'network_use', self.network_use)
177 config.set('global', 'freshness', self.freshness)
179 path = basedir.save_config_path(config_site, config_prog)
180 path = os.path.join(path, 'global')
181 config.write(file(path + '.new', 'w'))
182 os.rename(path + '.new', path)
184 def process_pending(self):
185 """For each pending feed, either import it fully (if we now
186 trust one of the signatures) or start performing whatever action
187 is needed next (either downloading a key or confirming a
188 fingerprint).
189 @since: 0.25
191 # process_pending must never be called from recalculate
193 for pending in iface_cache.pending.values():
194 pending.begin_key_downloads(self.handler, lambda pending = pending: self._keys_ready(pending))
196 def _keys_ready(self, pending):
197 try:
198 iface = iface_cache.get_interface(pending.url)
199 # Note: this may call recalculate, but it shouldn't do any harm
200 # (just a bit slow)
201 updated = iface_cache.update_interface_if_trusted(iface, pending.sigs, pending.new_xml)
202 except SafeException, ex:
203 self.handler.report_error(ex)
204 # Ignore the problematic new version and continue...
205 else:
206 if not updated:
207 self.handler.confirm_trust_keys(iface, pending.sigs, pending.new_xml)
209 def recalculate(self, fetch_stale_interfaces = True):
210 """Try to choose a set of implementations.
211 This may start downloading more interfaces, but will return immediately.
212 @param fetch_stale_interfaces: whether to begin downloading interfaces which are present but haven't
213 been checked within the L{freshness} period
214 @type fetch_stale_interfaces: bool
215 @postcondition: L{ready} indicates whether a possible set of implementations was chosen
216 @note: A policy may be ready before all feeds have been downloaded. As new feeds
217 arrive, the chosen versions may change.
220 self.stale_feeds = sets.Set()
221 self.restrictions = {}
222 self.implementation = {}
223 self.ready = True
224 debug("Recalculate! root = %s", self.root)
225 def process(dep):
226 iface = self.get_interface(dep.interface)
227 if iface in self.implementation:
228 debug("Interface requested twice; skipping second %s", iface)
229 if dep.restrictions:
230 warn("Interface requested twice; I've already chosen an implementation "
231 "of '%s' but there are more restrictions! Ignoring the second set.", iface)
232 return
233 self.implementation[iface] = None # Avoid cycles
235 assert iface not in self.restrictions
236 self.restrictions[iface] = dep.restrictions
238 impl = self._get_best_implementation(iface)
239 if impl:
240 debug("Will use implementation %s (version %s)", impl, impl.get_version())
241 self.implementation[iface] = impl
242 for d in impl.requires:
243 debug("Considering dependency %s", d)
244 process(d)
245 else:
246 debug("No implementation chould be chosen yet");
247 self.ready = False
248 process(InterfaceDependency(self.root, restrictions = self.root_restrictions))
250 if fetch_stale_interfaces and self.network_use != network_offline:
251 for stale in self.stale_feeds:
252 info("Checking for updates to stale feed %s", stale)
253 self.begin_iface_download(stale, False)
255 for w in self.watchers: w()
257 # Only to be called from recalculate, as it is quite slow.
258 # Use the results stored in self.implementation instead.
259 def _get_best_implementation(self, iface):
260 impls = iface.implementations.values()
261 for f in self.usable_feeds(iface):
262 debug("Processing feed %s", f)
263 try:
264 feed_iface = self.get_interface(f.uri)
265 if feed_iface.name and iface.uri not in feed_iface.feed_for:
266 warn("Missing <feed-for> for '%s' in '%s'",
267 iface.uri, f.uri)
268 if feed_iface.implementations:
269 impls.extend(feed_iface.implementations.values())
270 except NeedDownload, ex:
271 raise ex
272 except Exception, ex:
273 warn("Failed to load feed %s for %s: %s",
274 f, iface, str(ex))
276 debug("get_best_implementation(%s), with feeds: %s", iface, iface.feeds)
278 if not impls:
279 info("Interface %s has no implementations!", iface)
280 return None
281 best = impls[0]
282 for x in impls[1:]:
283 if self.compare(iface, x, best) < 0:
284 best = x
285 unusable = self.get_unusable_reason(best, self.restrictions.get(iface, []))
286 if unusable:
287 info("Best implementation of %s is %s, but unusable (%s)", iface, best, unusable)
288 return None
289 return best
291 def compare(self, interface, b, a):
292 """Compare a and b to see which would be chosen first.
293 @param interface: The interface we are trying to resolve, which may
294 not be the interface of a or b if they are from feeds.
295 @rtype: int"""
296 restrictions = self.restrictions.get(interface, [])
298 a_stab = a.get_stability()
299 b_stab = b.get_stability()
301 # Usable ones come first
302 r = cmp(self.is_unusable(b, restrictions), self.is_unusable(a, restrictions))
303 if r: return r
305 # Preferred versions come first
306 r = cmp(a_stab == preferred, b_stab == preferred)
307 if r: return r
309 if self.network_use != network_full:
310 r = cmp(self.get_cached(a), self.get_cached(b))
311 if r: return r
313 # Stability
314 stab_policy = interface.stability_policy
315 if not stab_policy:
316 if self.help_with_testing: stab_policy = testing
317 else: stab_policy = stable
319 if a_stab >= stab_policy: a_stab = preferred
320 if b_stab >= stab_policy: b_stab = preferred
322 r = cmp(a_stab, b_stab)
323 if r: return r
325 # Newer versions come before older ones
326 r = cmp(a.version, b.version)
327 if r: return r
329 # Get best OS
330 r = cmp(arch.os_ranks.get(a.os, None),
331 arch.os_ranks.get(b.os, None))
332 if r: return r
334 # Get best machine
335 r = cmp(arch.machine_ranks.get(a.machine, None),
336 arch.machine_ranks.get(b.machine, None))
337 if r: return r
339 # Slightly prefer cached versions
340 if self.network_use == network_full:
341 r = cmp(self.get_cached(a), self.get_cached(b))
342 if r: return r
344 return cmp(a.id, b.id)
346 def usable_feeds(self, iface):
347 """Generator for C{iface.feeds} that are valid for our architecture.
348 @rtype: generator
349 @see: L{arch}"""
350 if self.src and iface.uri == self.root:
351 # Note: when feeds are recursive, we'll need a better test for root here
352 machine_ranks = {'src': 1}
353 else:
354 machine_ranks = arch.machine_ranks
356 for f in iface.feeds:
357 if f.os in arch.os_ranks and f.machine in machine_ranks:
358 yield f
359 else:
360 debug("Skipping '%s'; unsupported architecture %s-%s",
361 f, f.os, f.machine)
363 def get_ranked_implementations(self, iface):
364 """Get all implementations from all feeds, in order.
365 @type iface: Interface
366 @return: a sorted list of implementations.
367 @rtype: [model.Implementation]"""
368 impls = iface.implementations.values()
369 for f in self.usable_feeds(iface):
370 feed_iface = iface_cache.get_interface(f.uri)
371 if feed_iface.implementations:
372 impls.extend(feed_iface.implementations.values())
373 impls.sort(lambda a, b: self.compare(iface, a, b))
374 return impls
376 def is_unusable(self, impl, restrictions = []):
377 """@return: whether this implementation is unusable.
378 @rtype: bool"""
379 return self.get_unusable_reason(impl, restrictions) != None
381 def get_unusable_reason(self, impl, restrictions = []):
383 @param impl: Implementation to test.
384 @type restrictions: [L{model.Restriction}]
385 @return: The reason why this impl is unusable, or None if it's OK.
386 @rtype: str
387 @note: The restrictions are for the interface being requested, not the interface
388 of the implementation; they may be different when feeds are being used."""
389 for r in restrictions:
390 if not r.meets_restriction(impl):
391 return "Incompatible with another selected implementation"
392 stability = impl.get_stability()
393 if stability <= buggy:
394 return stability.name
395 if self.network_use == network_offline and not self.get_cached(impl):
396 return "Not cached and we are off-line"
397 if impl.os not in arch.os_ranks:
398 return "Unsupported OS"
399 # When looking for source code, we need to known if we're
400 # looking at an implementation of the root interface, even if
401 # it's from a feed, hence the sneaky restrictions identity check.
402 if self.src and restrictions is self.root_restrictions:
403 if impl.machine != 'src':
404 return "Not source code"
405 else:
406 if impl.machine not in arch.machine_ranks:
407 if impl.machine == 'src':
408 return "Source code"
409 return "Unsupported machine type"
410 return None
412 def get_interface(self, uri):
413 """Get an interface from the L{iface_cache}. If it is missing start a new download.
414 If it is present but stale, add it to L{stale_feeds}. This should only be called
415 from L{recalculate}.
416 @see: iface_cache.iface_cache.get_interface
417 @rtype: L{model.Interface}"""
418 iface = iface_cache.get_interface(uri)
420 if uri in iface_cache.pending:
421 # Don't start another download while one is pending
422 # TODO: unless the pending version is very old
423 return iface
425 if iface.last_modified is None:
426 if self.network_use != network_offline:
427 debug("Interface not cached and not off-line. Downloading...")
428 self.begin_iface_download(iface)
429 else:
430 if self._warned_offline:
431 debug("Nothing known about interface, but we are off-line.")
432 else:
433 if iface.feeds:
434 info("Nothing known about interface '%s' and off-line. Trying feeds only.", uri)
435 else:
436 warn("Nothing known about interface '%s', but we are in off-line mode "
437 "(so not fetching).", uri)
438 self._warned_offline = True
439 elif not uri.startswith('/'):
440 now = time.time()
441 staleness = now - (iface.last_checked or 0)
442 debug("Staleness for %s is %.2f hours", iface, staleness / 3600.0)
444 if self.freshness > 0 and staleness > self.freshness:
445 if iface.last_check_attempt and iface.last_check_attempt > now - FAILED_CHECK_DELAY:
446 debug("Stale, but tried to check recently (%s) so not rechecking now.", time.ctime(iface.last_check_attempt))
447 else:
448 debug("Adding %s to stale set", iface)
449 self.stale_feeds.add(iface)
450 #else: debug("Local interface, so not checking staleness.")
452 return iface
454 def begin_iface_download(self, interface, force = False):
455 """Start downloading the interface, and add a callback to process it when
456 done. If it is already being downloaded, do nothing."""
458 debug("begin_iface_download %s (force = %d)", interface, force)
459 if interface.uri.startswith('/'):
460 return
461 debug("Need to download")
462 dl = self.handler.get_download(interface.uri, force = force)
463 if dl.on_success:
464 # Possibly we should handle this better, but it's unlikely anyone will need
465 # to use an interface as an icon or implementation as well, and some of the code
466 # assumes it's OK keep asking for the same interface to be downloaded.
467 debug("Already have a handler for %s; not adding another", interface)
468 return
470 def feed_downloaded(stream):
471 pending = PendingFeed(interface.uri, stream)
472 iface_cache.add_pending(pending)
473 # This will trigger any required confirmations
474 self.process_pending()
476 dl.on_success.append(feed_downloaded)
478 def begin_impl_download(self, impl, retrieval_method, force = False):
479 """Start fetching impl, using retrieval_method. Each download started
480 will call monitor_download."""
481 assert impl
482 assert retrieval_method
484 from zeroinstall.zerostore import manifest
485 alg = impl.id.split('=', 1)[0]
486 if alg not in manifest.algorithms:
487 raise SafeException("Unknown digest algorithm '%s' for '%s' version %s" %
488 (alg, impl.interface.get_name(), impl.get_version()))
490 if isinstance(retrieval_method, DownloadSource):
491 def archive_ready(stream):
492 iface_cache.add_to_cache(retrieval_method, stream)
493 self.begin_archive_download(retrieval_method, success_callback = archive_ready, force = force)
494 elif isinstance(retrieval_method, Recipe):
495 _Cook(self, impl.id, retrieval_method)
496 else:
497 raise Exception("Unknown download type for '%s'" % retrieval_method)
499 def begin_archive_download(self, download_source, success_callback, force = False):
500 """Start fetching an archive. You should normally call L{begin_impl_download}
501 instead, since it handles other kinds of retrieval method too."""
502 from zeroinstall.zerostore import unpack
503 mime_type = download_source.type
504 if not mime_type:
505 mime_type = unpack.type_from_url(download_source.url)
506 if not mime_type:
507 raise SafeException("No 'type' attribute on archive, and I can't guess from the name (%s)" % download_source.url)
508 unpack.check_type_ok(mime_type)
509 dl = self.handler.get_download(download_source.url, force = force)
510 dl.expected_size = download_source.size + (download_source.start_offset or 0)
511 dl.on_success.append(success_callback)
512 return dl
514 def begin_icon_download(self, interface, force = False):
515 """Start downloading an icon for this interface. On success, add it to the
516 icon cache. If the interface has no icon, do nothing."""
517 debug("begin_icon_download %s (force = %d)", interface, force)
519 # Find a suitable icon to download
520 for icon in interface.get_metadata(XMLNS_IFACE, 'icon'):
521 type = icon.getAttribute('type')
522 if type != 'image/png':
523 debug('Skipping non-PNG icon')
524 continue
525 source = icon.getAttribute('href')
526 if source:
527 break
528 warn('Missing "href" attribute on <icon> in %s', interface)
529 else:
530 info('No PNG icons found in %s', interface)
531 return
533 dl = self.handler.get_download(source, force = force)
534 if dl.on_success:
535 # Possibly we should handle this better, but it's unlikely anyone will need
536 # to use an icon as an interface or implementation as well, and some of the code
537 # may assume it's OK keep asking for the same icon to be downloaded.
538 info("Already have a handler for %s; not adding another", source)
539 return
540 dl.on_success.append(lambda stream: self.store_icon(interface, stream))
542 def store_icon(self, interface, stream):
543 """Called when an icon has been successfully downloaded.
544 Subclasses may wish to wrap this to repaint the display."""
545 from zeroinstall.injector import basedir
546 import shutil
547 icons_cache = basedir.save_cache_path(config_site, 'interface_icons')
548 icon_file = file(os.path.join(icons_cache, escape(interface.uri)), 'w')
549 shutil.copyfileobj(stream, icon_file)
551 def get_implementation_path(self, impl):
552 """Return the local path of impl.
553 @rtype: str
554 @raise zeroinstall.zerostore.NotStored: if it needs to be added to the cache first."""
555 assert isinstance(impl, Implementation)
556 if impl.id.startswith('/'):
557 return impl.id
558 return iface_cache.stores.lookup(impl.id)
560 def get_implementation(self, interface):
561 """Get the chosen implementation.
562 @type interface: Interface
563 @rtype: L{model.Implementation}
564 @raise SafeException: if interface has not been fetched or no implementation could be
565 chosen."""
566 assert isinstance(interface, Interface)
568 if not interface.name and not interface.feeds:
569 raise SafeException("We don't have enough information to "
570 "run this program yet. "
571 "Need to download:\n%s" % interface.uri)
572 try:
573 return self.implementation[interface]
574 except KeyError, ex:
575 if interface.implementations:
576 offline = ""
577 if self.network_use == network_offline:
578 offline = "\nThis may be because 'Network Use' is set to Off-line."
579 raise SafeException("No usable implementation found for '%s'.%s" %
580 (interface.name, offline))
581 raise ex
583 def walk_interfaces(self):
584 """@deprecated: use L{implementation} instead"""
585 return iter(self.implementation)
587 def get_cached(self, impl):
588 """Check whether an implementation is available locally.
589 @type impl: model.Implementation
590 @rtype: bool
592 if isinstance(impl, DistributionImplementation):
593 return impl.installed
594 if impl.id.startswith('/'):
595 return os.path.exists(impl.id)
596 else:
597 try:
598 path = self.get_implementation_path(impl)
599 assert path
600 return True
601 except:
602 pass # OK
603 return False
605 def add_to_cache(self, source, data):
606 """Wrapper for L{iface_cache.IfaceCache.add_to_cache}."""
607 iface_cache.add_to_cache(source, data)
609 def get_uncached_implementations(self):
610 """List all chosen implementations which aren't yet available locally.
611 @rtype: [(str, model.Implementation)]"""
612 uncached = []
613 for iface in self.implementation:
614 impl = self.implementation[iface]
615 assert impl
616 if not self.get_cached(impl):
617 uncached.append((iface, impl))
618 return uncached
620 def refresh_all(self, force = True):
621 """Start downloading all feeds for all selected interfaces.
622 @param force: Whether to restart existing downloads."""
623 for x in self.implementation:
624 self.begin_iface_download(x, force)
625 for f in self.usable_feeds(x):
626 feed_iface = iface_cache.get_interface(f.uri)
627 self.begin_iface_download(feed_iface, force)
629 def interface_changed(self, interface):
630 """Callback used by L{iface_cache.IfaceCache.update_interface_from_network}."""
631 debug("interface_changed(%s): recalculating", interface)
632 self.recalculate()
634 def get_feed_targets(self, feed_iface_uri):
635 """Return a list of Interfaces for which feed_iface can be a feed.
636 This is used by B{0launch --feed}.
637 @rtype: [model.Interface]
638 @raise SafeException: If there are no known feeds."""
639 # TODO: what if it isn't cached yet?
640 feed_iface = iface_cache.get_interface(feed_iface_uri)
641 if not feed_iface.feed_for:
642 if not feed_iface.name:
643 raise SafeException("Can't get feed targets for '%s'; failed to load interface." %
644 feed_iface_uri)
645 raise SafeException("Missing <feed-for> element in '%s'; "
646 "this interface can't be used as a feed." % feed_iface_uri)
647 feed_targets = feed_iface.feed_for
648 debug("Feed targets: %s", feed_targets)
649 if not feed_iface.name:
650 warn("Warning: unknown interface '%s'" % feed_iface_uri)
651 return [iface_cache.get_interface(uri) for uri in feed_targets]
653 def get_icon_path(self, iface):
654 """Get an icon for this interface. If the icon is in the cache, use that.
655 If not, start a download. If we already started a download (successful or
656 not) do nothing.
657 @return: The cached icon's path, or None if no icon is currently available.
658 @rtype: str"""
659 path = iface_cache.get_icon_path(iface)
660 if path:
661 return path
663 if self.network_use == network_offline:
664 info("No icon present for %s, but off-line so not downloading", iface)
665 return None
667 self.begin_icon_download(iface)
668 return None
670 def get_best_source(self, impl):
671 """Return the best download source for this implementation.
672 @rtype: L{model.RetrievalMethod}"""
673 if impl.download_sources:
674 return impl.download_sources[0]
675 return None