Use subprocess instead of deprecated popen2 module
[zeroinstall.git] / zeroinstall / injector / iface_cache.py
blob62d24924beebf9e2209661ef0324644699b3daee
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 config_site, config_prog
37 from zeroinstall.injector.model import Interface, escape, unescape
38 from zeroinstall import zerostore, SafeException
40 def _pretty_time(t):
41 assert isinstance(t, (int, long)), t
42 return time.strftime('%Y-%m-%d %H:%M:%S UTC', time.localtime(t))
44 class ReplayAttack(SafeException):
45 """Attempt to import a feed that's older than the one in the cache."""
46 pass
48 class PendingFeed(object):
49 """A feed that has been downloaded but not yet added to the interface cache.
50 Feeds remain in this state until the user confirms that they trust at least
51 one of the signatures.
52 @ivar url: URL for the feed
53 @type url: str
54 @ivar signed_data: the untrusted data
55 @type signed_data: stream
56 @ivar sigs: signatures extracted from signed_data
57 @type sigs: [L{gpg.Signature}]
58 @ivar new_xml: the payload of the signed_data, or the whole thing if XML
59 @type new_xml: str
60 @since: 0.25"""
61 __slots__ = ['url', 'signed_data', 'sigs', 'new_xml']
63 def __init__(self, url, signed_data):
64 """Downloaded data is a GPG-signed message.
65 @param url: the URL of the downloaded feed
66 @type url: str
67 @param signed_data: the downloaded data (not yet trusted)
68 @type signed_data: stream
69 @raise SafeException: if the data is not signed, and logs the actual data"""
70 self.url = url
71 self.signed_data = signed_data
72 self.recheck()
74 def download_keys(self, handler, feed_hint = None, key_mirror = None):
75 """Download any required GPG keys not already on our keyring.
76 When all downloads are done (successful or otherwise), add any new keys
77 to the keyring, L{recheck}.
78 @param handler: handler to manage the download
79 @type handler: L{handler.Handler}
80 @param key_mirror: URL of directory containing keys, or None to use feed's directory
81 @type key_mirror: str
82 """
83 downloads = {}
84 blockers = []
85 for x in self.sigs:
86 key_id = x.need_key()
87 if key_id:
88 import urlparse
89 key_url = urlparse.urljoin(key_mirror or self.url, '%s.gpg' % key_id)
90 info("Fetching key from %s", key_url)
91 dl = handler.get_download(key_url, hint = feed_hint)
92 downloads[dl.downloaded] = (dl, dl.tempfile)
93 blockers.append(dl.downloaded)
95 exception = None
96 any_success = False
98 from zeroinstall.support import tasks
100 while blockers:
101 yield blockers
103 old_blockers = blockers
104 blockers = []
106 for b in old_blockers:
107 try:
108 tasks.check(b)
109 if b.happened:
110 dl, stream = downloads[b]
111 stream.seek(0)
112 self._downloaded_key(stream)
113 any_success = True
114 else:
115 blockers.append(b)
116 except Exception:
117 _, exception, tb = sys.exc_info()
118 warn("Failed to import key for '%s': %s", self.url, str(exception))
120 if exception and not any_success:
121 raise exception, None, tb
123 self.recheck()
125 def _downloaded_key(self, stream):
126 import shutil, tempfile
127 from zeroinstall.injector import gpg
129 info("Importing key for feed '%s'", self.url)
131 # Python2.4: can't call fileno() on stream, so save to tmp file instead
132 tmpfile = tempfile.TemporaryFile(prefix = 'injector-dl-data-')
133 try:
134 shutil.copyfileobj(stream, tmpfile)
135 tmpfile.flush()
137 tmpfile.seek(0)
138 gpg.import_key(tmpfile)
139 finally:
140 tmpfile.close()
142 def recheck(self):
143 """Set new_xml and sigs by reading signed_data.
144 You need to call this when previously-missing keys are added to the GPG keyring."""
145 import gpg
146 try:
147 self.signed_data.seek(0)
148 stream, sigs = gpg.check_stream(self.signed_data)
149 assert sigs
151 data = stream.read()
152 if stream is not self.signed_data:
153 stream.close()
155 self.new_xml = data
156 self.sigs = sigs
157 except:
158 self.signed_data.seek(0)
159 info("Failed to check GPG signature. Data received was:\n" + repr(self.signed_data.read()))
160 raise
162 class IfaceCache(object):
164 The interface cache stores downloaded and verified interfaces in
165 ~/.cache/0install.net/interfaces (by default).
167 There are methods to query the cache, add to it, check signatures, etc.
169 The cache is updated by L{fetch.Fetcher}.
171 Confusingly, this class is really two caches combined: the in-memory
172 cache of L{model.Interface} objects, and an on-disk cache of L{model.ZeroInstallFeed}s.
173 It will probably be split into two in future.
175 @see: L{iface_cache} - the singleton IfaceCache instance.
178 __slots__ = ['_interfaces', 'stores']
180 def __init__(self):
181 self._interfaces = {}
183 self.stores = zerostore.Stores()
185 def update_interface_if_trusted(self, interface, sigs, xml):
186 """Update a cached interface (using L{update_interface_from_network})
187 if we trust the signatures.
188 If we don't trust any of the signatures, do nothing.
189 @param interface: the interface being updated
190 @type interface: L{model.Interface}
191 @param sigs: signatures from L{gpg.check_stream}
192 @type sigs: [L{gpg.Signature}]
193 @param xml: the downloaded replacement interface document
194 @type xml: str
195 @return: True if the interface was updated
196 @rtype: bool
198 import trust
199 updated = self._oldest_trusted(sigs, trust.domain_from_url(interface.uri))
200 if updated is None: return False # None are trusted
202 self.update_interface_from_network(interface, xml, updated)
203 return True
205 def update_interface_from_network(self, interface, new_xml, modified_time):
206 """Update a cached interface.
207 Called by L{update_interface_if_trusted} if we trust this data.
208 After a successful update, L{writer} is used to update the interface's
209 last_checked time.
210 @param interface: the interface being updated
211 @type interface: L{model.Interface}
212 @param new_xml: the downloaded replacement interface document
213 @type new_xml: str
214 @param modified_time: the timestamp of the oldest trusted signature
215 (used as an approximation to the interface's modification time)
216 @type modified_time: long
217 @raises ReplayAttack: if modified_time is older than the currently cached time
219 debug("Updating '%s' from network; modified at %s" %
220 (interface.name or interface.uri, _pretty_time(modified_time)))
222 if '\n<!-- Base64 Signature' not in new_xml:
223 # Only do this for old-style interfaces without
224 # signatures Otherwise, we can get the time from the
225 # signature, and adding this attribute just makes the
226 # signature invalid.
227 from xml.dom import minidom
228 doc = minidom.parseString(new_xml)
229 doc.documentElement.setAttribute('last-modified', str(modified_time))
230 new_xml = StringIO()
231 doc.writexml(new_xml)
232 new_xml = new_xml.getvalue()
234 self._import_new_interface(interface, new_xml, modified_time)
236 import writer
237 interface._main_feed.last_checked = long(time.time())
238 writer.save_interface(interface)
240 info("Updated interface cache entry for %s (modified %s)",
241 interface.get_name(), _pretty_time(modified_time))
243 def _import_new_interface(self, interface, new_xml, modified_time):
244 """Write new_xml into the cache.
245 @param interface: updated once the new XML is written
246 @param new_xml: the data to write
247 @param modified_time: when new_xml was modified
248 @raises ReplayAttack: if the new mtime is older than the current one
250 assert modified_time
252 upstream_dir = basedir.save_cache_path(config_site, 'interfaces')
253 cached = os.path.join(upstream_dir, escape(interface.uri))
255 if os.path.exists(cached):
256 old_xml = file(cached).read()
257 if old_xml == new_xml:
258 debug("No change")
259 return
261 stream = file(cached + '.new', 'w')
262 stream.write(new_xml)
263 stream.close()
264 os.utime(cached + '.new', (modified_time, modified_time))
265 new_mtime = reader.check_readable(interface.uri, cached + '.new')
266 assert new_mtime == modified_time
268 old_modified = self._get_signature_date(interface.uri)
269 if old_modified is None:
270 old_modified = interface.last_modified
272 if old_modified:
273 if new_mtime < old_modified:
274 os.unlink(cached + '.new')
275 raise ReplayAttack("New interface's modification time is before old "
276 "version!"
277 "\nOld time: " + _pretty_time(old_modified) +
278 "\nNew time: " + _pretty_time(new_mtime) +
279 "\nRefusing update.")
280 if new_mtime == old_modified:
281 # You used to have to update the modification time manually.
282 # Now it comes from the signature, this check isn't useful
283 # and often causes problems when the stored format changes
284 # (e.g., when we stopped writing last-modified attributes)
285 pass
286 #raise SafeException("Interface has changed, but modification time "
287 # "hasn't! Refusing update.")
288 os.rename(cached + '.new', cached)
289 debug("Saved as " + cached)
291 reader.update_from_cache(interface)
293 def get_feed(self, url):
294 """Get a feed from the cache.
295 @param url: the URL of the feed
296 @return: the feed, or None if it isn't cached
297 @rtype: L{model.ZeroInstallFeed}"""
298 # TODO: This isn't a good implementation
299 iface = self.get_interface(url)
300 feed = iface._main_feed
301 if not isinstance(feed, model.DummyFeed):
302 return feed
303 return None
305 def get_interface(self, uri):
306 """Get the interface for uri, creating a new one if required.
307 New interfaces are initialised from the disk cache, but not from
308 the network.
309 @param uri: the URI of the interface to find
310 @rtype: L{model.Interface}
312 if type(uri) == str:
313 uri = unicode(uri)
314 assert isinstance(uri, unicode)
316 if uri in self._interfaces:
317 return self._interfaces[uri]
319 debug("Initialising new interface object for %s", uri)
320 self._interfaces[uri] = Interface(uri)
321 reader.update_from_cache(self._interfaces[uri])
322 return self._interfaces[uri]
324 def list_all_interfaces(self):
325 """List all interfaces in the cache.
326 @rtype: [str]
328 all = set()
329 for d in basedir.load_cache_paths(config_site, 'interfaces'):
330 for leaf in os.listdir(d):
331 if not leaf.startswith('.'):
332 all.add(unescape(leaf))
333 for d in basedir.load_config_paths(config_site, config_prog, 'user_overrides'):
334 for leaf in os.listdir(d):
335 if not leaf.startswith('.'):
336 all.add(unescape(leaf))
337 return list(all) # Why not just return the set?
339 def get_icon_path(self, iface):
340 """Get the path of a cached icon for an interface.
341 @param iface: interface whose icon we want
342 @return: the path of the cached icon, or None if not cached.
343 @rtype: str"""
344 return basedir.load_first_cache(config_site, 'interface_icons',
345 escape(iface.uri))
347 def get_cached_signatures(self, uri):
348 """Verify the cached interface using GPG.
349 Only new-style XML-signed interfaces retain their signatures in the cache.
350 @param uri: the feed to check
351 @type uri: str
352 @return: a list of signatures, or None
353 @rtype: [L{gpg.Signature}] or None
354 @since: 0.25"""
355 import gpg
356 if uri.startswith('/'):
357 old_iface = uri
358 else:
359 old_iface = basedir.load_first_cache(config_site, 'interfaces', escape(uri))
360 if old_iface is None:
361 return None
362 try:
363 return gpg.check_stream(file(old_iface))[1]
364 except SafeException, ex:
365 debug("No signatures (old-style interface): %s" % ex)
366 return None
368 def _get_signature_date(self, uri):
369 """Read the date-stamp from the signature of the cached interface.
370 If the date-stamp is unavailable, returns None."""
371 import trust
372 sigs = self.get_cached_signatures(uri)
373 if sigs:
374 return self._oldest_trusted(sigs, trust.domain_from_url(uri))
376 def _oldest_trusted(self, sigs, domain):
377 """Return the date of the oldest trusted signature in the list, or None if there
378 are no trusted sigs in the list."""
379 trusted = [s.get_timestamp() for s in sigs if s.is_trusted(domain)]
380 if trusted:
381 return min(trusted)
382 return None
384 def mark_as_checking(self, url):
385 """Touch a 'last_check_attempt_timestamp' file for this feed.
386 If url is a local path, nothing happens.
387 This prevents us from repeatedly trying to download a failing feed many
388 times in a short period."""
389 if url.startswith('/'):
390 return
391 feeds_dir = basedir.save_cache_path(config_site, config_prog, 'last-check-attempt')
392 timestamp_path = os.path.join(feeds_dir, model._pretty_escape(url))
393 fd = os.open(timestamp_path, os.O_WRONLY | os.O_CREAT, 0644)
394 os.close(fd)
395 os.utime(timestamp_path, None) # In case file already exists
397 def get_last_check_attempt(self, url):
398 """Return the time of the most recent update attempt for a feed.
399 @see: L{mark_as_checking}
400 @return: The time, or None if none is recorded
401 @rtype: float | None"""
402 timestamp_path = basedir.load_first_cache(config_site, config_prog, 'last-check-attempt', model._pretty_escape(url))
403 if timestamp_path:
404 return os.stat(timestamp_path).st_mtime
405 return None
407 iface_cache = IfaceCache()