3 from optparse
import OptionParser
4 import sys
, shutil
, tempfile
, urlparse
6 import urllib2
, os
, httplib
8 import logging
, time
, traceback
9 from logging
import info
11 from zeroinstall
import SafeException
12 from zeroinstall
.injector
import model
, gpg
, namespaces
, qdom
13 from zeroinstall
.injector
.config
import load_config
15 from display
import checking
, result
, error
, highlight
, error_new_line
17 config
= load_config()
23 WEEK
= 60 * 60 * 24 * 7
26 if hasattr(address
, 'hostname'):
27 return address
.hostname
29 return address
[1].split(':', 1)[0]
32 if hasattr(address
, 'port'):
35 port
= address
[1].split(':', 1)[1:]
41 assert port(('http', 'foo:81')) == 81
42 assert port(urlparse
.urlparse('http://foo:81')) == 81
44 parser
= OptionParser(usage
="usage: %prog [options] feed.xml")
45 parser
.add_option("-d", "--dependencies", help="also check feeds for dependencies", action
='store_true')
46 parser
.add_option("-s", "--skip-archives", help="don't check the archives are OK", action
='store_true')
47 parser
.add_option("-v", "--verbose", help="more verbose output", action
='count')
48 parser
.add_option("-V", "--version", help="display version information", action
='store_true')
50 (options
, args
) = parser
.parse_args()
53 print "FeedLint (zero-install) " + version
54 print "Copyright (C) 2007 Thomas Leonard"
55 print "This program comes with ABSOLUTELY NO WARRANTY,"
56 print "to the extent permitted by law."
57 print "You may redistribute copies of this program"
58 print "under the terms of the GNU General Public License."
59 print "For more information about these matters, see the file named COPYING."
63 logger
= logging
.getLogger()
64 if options
.verbose
== 1:
65 logger
.setLevel(logging
.INFO
)
67 logger
.setLevel(logging
.DEBUG
)
76 app
= config
.app_mgr
.lookup_app(arg
, missing_ok
= True)
78 return app
.get_requirements().interface_uri
80 return model
.canonical_iface_uri(a
)
83 to_check
= [arg_to_uri(a
) for a
in args
]
84 except SafeException
, ex
:
85 if options
.verbose
: raise
86 print >>sys
.stderr
, ex
89 def check_key(feed_url
, fingerprint
):
90 for line
in os
.popen('gpg --with-colons --list-keys %s' % fingerprint
):
91 if line
.startswith('pub:'):
92 key_id
= line
.split(':')[4]
95 raise SafeException('Failed to find key with fingerprint %s on your keyring' % fingerprint
)
97 key_url
= urlparse
.urljoin(feed_url
, '%s.gpg' % key_id
)
99 if key_url
in checked
:
100 info("(already checked key URL %s)", key_url
)
102 checking("Checking key %s" % key_url
)
103 urllib2
.urlopen(key_url
).read()
107 def get_http_size(url
, ttl
= 3):
108 address
= urlparse
.urlparse(url
)
110 if url
.lower().startswith('http://'):
111 http
= httplib
.HTTPConnection(host(address
), port(address
) or 80)
112 elif url
.lower().startswith('https://'):
113 http
= httplib
.HTTPSConnection(host(address
), port(address
) or 443)
117 parts
= url
.split('/', 3)
123 http
.request('HEAD', '/' + path
, headers
= {'Host': host(address
)})
124 response
= http
.getresponse()
126 if response
.status
== 200:
127 return response
.getheader('Content-Length')
128 elif response
.status
in (301, 302, 303):
129 new_url_rel
= response
.getheader('Location') or response
.getheader('URI')
130 new_url
= urlparse
.urljoin(url
, new_url_rel
)
132 raise SafeException("HTTP error: got status code %s" % response
.status
)
137 result("Moved", 'YELLOW')
138 checking("Checking new URL %s" % new_url
)
140 return get_http_size(new_url
, ttl
- 1)
142 raise SafeException('Too many redirections.')
144 def get_ftp_size(url
):
145 address
= urlparse
.urlparse(url
)
146 ftp
= ftplib
.FTP(host(address
))
149 ftp
.voidcmd('TYPE I')
150 return ftp
.size(url
.split('/', 3)[3])
155 scheme
= urlparse
.urlparse(url
)[0].lower()
156 if scheme
.startswith('http') or scheme
.startswith('https'):
157 return get_http_size(url
)
158 elif scheme
.startswith('ftp'):
159 return get_ftp_size(url
)
161 raise SafeException("Unknown scheme '%s' in '%s'" % (scheme
, url
))
163 def check_source(source
):
164 if hasattr(source
, 'url'):
165 checking("Checking archive %s" % source
.url
)
166 actual_size
= get_size(source
.url
)
167 if actual_size
is None:
168 result("No Content-Length for archive; can't check", 'YELLOW')
170 actual_size
= int(actual_size
)
171 expected_size
= source
.size
+ (source
.start_offset
or 0)
172 if actual_size
!= expected_size
:
174 raise SafeException("Expected archive to have a size of %d, but server says it is %d" %
175 (expected_size
, actual_size
))
177 elif hasattr(source
, 'steps'):
178 for step
in source
.steps
:
181 existing_urls
= set()
182 def check_exists(url
):
183 if url
in existing_urls
: return # Already checked
185 checking("Checking URL exists %s" % url
)
188 existing_urls
.add(url
)
190 def scan_implementations(impls
, dom
):
191 """Add each implementation in dom to impls. Error if duplicate."""
192 for elem
in dom
.childNodes
:
193 if elem
.uri
!= namespaces
.XMLNS_IFACE
: continue
194 if elem
.name
== 'implementation':
195 impl_id
= elem
.attrs
['id']
197 raise SafeException("Duplicate ID {id}!".format(id = impl_id
))
198 impls
[impl_id
] = elem
199 elif elem
.name
== 'group':
200 scan_implementations(impls
, elem
)
204 def check_gpg_sig(feed_url
, stream
):
205 start
= stream
.read(5)
208 elif start
== '-----':
209 result('Old sig', colour
= 'RED')
210 error_new_line(' Feed has an old-style plain GPG signature. Use 0publish --xmlsign.',
214 error_new_line(' Unknown format. File starts "%s"' % start
)
216 data
, sigs
= gpg
.check_stream(stream
)
219 if isinstance(s
, gpg
.ValidSig
):
220 check_key(feed_url
, s
.fingerprint
)
222 raise SafeException("Can't check sig: %s" % s
)
227 feed
= to_check
.pop()
229 info("Already checked feed %s", feed
)
234 checking("Checking " + feed
, indent
= 0)
237 if feed
.startswith('/'):
238 with
open(feed
) as stream
:
239 dom
= qdom
.parse(stream
)
241 if "uri" in dom
.attrs
:
244 check_gpg_sig(dom
.attrs
['uri'], stream
)
245 except SafeException
, ex
:
247 error_new_line(' %s' % ex
)
249 feed_obj
= model
.ZeroInstallFeed(dom
, local_path
= feed
)
252 tmp
= tempfile
.TemporaryFile(prefix
= 'feedlint-')
255 stream
= urllib2
.urlopen(feed
)
256 shutil
.copyfileobj(stream
, tmp
)
257 except Exception as ex
:
258 raise SafeException('Failed to fetch feed: {ex}'.format(ex
= ex
))
261 data
= check_gpg_sig(feed
, tmp
)
264 dom
= qdom
.parse(data
)
265 feed_obj
= model
.ZeroInstallFeed(dom
)
267 if feed_obj
.url
!= feed
:
268 raise SafeException('Incorrect URL "%s"' % feed_obj
.url
)
273 # Check for duplicate IDs
274 scan_implementations({}, dom
)
276 for f
in feed_obj
.feeds
:
277 info("Will check feed %s", f
.uri
)
278 to_check
.append(f
.uri
)
280 highest_version
= None
281 for impl
in sorted(feed_obj
.implementations
.values()):
282 if hasattr(impl
, 'dependencies'):
283 for r
in impl
.dependencies
.values():
284 if r
.interface
not in checked
:
285 info("Will check dependency %s", r
)
286 if options
.dependencies
:
287 to_check
.append(r
.interface
)
289 check_exists(r
.interface
)
290 if hasattr(impl
, 'download_sources') and not options
.skip_archives
:
291 for source
in impl
.download_sources
:
293 if impl
.local_path
is None:
295 raise SafeException("Version {version} has no digests".format(version
= impl
.get_version()))
296 stability
= impl
.upstream_stability
or model
.testing
297 if highest_version
is None or impl
.version
> highest_version
.version
:
298 highest_version
= impl
299 if stability
== model
.testing
:
301 if not impl
.released
:
302 if not impl
.local_path
:
303 testing_error
= "No release date on testing version"
306 released
= time
.strptime(impl
.released
, '%Y-%m-%d')
307 except ValueError, ex
:
308 testing_error
= "Can't parse date"
310 ago
= now
- time
.mktime(released
)
312 testing_error
= 'Release date is in the future!'
314 raise SafeException("Version %s: %s (released %s)" % (impl
.get_version(), testing_error
, impl
.released
))
316 # Old Windows versions use 32-bit integers to store versions. Newer versions use 64-bit ones, but in general
317 # keeping the numbers small is helpful.
318 for i
in range(0, len(impl
.version
), 2):
319 for x
in impl
.version
[i
]:
321 raise SafeException("Version %s: component %s won't fit in a 32-bit signed integer" % (impl
.get_version(), x
))
323 if highest_version
and (highest_version
.upstream_stability
or model
.testing
) is model
.testing
:
324 print highlight(' Highest version (%s) is still "testing"' % highest_version
.get_version(), 'YELLOW')
326 for homepage
in feed_obj
.get_metadata(namespaces
.XMLNS_IFACE
, 'homepage'):
327 check_exists(homepage
.content
)
329 for icon
in feed_obj
.get_metadata(namespaces
.XMLNS_IFACE
, 'icon'):
330 check_exists(icon
.getAttribute('href'))
332 except (urllib2
.HTTPError
, httplib
.BadStatusLine
, socket
.error
, ftplib
.error_perm
), ex
:
333 err_msg
= str(ex
).strip() or str(type(ex
))
334 error_new_line(' ' + err_msg
)
336 if options
.verbose
: traceback
.print_exc()
337 except SafeException
, ex
:
338 if options
.verbose
: raise
339 error_new_line(' ' + str(ex
))
345 print "\nERRORS FOUND:", n_errors