Also get GPG keys using the mirror.
[zeroinstall/zeroinstall-mseaborn.git] / zeroinstall / injector / iface_cache.py
blob5b453be5ea7065214fc6a5864ac6998e55ce4497
1 """
2 Manages the feed cache.
4 @var iface_cache: A singleton cache object. You should normally use this rather than
5 creating new cache objects.
6 """
7 # Copyright (C) 2008, Thomas Leonard
8 # See the README file for details, or visit http://0install.net.
10 # Note:
12 # We need to know the modification time of each interface, because we refuse
13 # to update to an older version (this prevents an attack where the attacker
14 # sends back an old version which is correctly signed but has a known bug).
16 # The way we store this is a bit complicated due to backward compatibility:
18 # - GPG-signed interfaces have their signatures removed and a last-modified
19 # attribute is stored containing the date from the signature.
21 # - XML-signed interfaces are stored unmodified with their signatures. The
22 # date is extracted from the signature when needed.
24 # - Older versions used to add the last-modified attribute even to files
25 # with XML signatures - these files therefore have invalid signatures and
26 # we extract from the attribute for these.
28 # Eventually, support for the first and third cases will be removed.
30 import os, sys, time
31 from logging import debug, info, warn
32 from cStringIO import StringIO
34 from zeroinstall.support import basedir
35 from zeroinstall.injector import reader, model
36 from zeroinstall.injector.namespaces import *
37 from zeroinstall.injector.model import *
38 from zeroinstall import zerostore
40 def _pretty_time(t):
41 assert isinstance(t, (int, long))
42 return time.strftime('%Y-%m-%d %H:%M:%S UTC', time.localtime(t))
44 class PendingFeed(object):
45 """A feed that has been downloaded but not yet added to the interface cache.
46 Feeds remain in this state until the user confirms that they trust at least
47 one of the signatures.
48 @ivar url: URL for the feed
49 @type url: str
50 @ivar signed_data: the untrusted data
51 @type signed_data: stream
52 @ivar sigs: signatures extracted from signed_data
53 @type sigs: [L{gpg.Signature}]
54 @ivar new_xml: the payload of the signed_data, or the whole thing if XML
55 @type new_xml: str
56 @since: 0.25"""
57 __slots__ = ['url', 'signed_data', 'sigs', 'new_xml']
59 def __init__(self, url, signed_data):
60 """Downloaded data is a GPG-signed message.
61 @param url: the URL of the downloaded feed
62 @type url: str
63 @param signed_data: the downloaded data (not yet trusted)
64 @type signed_data: stream
65 @raise SafeException: if the data is not signed, and logs the actual data"""
66 self.url = url
67 self.signed_data = signed_data
68 self.recheck()
70 def download_keys(self, handler, feed_hint = None, key_mirror = None):
71 """Download any required GPG keys not already on our keyring.
72 When all downloads are done (successful or otherwise), add any new keys
73 to the keyring, L{recheck}.
74 @param handler: handler to manage the download
75 @type handler: L{handler.Handler}
76 @param key_mirror: URL of directory containing keys, or None to use feed's directory
77 @type key_mirror: str
78 """
79 downloads = {}
80 blockers = []
81 for x in self.sigs:
82 key_id = x.need_key()
83 if key_id:
84 import urlparse
85 key_url = urlparse.urljoin(key_mirror or self.url, '%s.gpg' % key_id)
86 info("Fetching key from %s", key_url)
87 dl = handler.get_download(key_url, hint = feed_hint)
88 downloads[dl.downloaded] = (dl, dl.tempfile)
89 blockers.append(dl.downloaded)
91 exception = None
92 any_success = False
94 from zeroinstall.support import tasks
96 while blockers:
97 yield blockers
99 old_blockers = blockers
100 blockers = []
102 for b in old_blockers:
103 try:
104 tasks.check(b)
105 if b.happened:
106 dl, stream = downloads[b]
107 stream.seek(0)
108 self._downloaded_key(stream)
109 any_success = True
110 else:
111 blockers.append(b)
112 except Exception:
113 _, exception, tb = sys.exc_info()
114 warn("Failed to import key for '%s': %s", self.url, str(exception))
116 if exception and not any_success:
117 raise exception, None, tb
119 self.recheck()
121 def _downloaded_key(self, stream):
122 import shutil, tempfile
123 from zeroinstall.injector import gpg
125 info("Importing key for feed '%s'", self.url)
127 # Python2.4: can't call fileno() on stream, so save to tmp file instead
128 tmpfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
129 try:
130 shutil.copyfileobj(stream, tmpfile)
131 tmpfile.flush()
133 tmpfile.seek(0)
134 gpg.import_key(tmpfile)
135 finally:
136 tmpfile.close()
138 def recheck(self):
139 """Set new_xml and sigs by reading signed_data.
140 You need to call this when previously-missing keys are added to the GPG keyring."""
141 import gpg
142 try:
143 self.signed_data.seek(0)
144 stream, sigs = gpg.check_stream(self.signed_data)
145 assert sigs
147 data = stream.read()
148 if stream is not self.signed_data:
149 stream.close()
151 self.new_xml = data
152 self.sigs = sigs
153 except:
154 self.signed_data.seek(0)
155 info("Failed to check GPG signature. Data received was:\n" + `self.signed_data.read()`)
156 raise
158 class IfaceCache(object):
160 The interface cache stores downloaded and verified interfaces in
161 ~/.cache/0install.net/interfaces (by default).
163 There are methods to query the cache, add to it, check signatures, etc.
165 The cache is updated by L{fetch.Fetcher}.
167 Confusingly, this class is really two caches combined: the in-memory
168 cache of L{model.Interface} objects, and an on-disk cache of L{model.ZeroInstallFeed}s.
169 It will probably be split into two in future.
171 @ivar pending: downloaded feeds which are not yet trusted
172 @type pending: str -> PendingFeed
173 @see: L{iface_cache} - the singleton IfaceCache instance.
176 __slots__ = ['_interfaces', 'stores', 'pending']
178 def __init__(self):
179 self._interfaces = {}
180 self.pending = {}
182 self.stores = zerostore.Stores()
184 def add_pending(self, pending):
185 """Add a PendingFeed to the pending dict.
186 @param pending: the untrusted download to add
187 @type pending: PendingFeed
188 @since: 0.25"""
189 assert isinstance(pending, PendingFeed)
190 self.pending[pending.url] = pending
192 def update_interface_if_trusted(self, interface, sigs, xml):
193 """Update a cached interface (using L{update_interface_from_network})
194 if we trust the signatures, and remove it from L{pending}.
195 If we don't trust any of the signatures, do nothing.
196 @param interface: the interface being updated
197 @type interface: L{model.Interface}
198 @param sigs: signatures from L{gpg.check_stream}
199 @type sigs: [L{gpg.Signature}]
200 @param xml: the downloaded replacement interface document
201 @type xml: str
202 @return: True if the interface was updated
203 @rtype: bool
204 @precondition: call L{add_pending}
206 import trust
207 updated = self._oldest_trusted(sigs, trust.domain_from_url(interface.uri))
208 if updated is None: return False # None are trusted
210 if interface.uri in self.pending:
211 del self.pending[interface.uri]
212 else:
213 raise Exception("update_interface_if_trusted, but '%s' not pending!" % interface.uri)
215 self.update_interface_from_network(interface, xml, updated)
216 return True
218 def update_interface_from_network(self, interface, new_xml, modified_time):
219 """Update a cached interface.
220 Called by L{update_interface_if_trusted} if we trust this data.
221 After a successful update, L{writer} is used to update the interface's
222 last_checked time.
223 @param interface: the interface being updated
224 @type interface: L{model.Interface}
225 @param new_xml: the downloaded replacement interface document
226 @type new_xml: str
227 @param modified_time: the timestamp of the oldest trusted signature
228 (used as an approximation to the interface's modification time)
229 @type modified_time: long
230 @raises SafeException: if modified_time is older than the currently cached time
232 debug("Updating '%s' from network; modified at %s" %
233 (interface.name or interface.uri, _pretty_time(modified_time)))
235 if '\n<!-- Base64 Signature' not in new_xml:
236 # Only do this for old-style interfaces without
237 # signatures Otherwise, we can get the time from the
238 # signature, and adding this attribute just makes the
239 # signature invalid.
240 from xml.dom import minidom
241 doc = minidom.parseString(new_xml)
242 doc.documentElement.setAttribute('last-modified', str(modified_time))
243 new_xml = StringIO()
244 doc.writexml(new_xml)
245 new_xml = new_xml.getvalue()
247 self._import_new_interface(interface, new_xml, modified_time)
249 import writer
250 interface._main_feed.last_checked = long(time.time())
251 writer.save_interface(interface)
253 info("Updated interface cache entry for %s (modified %s)",
254 interface.get_name(), _pretty_time(modified_time))
256 def _import_new_interface(self, interface, new_xml, modified_time):
257 """Write new_xml into the cache.
258 @param interface: updated once the new XML is written
259 @param new_xml: the data to write
260 @param modified_time: when new_xml was modified
261 @raises SafeException: if the new mtime is older than the current one
263 assert modified_time
265 upstream_dir = basedir.save_cache_path(config_site, 'interfaces')
266 cached = os.path.join(upstream_dir, escape(interface.uri))
268 if os.path.exists(cached):
269 old_xml = file(cached).read()
270 if old_xml == new_xml:
271 debug("No change")
272 return
274 stream = file(cached + '.new', 'w')
275 stream.write(new_xml)
276 stream.close()
277 os.utime(cached + '.new', (modified_time, modified_time))
278 new_mtime = reader.check_readable(interface.uri, cached + '.new')
279 assert new_mtime == modified_time
281 old_modified = self._get_signature_date(interface.uri)
282 if old_modified is None:
283 old_modified = interface.last_modified
285 if old_modified:
286 if new_mtime < old_modified:
287 raise SafeException("New interface's modification time is before old "
288 "version!"
289 "\nOld time: " + _pretty_time(old_modified) +
290 "\nNew time: " + _pretty_time(new_mtime) +
291 "\nRefusing update (leaving new copy as " +
292 cached + ".new)")
293 if new_mtime == old_modified:
294 # You used to have to update the modification time manually.
295 # Now it comes from the signature, this check isn't useful
296 # and often causes problems when the stored format changes
297 # (e.g., when we stopped writing last-modified attributes)
298 pass
299 #raise SafeException("Interface has changed, but modification time "
300 # "hasn't! Refusing update.")
301 os.rename(cached + '.new', cached)
302 debug("Saved as " + cached)
304 reader.update_from_cache(interface)
306 def get_feed(self, url):
307 """Get a feed from the cache.
308 @param url: the URL of the feed
309 @return: the feed, or None if it isn't cached
310 @rtype: L{model.ZeroInstallFeed}"""
311 # TODO: This isn't a good implementation
312 iface = self.get_interface(url)
313 feed = iface._main_feed
314 if not isinstance(feed, model.DummyFeed):
315 return feed
316 return None
318 def get_interface(self, uri):
319 """Get the interface for uri, creating a new one if required.
320 New interfaces are initialised from the disk cache, but not from
321 the network.
322 @param uri: the URI of the interface to find
323 @rtype: L{model.Interface}
325 if type(uri) == str:
326 uri = unicode(uri)
327 assert isinstance(uri, unicode)
329 if uri in self._interfaces:
330 return self._interfaces[uri]
332 debug("Initialising new interface object for %s", uri)
333 self._interfaces[uri] = Interface(uri)
334 reader.update_from_cache(self._interfaces[uri])
335 return self._interfaces[uri]
337 def list_all_interfaces(self):
338 """List all interfaces in the cache.
339 @rtype: [str]
341 all = set()
342 for d in basedir.load_cache_paths(config_site, 'interfaces'):
343 for leaf in os.listdir(d):
344 if not leaf.startswith('.'):
345 all.add(unescape(leaf))
346 for d in basedir.load_config_paths(config_site, config_prog, 'user_overrides'):
347 for leaf in os.listdir(d):
348 if not leaf.startswith('.'):
349 all.add(unescape(leaf))
350 return list(all) # Why not just return the set?
352 def get_icon_path(self, iface):
353 """Get the path of a cached icon for an interface.
354 @param iface: interface whose icon we want
355 @return: the path of the cached icon, or None if not cached.
356 @rtype: str"""
357 return basedir.load_first_cache(config_site, 'interface_icons',
358 escape(iface.uri))
360 def get_cached_signatures(self, uri):
361 """Verify the cached interface using GPG.
362 Only new-style XML-signed interfaces retain their signatures in the cache.
363 @param uri: the feed to check
364 @type uri: str
365 @return: a list of signatures, or None
366 @rtype: [L{gpg.Signature}] or None
367 @since: 0.25"""
368 import gpg
369 if uri.startswith('/'):
370 old_iface = uri
371 else:
372 old_iface = basedir.load_first_cache(config_site, 'interfaces', escape(uri))
373 if old_iface is None:
374 return None
375 try:
376 return gpg.check_stream(file(old_iface))[1]
377 except SafeException, ex:
378 debug("No signatures (old-style interface): %s" % ex)
379 return None
381 def _get_signature_date(self, uri):
382 """Read the date-stamp from the signature of the cached interface.
383 If the date-stamp is unavailable, returns None."""
384 import trust
385 sigs = self.get_cached_signatures(uri)
386 if sigs:
387 return self._oldest_trusted(sigs, trust.domain_from_url(uri))
389 def _oldest_trusted(self, sigs, domain):
390 """Return the date of the oldest trusted signature in the list, or None if there
391 are no trusted sigs in the list."""
392 trusted = [s.get_timestamp() for s in sigs if s.is_trusted(domain)]
393 if trusted:
394 return min(trusted)
395 return None
397 def mark_as_checking(self, url):
398 """Touch a 'last_check_attempt_timestamp' file for this feed.
399 If url is a local path, nothing happens.
400 This prevents us from repeatedly trying to download a failing feed many
401 times in a short period."""
402 if url.startswith('/'):
403 return
404 feeds_dir = basedir.save_cache_path(config_site, config_prog, 'last-check-attempt')
405 timestamp_path = os.path.join(feeds_dir, model._pretty_escape(url))
406 fd = os.open(timestamp_path, os.O_WRONLY | os.O_CREAT, 0644)
407 os.close(fd)
409 def get_last_check_attempt(self, url):
410 """Return the time of the most recent update attempt for a feed.
411 @see: L{mark_as_checking}
412 @return: The time, or None if none is recorded
413 @rtype: float | None"""
414 timestamp_path = basedir.load_first_cache(config_site, config_prog, 'last-check-attempt', model._pretty_escape(url))
415 if timestamp_path:
416 return os.stat(timestamp_path).st_mtime
417 return None
419 iface_cache = IfaceCache()