Ensure unpacked archive is world-readable, so that the privileged helper
[0publish-gui.git] / archive.py
blob3596db8fb6f2bf396a595923ea26986e5d3f48ab
1 from xml.dom import Node, minidom
2 import re
4 import rox, os, sys, urlparse, tempfile, shutil, time, urllib
5 from rox import g, tasks
6 import gtk.glade
8 from zeroinstall.injector import model
9 from zeroinstall.zerostore import unpack, manifest, NotStored
11 from logging import warn
12 import signing
13 from xmltools import *
14 import main
16 dotted_ints = '[0-9]+(.[0-9]+)*'
17 version_regexp = '[^a-zA-Z0-9](%s)(-(pre|rc|post|)%s)*' % (dotted_ints, dotted_ints)
19 def get_combo_value(combo):
20 i = combo.get_active()
21 m = combo.get_model()
22 return m[i][0]
24 watch = gtk.gdk.Cursor(gtk.gdk.WATCH)
26 def autopackage_get_details(package):
27 size = None
28 type = 'application/x-bzip-compressed-tar'
29 for line in file(package):
30 if line.startswith('export dataSize=') or line.startswith('export data_size='):
31 size = os.path.getsize(package) - int(line.split('"', 2)[1])
32 elif line.startswith('compression=') and 'lzma' in line:
33 type = 'application/x-lzma-compressed-tar'
34 if line.startswith('## END OF STUB'): break
35 if size is None:
36 raise Exception("Can't find payload in autopackage (missing 'dataSize')")
37 return size, type
39 def try_parse_version(version_str):
40 try:
41 return model.parse_version(version_str)
42 except SafeException, ex:
43 warn("Bad version", ex)
44 return None
46 class AddArchiveBox:
47 def __init__(self, feed_editor, local_archive = None):
48 self.feed_editor = feed_editor
49 self.tmpdir = None
50 self.mime_type = self.start_offset = None
52 widgets = gtk.glade.XML(main.gladefile, 'add_archive')
54 tree = widgets.get_widget('extract_list')
55 model = g.TreeStore(str)
56 tree.set_model(model)
57 selection = tree.get_selection()
58 selection.set_mode(g.SELECTION_BROWSE)
60 cell = g.CellRendererText()
61 col = g.TreeViewColumn('Extract', cell)
62 col.add_attribute(cell, 'text', 0)
63 tree.append_column(col)
65 dialog = widgets.get_widget('add_archive')
67 mime_type = widgets.get_widget('mime_type')
68 mime_type.set_active(0)
70 def local_archive_changed(chooser):
71 model.clear()
72 path = chooser.get_filename()
73 widgets.get_widget('subdirectory_frame').set_sensitive(False)
74 self.destroy_tmp()
75 if not path: return
77 if mime_type.get_active() == 0:
78 type = None
79 else:
80 type = mime_type.get_active_text()
82 archive_url = widgets.get_widget('archive_url')
83 url = archive_url.get_text()
84 if not url:
85 url = 'http://SITE/' + os.path.basename(path)
86 archive_url.set_text(url)
88 start_offset = 0
89 if not type:
90 if url.endswith('.package'):
91 type = 'Autopackage'
92 else:
93 type = unpack.type_from_url(url)
95 if type == 'Autopackage':
96 # Autopackage isn't a real type. Examine the .package file
97 # and find out what it really is.
98 start_offset, type = autopackage_get_details(path)
100 self.tmpdir = tempfile.mkdtemp('-0publish-gui')
101 try:
102 # Must be readable to helper process running as 'zeroinst'...
103 old_umask = os.umask(0022)
104 try:
105 unpack_dir = os.path.join(self.tmpdir, 'unpacked')
106 os.mkdir(unpack_dir)
108 dialog.window.set_cursor(watch)
109 gtk.gdk.flush()
110 try:
111 unpack.unpack_archive(url, file(path), unpack_dir,
112 type = type, start_offset = start_offset)
113 finally:
114 dialog.window.set_cursor(None)
115 finally:
116 os.umask(old_umask)
117 except:
118 chooser.unselect_filename(path)
119 self.destroy_tmp()
120 raise
121 iter = model.append(None, ['Everything'])
122 items = os.listdir(unpack_dir)
123 for f in items:
124 model.append(iter, [f])
125 tree.expand_all()
126 # Choose a sensible default
127 iter = model.get_iter_root()
128 if len(items) == 1 and \
129 os.path.isdir(os.path.join(unpack_dir, items[0])) and \
130 items[0] not in ('usr', 'opt', 'bin', 'etc', 'sbin', 'doc', 'var'):
131 iter = model.iter_children(iter)
132 selection.select_iter(iter)
134 self.mime_type = type
135 self.start_offset = start_offset
136 widgets.get_widget('subdirectory_frame').set_sensitive(True)
138 local_archive_button = widgets.get_widget('local_archive')
139 local_archive_button.connect('selection-changed', local_archive_changed)
140 widgets.get_widget('subdirectory_frame').set_sensitive(False)
142 def download(button):
143 url = widgets.get_widget('archive_url').get_text()
144 if not url:
145 raise Exception("Enter a URL to download from!")
147 chooser = g.FileChooserDialog('Save archive as...', dialog, g.FILE_CHOOSER_ACTION_SAVE)
148 chooser.set_current_name(os.path.basename(url))
149 chooser.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
150 chooser.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
151 chooser.set_default_response(g.RESPONSE_OK)
152 resp = chooser.run()
153 filename = chooser.get_filename()
154 chooser.destroy()
155 if resp != g.RESPONSE_OK:
156 return
158 DownloadBox(url, filename, local_archive_button)
160 widgets.get_widget('download').connect('clicked', download)
162 def resp(dialog, r):
163 if r == g.RESPONSE_OK:
164 unpack_dir = os.path.join(self.tmpdir, 'unpacked')
166 url = widgets.get_widget('archive_url').get_text()
167 if urlparse.urlparse(url)[1] == '':
168 raise Exception('Missing host name in URL "%s"' % url)
169 if urlparse.urlparse(url)[2] == '':
170 raise Exception('Missing resource part in URL "%s"' % url)
171 local_archive = widgets.get_widget('local_archive').get_filename()
172 if not local_archive:
173 raise Exception('Please select a local file')
174 if selection.iter_is_selected(model.get_iter_root()):
175 root = unpack_dir
176 extract = None
177 else:
178 _, iter = selection.get_selected()
179 extract = model[iter][0]
180 root = os.path.join(unpack_dir, extract)
182 size = os.path.getsize(local_archive)
183 if self.start_offset:
184 size -= self.start_offset
185 self.create_archive_element(url, self.mime_type, root, extract, size,
186 self.start_offset)
187 self.destroy_tmp()
188 dialog.destroy()
190 dialog.connect('response', resp)
192 if local_archive:
193 local_archive_button.set_filename(local_archive)
194 initial_url = 'http://SITE/' + os.path.basename(local_archive)
195 widgets.get_widget('archive_url').set_text(initial_url)
197 def destroy_tmp(self):
198 if self.tmpdir:
199 shutil.rmtree(self.tmpdir)
200 self.tmpdir = None
202 def create_archive_element(self, url, mime_type, root, extract, size, start_offset):
203 alg = manifest.get_algorithm('sha1new')
204 digest = alg.new_digest()
205 for line in alg.generate_manifest(root):
206 digest.update(line + '\n')
207 id = alg.getID(digest)
209 # Add it to the cache if missing
210 # Helps with setting 'main' attribute later
211 try:
212 main.stores.lookup(id)
213 except NotStored:
214 main.stores.add_dir_to_cache(id, root)
216 # Do we already have an implementation with this digest?
217 impl_element = self.feed_editor.find_implementation(id)
219 if impl_element is None:
220 # No. Create a new implementation. Guess the details...
222 leaf = url.split('/')[-1]
223 version = None
224 for m in re.finditer(version_regexp, leaf):
225 match = m.group()[1:]
226 if version is None or len(version) < len(match):
227 version = match
229 existing_versions = self.feed_editor.list_versions()
230 older_versions = []
231 if existing_versions and version:
232 parsed_version = try_parse_version(version)
233 if parsed_version:
234 older_versions = [(v, elem) for v, elem in existing_versions if v < parsed_version]
236 impl_element = self.feed_editor.doc.createElementNS(XMLNS_INTERFACE, 'implementation')
238 if older_versions:
239 # Try to add it just after the previous version's element in the XML document
240 insert_after(impl_element, max(older_versions)[1])
241 elif existing_versions:
242 # Else add it before the first
243 insert_before(impl_element, min(existing_versions)[1])
244 else:
245 # Put it in the root
246 insert_element(impl_element, self.feed_editor.doc.documentElement)
248 impl_element.setAttribute('id', id)
249 impl_element.setAttribute('released', time.strftime('%Y-%m-%d'))
250 if version: impl_element.setAttribute('version', version)
251 created_impl = True
252 else:
253 created_impl = False
255 archive_element = create_element(impl_element, 'archive')
256 archive_element.setAttribute('size', str(size))
257 archive_element.setAttribute('href', url)
258 if extract: archive_element.setAttribute('extract', extract)
259 if mime_type: archive_element.setAttribute('type', mime_type)
260 if start_offset: archive_element.setAttribute('start-offset', str(start_offset))
262 self.feed_editor.update_version_model()
264 if created_impl:
265 self.feed_editor.edit_properties(element = impl_element)
267 class DownloadBox:
268 def __init__(self, url, path, archive_button):
269 widgets = gtk.glade.XML(main.gladefile, 'download')
270 gtk.gdk.flush()
272 output = file(path, 'w')
274 dialog = widgets.get_widget('download')
275 progress = widgets.get_widget('progress')
277 cancelled = tasks.Blocker()
278 def resp(box, resp):
279 cancelled.trigger()
280 dialog.connect('response', resp)
282 def download():
283 stream = None
285 def cleanup():
286 dialog.destroy()
287 if output:
288 output.close()
289 if stream:
290 stream.close()
292 try:
293 # (urllib2 is buggy; no fileno)
294 stream = urllib.urlopen(url)
295 size = float(stream.info().get('Content-Length', None))
296 got = 0
298 while True:
299 yield signing.InputBlocker(stream), cancelled
300 if cancelled.happened:
301 raise Exception("Download cancelled at user's request")
302 data = os.read(stream.fileno(), 1024)
303 if not data: break
304 output.write(data)
305 got += len(data)
307 if size:
308 progress.set_fraction(got / size)
309 else:
310 progress.pulse()
311 except:
312 # No finally in python 2.4
313 cleanup()
314 raise
315 else:
316 cleanup()
317 archive_button.set_filename(path)
319 tasks.Task(download())