chiark / gitweb /
metadata: Always use regex to verify values
[fdroidserver.git] / fdroidserver / metadata.py
1 # -*- coding: utf-8 -*-
2 #
3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import json
21 import os
22 import re
23 import glob
24 import cgi
25 import textwrap
26
27 try:
28     from cStringIO import StringIO
29 except:
30     from StringIO import StringIO
31
32 import yaml
33 # use libyaml if it is available
34 try:
35     from yaml import CLoader
36     YamlLoader = CLoader
37 except ImportError:
38     from yaml import Loader
39     YamlLoader = Loader
40
41 # use the C implementation when available
42 import xml.etree.cElementTree as ElementTree
43
44 import common
45
46 srclibs = None
47
48
49 class MetaDataException(Exception):
50
51     def __init__(self, value):
52         self.value = value
53
54     def __str__(self):
55         return self.value
56
57 # To filter which ones should be written to the metadata files if
58 # present
59 app_fields = set([
60     'Disabled',
61     'AntiFeatures',
62     'Provides',
63     'Categories',
64     'License',
65     'Author Name',
66     'Author Email',
67     'Web Site',
68     'Source Code',
69     'Issue Tracker',
70     'Changelog',
71     'Donate',
72     'FlattrID',
73     'Bitcoin',
74     'Litecoin',
75     'Name',
76     'Auto Name',
77     'Summary',
78     'Description',
79     'Requires Root',
80     'Repo Type',
81     'Repo',
82     'Binaries',
83     'Maintainer Notes',
84     'Archive Policy',
85     'Auto Update Mode',
86     'Update Check Mode',
87     'Update Check Ignore',
88     'Vercode Operation',
89     'Update Check Name',
90     'Update Check Data',
91     'Current Version',
92     'Current Version Code',
93     'No Source Since',
94
95     'comments',  # For formats that don't do inline comments
96     'builds',    # For formats that do builds as a list
97 ])
98
99
100 class App():
101
102     def __init__(self):
103         self.Disabled = None
104         self.AntiFeatures = []
105         self.Provides = None
106         self.Categories = ['None']
107         self.License = 'Unknown'
108         self.AuthorName = None
109         self.AuthorEmail = None
110         self.WebSite = ''
111         self.SourceCode = ''
112         self.IssueTracker = ''
113         self.Changelog = ''
114         self.Donate = None
115         self.FlattrID = None
116         self.Bitcoin = None
117         self.Litecoin = None
118         self.Name = None
119         self.AutoName = ''
120         self.Summary = ''
121         self.Description = ''
122         self.RequiresRoot = False
123         self.RepoType = ''
124         self.Repo = ''
125         self.Binaries = None
126         self.MaintainerNotes = ''
127         self.ArchivePolicy = None
128         self.AutoUpdateMode = 'None'
129         self.UpdateCheckMode = 'None'
130         self.UpdateCheckIgnore = None
131         self.VercodeOperation = None
132         self.UpdateCheckName = None
133         self.UpdateCheckData = None
134         self.CurrentVersion = ''
135         self.CurrentVersionCode = '0'
136         self.NoSourceSince = ''
137
138         self.id = None
139         self.metadatapath = None
140         self.builds = []
141         self.comments = {}
142         self.added = None
143         self.lastupdated = None
144         self._modified = set()
145
146     # Translates human-readable field names to attribute names, e.g.
147     # 'Auto Name' to 'AutoName'
148     @classmethod
149     def field_to_attr(cls, f):
150         return f.replace(' ', '')
151
152     # Translates attribute names to human-readable field names, e.g.
153     # 'AutoName' to 'Auto Name'
154     @classmethod
155     def attr_to_field(cls, k):
156         if k in app_fields:
157             return k
158         f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
159         return f
160
161     # Constructs an old-fashioned dict with the human-readable field
162     # names. Should only be used for tests.
163     def field_dict(self):
164         d = {}
165         for k, v in self.__dict__.iteritems():
166             if k == 'builds':
167                 d['builds'] = []
168                 for build in v:
169                     b = {k: v for k, v in build.__dict__.iteritems() if not k.startswith('_')}
170                     d['builds'].append(b)
171             elif not k.startswith('_'):
172                 f = App.attr_to_field(k)
173                 d[f] = v
174         return d
175
176     # Gets the value associated to a field name, e.g. 'Auto Name'
177     def get_field(self, f):
178         if f not in app_fields:
179             raise MetaDataException('Unrecognised app field: ' + f)
180         k = App.field_to_attr(f)
181         return getattr(self, k)
182
183     # Sets the value associated to a field name, e.g. 'Auto Name'
184     def set_field(self, f, v):
185         if f not in app_fields:
186             raise MetaDataException('Unrecognised app field: ' + f)
187         k = App.field_to_attr(f)
188         self.__dict__[k] = v
189         self._modified.add(k)
190
191     # Appends to the value associated to a field name, e.g. 'Auto Name'
192     def append_field(self, f, v):
193         if f not in app_fields:
194             raise MetaDataException('Unrecognised app field: ' + f)
195         k = App.field_to_attr(f)
196         if k not in self.__dict__:
197             self.__dict__[k] = [v]
198         else:
199             self.__dict__[k].append(v)
200
201     # Like dict.update(), but using human-readable field names
202     def update_fields(self, d):
203         for f, v in d.iteritems():
204             if f == 'builds':
205                 for b in v:
206                     build = Build()
207                     build.update_flags(b)
208                     self.builds.append(build)
209             else:
210                 self.set_field(f, v)
211
212 TYPE_UNKNOWN = 0
213 TYPE_OBSOLETE = 1
214 TYPE_STRING = 2
215 TYPE_BOOL = 3
216 TYPE_LIST = 4
217 TYPE_SCRIPT = 5
218 TYPE_MULTILINE = 6
219 TYPE_BUILD = 7
220 TYPE_BUILD_V2 = 8
221
222 fieldtypes = {
223     'Description': TYPE_MULTILINE,
224     'Maintainer Notes': TYPE_MULTILINE,
225     'Categories': TYPE_LIST,
226     'AntiFeatures': TYPE_LIST,
227     'Build Version': TYPE_BUILD,
228     'Build': TYPE_BUILD_V2,
229     'Use Built': TYPE_OBSOLETE,
230 }
231
232
233 def fieldtype(name):
234     if name in fieldtypes:
235         return fieldtypes[name]
236     return TYPE_STRING
237
238
239 # In the order in which they are laid out on files
240 build_flags_order = [
241     'disable',
242     'commit',
243     'subdir',
244     'submodules',
245     'init',
246     'patch',
247     'gradle',
248     'maven',
249     'kivy',
250     'output',
251     'srclibs',
252     'oldsdkloc',
253     'encoding',
254     'forceversion',
255     'forcevercode',
256     'rm',
257     'extlibs',
258     'prebuild',
259     'update',
260     'target',
261     'scanignore',
262     'scandelete',
263     'build',
264     'buildjni',
265     'ndk',
266     'preassemble',
267     'gradleprops',
268     'antcommands',
269     'novcheck',
270 ]
271
272
273 build_flags = set(build_flags_order + ['version', 'vercode'])
274
275
276 class Build():
277
278     def __init__(self):
279         self.disable = False
280         self.commit = None
281         self.subdir = None
282         self.submodules = False
283         self.init = ''
284         self.patch = []
285         self.gradle = []
286         self.maven = False
287         self.kivy = False
288         self.output = None
289         self.srclibs = []
290         self.oldsdkloc = False
291         self.encoding = None
292         self.forceversion = False
293         self.forcevercode = False
294         self.rm = []
295         self.extlibs = []
296         self.prebuild = ''
297         self.update = []
298         self.target = None
299         self.scanignore = []
300         self.scandelete = []
301         self.build = ''
302         self.buildjni = []
303         self.ndk = None
304         self.preassemble = []
305         self.gradleprops = []
306         self.antcommands = []
307         self.novcheck = False
308
309         self._modified = set()
310
311     def get_flag(self, f):
312         if f not in build_flags:
313             raise MetaDataException('Unrecognised build flag: ' + f)
314         return getattr(self, f)
315
316     def set_flag(self, f, v):
317         if f == 'versionName':
318             f = 'version'
319         if f == 'versionCode':
320             f = 'vercode'
321         if f not in build_flags:
322             raise MetaDataException('Unrecognised build flag: ' + f)
323         self.__dict__[f] = v
324         self._modified.add(f)
325
326     def append_flag(self, f, v):
327         if f not in build_flags:
328             raise MetaDataException('Unrecognised build flag: ' + f)
329         if f not in self.__dict__:
330             self.__dict__[f] = [v]
331         else:
332             self.__dict__[f].append(v)
333
334     def method(self):
335         for f in ['maven', 'gradle', 'kivy']:
336             if self.get_flag(f):
337                 return f
338         if self.output:
339             return 'raw'
340         return 'ant'
341
342     def ndk_path(self):
343         version = self.ndk
344         if not version:
345             version = 'r10e'  # falls back to latest
346         paths = common.config['ndk_paths']
347         if version not in paths:
348             return ''
349         return paths[version]
350
351     def update_flags(self, d):
352         for f, v in d.iteritems():
353             self.set_flag(f, v)
354
355 flagtypes = {
356     'extlibs': TYPE_LIST,
357     'srclibs': TYPE_LIST,
358     'patch': TYPE_LIST,
359     'rm': TYPE_LIST,
360     'buildjni': TYPE_LIST,
361     'preassemble': TYPE_LIST,
362     'update': TYPE_LIST,
363     'scanignore': TYPE_LIST,
364     'scandelete': TYPE_LIST,
365     'gradle': TYPE_LIST,
366     'antcommands': TYPE_LIST,
367     'gradleprops': TYPE_LIST,
368     'init': TYPE_SCRIPT,
369     'prebuild': TYPE_SCRIPT,
370     'build': TYPE_SCRIPT,
371     'submodules': TYPE_BOOL,
372     'oldsdkloc': TYPE_BOOL,
373     'forceversion': TYPE_BOOL,
374     'forcevercode': TYPE_BOOL,
375     'novcheck': TYPE_BOOL,
376 }
377
378
379 def flagtype(name):
380     if name in flagtypes:
381         return flagtypes[name]
382     return TYPE_STRING
383
384
385 # Designates a metadata field type and checks that it matches
386 #
387 # 'name'     - The long name of the field type
388 # 'matching' - List of possible values or regex expression
389 # 'sep'      - Separator to use if value may be a list
390 # 'fields'   - Metadata fields (Field:Value) of this type
391 # 'flags'    - Build flags (flag=value) of this type
392 #
393 class FieldValidator():
394
395     def __init__(self, name, matching, fields, flags):
396         self.name = name
397         self.matching = matching
398         self.compiled = re.compile(matching)
399         self.fields = fields
400         self.flags = flags
401
402     def check(self, v, appid):
403         if not v:
404             return
405         if type(v) == list:
406             values = v
407         else:
408             values = [v]
409         for v in values:
410             if not self.compiled.match(v):
411                 raise MetaDataException("'%s' is not a valid %s in %s. Regex pattern: %s"
412                                         % (v, self.name, appid, self.matching))
413
414 # Generic value types
415 valuetypes = {
416     FieldValidator("Integer",
417                    r'^[1-9][0-9]*$',
418                    [],
419                    ['vercode']),
420
421     FieldValidator("Hexadecimal",
422                    r'^[0-9a-f]+$',
423                    ['FlattrID'],
424                    []),
425
426     FieldValidator("HTTP link",
427                    r'^http[s]?://',
428                    ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
429
430     FieldValidator("Email",
431                    r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
432                    ["AuthorEmail"], []),
433
434     FieldValidator("Bitcoin address",
435                    r'^[a-zA-Z0-9]{27,34}$',
436                    ["Bitcoin"],
437                    []),
438
439     FieldValidator("Litecoin address",
440                    r'^L[a-zA-Z0-9]{33}$',
441                    ["Litecoin"],
442                    []),
443
444     FieldValidator("Repo Type",
445                    r'^(git|git-svn|svn|hg|bzr|srclib)$',
446                    ["RepoType"],
447                    []),
448
449     FieldValidator("Binaries",
450                    r'^http[s]?://',
451                    ["Binaries"],
452                    []),
453
454     FieldValidator("Archive Policy",
455                    r'^[0-9]+ versions$',
456                    ["ArchivePolicy"],
457                    []),
458
459     FieldValidator("Anti-Feature",
460                    r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets)$',
461                    ["AntiFeatures"],
462                    []),
463
464     FieldValidator("Auto Update Mode",
465                    r"^(Version .+|None)$",
466                    ["AutoUpdateMode"],
467                    []),
468
469     FieldValidator("Update Check Mode",
470                    r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
471                    ["UpdateCheckMode"],
472                    [])
473 }
474
475
476 # Check an app's metadata information for integrity errors
477 def check_metadata(app):
478     for v in valuetypes:
479         for k in v.fields:
480             if k not in app._modified:
481                 continue
482             v.check(app.__dict__[k], app.id)
483         for build in app.builds:
484             for k in v.flags:
485                 if k not in build._modified:
486                     continue
487                 v.check(build.__dict__[k], app.id)
488
489
490 # Formatter for descriptions. Create an instance, and call parseline() with
491 # each line of the description source from the metadata. At the end, call
492 # end() and then text_txt and text_html will contain the result.
493 class DescriptionFormatter:
494
495     stNONE = 0
496     stPARA = 1
497     stUL = 2
498     stOL = 3
499
500     def __init__(self, linkres):
501         self.bold = False
502         self.ital = False
503         self.state = self.stNONE
504         self.laststate = self.stNONE
505         self.text_html = ''
506         self.text_txt = ''
507         self.html = StringIO()
508         self.text = StringIO()
509         self.para_lines = []
510         self.linkResolver = None
511         self.linkResolver = linkres
512
513     def endcur(self, notstates=None):
514         if notstates and self.state in notstates:
515             return
516         if self.state == self.stPARA:
517             self.endpara()
518         elif self.state == self.stUL:
519             self.endul()
520         elif self.state == self.stOL:
521             self.endol()
522
523     def endpara(self):
524         self.laststate = self.state
525         self.state = self.stNONE
526         whole_para = ' '.join(self.para_lines)
527         self.addtext(whole_para)
528         self.text.write(textwrap.fill(whole_para, 80,
529                                       break_long_words=False,
530                                       break_on_hyphens=False))
531         self.html.write('</p>')
532         del self.para_lines[:]
533
534     def endul(self):
535         self.html.write('</ul>')
536         self.laststate = self.state
537         self.state = self.stNONE
538
539     def endol(self):
540         self.html.write('</ol>')
541         self.laststate = self.state
542         self.state = self.stNONE
543
544     def formatted(self, txt, html):
545         res = ''
546         if html:
547             txt = cgi.escape(txt)
548         while True:
549             index = txt.find("''")
550             if index == -1:
551                 return res + txt
552             res += txt[:index]
553             txt = txt[index:]
554             if txt.startswith("'''"):
555                 if html:
556                     if self.bold:
557                         res += '</b>'
558                     else:
559                         res += '<b>'
560                 self.bold = not self.bold
561                 txt = txt[3:]
562             else:
563                 if html:
564                     if self.ital:
565                         res += '</i>'
566                     else:
567                         res += '<i>'
568                 self.ital = not self.ital
569                 txt = txt[2:]
570
571     def linkify(self, txt):
572         res_plain = ''
573         res_html = ''
574         while True:
575             index = txt.find("[")
576             if index == -1:
577                 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
578             res_plain += self.formatted(txt[:index], False)
579             res_html += self.formatted(txt[:index], True)
580             txt = txt[index:]
581             if txt.startswith("[["):
582                 index = txt.find("]]")
583                 if index == -1:
584                     raise MetaDataException("Unterminated ]]")
585                 url = txt[2:index]
586                 if self.linkResolver:
587                     url, urltext = self.linkResolver(url)
588                 else:
589                     urltext = url
590                 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
591                 res_plain += urltext
592                 txt = txt[index + 2:]
593             else:
594                 index = txt.find("]")
595                 if index == -1:
596                     raise MetaDataException("Unterminated ]")
597                 url = txt[1:index]
598                 index2 = url.find(' ')
599                 if index2 == -1:
600                     urltxt = url
601                 else:
602                     urltxt = url[index2 + 1:]
603                     url = url[:index2]
604                     if url == urltxt:
605                         raise MetaDataException("Url title is just the URL - use [url]")
606                 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
607                 res_plain += urltxt
608                 if urltxt != url:
609                     res_plain += ' (' + url + ')'
610                 txt = txt[index + 1:]
611
612     def addtext(self, txt):
613         p, h = self.linkify(txt)
614         self.html.write(h)
615
616     def parseline(self, line):
617         if not line:
618             self.endcur()
619         elif line.startswith('* '):
620             self.endcur([self.stUL])
621             if self.state != self.stUL:
622                 self.html.write('<ul>')
623                 self.state = self.stUL
624                 if self.laststate != self.stNONE:
625                     self.text.write('\n\n')
626             else:
627                 self.text.write('\n')
628             self.text.write(line)
629             self.html.write('<li>')
630             self.addtext(line[1:])
631             self.html.write('</li>')
632         elif line.startswith('# '):
633             self.endcur([self.stOL])
634             if self.state != self.stOL:
635                 self.html.write('<ol>')
636                 self.state = self.stOL
637                 if self.laststate != self.stNONE:
638                     self.text.write('\n\n')
639             else:
640                 self.text.write('\n')
641             self.text.write(line)
642             self.html.write('<li>')
643             self.addtext(line[1:])
644             self.html.write('</li>')
645         else:
646             self.para_lines.append(line)
647             self.endcur([self.stPARA])
648             if self.state == self.stNONE:
649                 self.state = self.stPARA
650                 if self.laststate != self.stNONE:
651                     self.text.write('\n\n')
652                 self.html.write('<p>')
653
654     def end(self):
655         self.endcur()
656         self.text_txt = self.text.getvalue()
657         self.text_html = self.html.getvalue()
658         self.text.close()
659         self.html.close()
660
661
662 # Parse multiple lines of description as written in a metadata file, returning
663 # a single string in text format and wrapped to 80 columns.
664 def description_txt(s):
665     ps = DescriptionFormatter(None)
666     for line in s.splitlines():
667         ps.parseline(line)
668     ps.end()
669     return ps.text_txt
670
671
672 # Parse multiple lines of description as written in a metadata file, returning
673 # a single string in wiki format. Used for the Maintainer Notes field as well,
674 # because it's the same format.
675 def description_wiki(s):
676     return s
677
678
679 # Parse multiple lines of description as written in a metadata file, returning
680 # a single string in HTML format.
681 def description_html(s, linkres):
682     ps = DescriptionFormatter(linkres)
683     for line in s.splitlines():
684         ps.parseline(line)
685     ps.end()
686     return ps.text_html
687
688
689 def parse_srclib(metadatapath):
690
691     thisinfo = {}
692
693     # Defaults for fields that come from metadata
694     thisinfo['Repo Type'] = ''
695     thisinfo['Repo'] = ''
696     thisinfo['Subdir'] = None
697     thisinfo['Prepare'] = None
698
699     if not os.path.exists(metadatapath):
700         return thisinfo
701
702     metafile = open(metadatapath, "r")
703
704     n = 0
705     for line in metafile:
706         n += 1
707         line = line.rstrip('\r\n')
708         if not line or line.startswith("#"):
709             continue
710
711         try:
712             f, v = line.split(':', 1)
713         except ValueError:
714             raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
715
716         if f == "Subdir":
717             thisinfo[f] = v.split(',')
718         else:
719             thisinfo[f] = v
720
721     metafile.close()
722
723     return thisinfo
724
725
726 def read_srclibs():
727     """Read all srclib metadata.
728
729     The information read will be accessible as metadata.srclibs, which is a
730     dictionary, keyed on srclib name, with the values each being a dictionary
731     in the same format as that returned by the parse_srclib function.
732
733     A MetaDataException is raised if there are any problems with the srclib
734     metadata.
735     """
736     global srclibs
737
738     # They were already loaded
739     if srclibs is not None:
740         return
741
742     srclibs = {}
743
744     srcdir = 'srclibs'
745     if not os.path.exists(srcdir):
746         os.makedirs(srcdir)
747
748     for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
749         srclibname = os.path.basename(metadatapath[:-4])
750         srclibs[srclibname] = parse_srclib(metadatapath)
751
752
753 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
754 # returned by the parse_txt_metadata function.
755 def read_metadata(xref=True):
756
757     # Always read the srclibs before the apps, since they can use a srlib as
758     # their source repository.
759     read_srclibs()
760
761     apps = {}
762
763     for basedir in ('metadata', 'tmp'):
764         if not os.path.exists(basedir):
765             os.makedirs(basedir)
766
767     # If there are multiple metadata files for a single appid, then the first
768     # file that is parsed wins over all the others, and the rest throw an
769     # exception. So the original .txt format is parsed first, at least until
770     # newer formats stabilize.
771
772     for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
773                                + glob.glob(os.path.join('metadata', '*.json'))
774                                + glob.glob(os.path.join('metadata', '*.xml'))
775                                + glob.glob(os.path.join('metadata', '*.yaml'))):
776         app = parse_metadata(metadatapath)
777         if app.id in apps:
778             raise MetaDataException("Found multiple metadata files for " + app.id)
779         check_metadata(app)
780         apps[app.id] = app
781
782     if xref:
783         # Parse all descriptions at load time, just to ensure cross-referencing
784         # errors are caught early rather than when they hit the build server.
785         def linkres(appid):
786             if appid in apps:
787                 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
788             raise MetaDataException("Cannot resolve app id " + appid)
789
790         for appid, app in apps.iteritems():
791             try:
792                 description_html(app.Description, linkres)
793             except MetaDataException as e:
794                 raise MetaDataException("Problem with description of " + appid +
795                                         " - " + str(e))
796
797     return apps
798
799 # Port legacy ';' separators
800 list_sep = re.compile(r'[,;]')
801
802
803 def split_list_values(s):
804     res = []
805     for v in re.split(list_sep, s):
806         if not v:
807             continue
808         v = v.strip()
809         if not v:
810             continue
811         res.append(v)
812     return res
813
814
815 def get_default_app_info(metadatapath=None):
816     if metadatapath is None:
817         appid = None
818     else:
819         appid, _ = common.get_extension(os.path.basename(metadatapath))
820
821     app = App()
822     app.metadatapath = metadatapath
823     if appid is not None:
824         app.id = appid
825
826     return app
827
828
829 def sorted_builds(builds):
830     return sorted(builds, key=lambda build: int(build.vercode))
831
832
833 esc_newlines = re.compile(r'\\( |\n)')
834
835
836 # This function uses __dict__ to be faster
837 def post_metadata_parse(app):
838
839     for k, v in app.__dict__.iteritems():
840         if k not in app._modified:
841             continue
842         if type(v) in (float, int):
843             app.__dict__[k] = str(v)
844
845     for build in app.builds:
846         for k, v in build.__dict__.iteritems():
847
848             if k not in build._modified:
849                 continue
850             if type(v) in (float, int):
851                 build.__dict__[k] = str(v)
852                 continue
853             ftype = flagtype(k)
854
855             if ftype == TYPE_SCRIPT:
856                 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
857             elif ftype == TYPE_BOOL:
858                 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
859                 if isinstance(v, basestring):
860                     build.__dict__[k] = _decode_bool(v)
861             elif ftype == TYPE_STRING:
862                 if isinstance(v, bool) and v:
863                     build.__dict__[k] = 'yes'
864
865     if not app.Description:
866         app.Description = 'No description available'
867
868     app.builds = sorted_builds(app.builds)
869
870
871 # Parse metadata for a single application.
872 #
873 #  'metadatapath' - the filename to read. The package id for the application comes
874 #               from this filename. Pass None to get a blank entry.
875 #
876 # Returns a dictionary containing all the details of the application. There are
877 # two major kinds of information in the dictionary. Keys beginning with capital
878 # letters correspond directory to identically named keys in the metadata file.
879 # Keys beginning with lower case letters are generated in one way or another,
880 # and are not found verbatim in the metadata.
881 #
882 # Known keys not originating from the metadata are:
883 #
884 #  'builds'           - a list of dictionaries containing build information
885 #                       for each defined build
886 #  'comments'         - a list of comments from the metadata file. Each is
887 #                       a list of the form [field, comment] where field is
888 #                       the name of the field it preceded in the metadata
889 #                       file. Where field is None, the comment goes at the
890 #                       end of the file. Alternatively, 'build:version' is
891 #                       for a comment before a particular build version.
892 #  'descriptionlines' - original lines of description as formatted in the
893 #                       metadata file.
894 #
895
896
897 def _decode_list(data):
898     '''convert items in a list from unicode to basestring'''
899     rv = []
900     for item in data:
901         if isinstance(item, unicode):
902             item = item.encode('utf-8')
903         elif isinstance(item, list):
904             item = _decode_list(item)
905         elif isinstance(item, dict):
906             item = _decode_dict(item)
907         rv.append(item)
908     return rv
909
910
911 def _decode_dict(data):
912     '''convert items in a dict from unicode to basestring'''
913     rv = {}
914     for k, v in data.iteritems():
915         if isinstance(k, unicode):
916             k = k.encode('utf-8')
917         if isinstance(v, unicode):
918             v = v.encode('utf-8')
919         elif isinstance(v, list):
920             v = _decode_list(v)
921         elif isinstance(v, dict):
922             v = _decode_dict(v)
923         rv[k] = v
924     return rv
925
926
927 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
928 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
929
930
931 def _decode_bool(s):
932     if bool_true.match(s):
933         return True
934     if bool_false.match(s):
935         return False
936     raise MetaDataException("Invalid bool '%s'" % s)
937
938
939 def parse_metadata(metadatapath):
940     _, ext = common.get_extension(metadatapath)
941     accepted = common.config['accepted_formats']
942     if ext not in accepted:
943         raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
944             metadatapath, ', '.join(accepted)))
945
946     app = App()
947     app.metadatapath = metadatapath
948     app.id, _ = common.get_extension(os.path.basename(metadatapath))
949
950     with open(metadatapath, 'r') as mf:
951         if ext == 'txt':
952             parse_txt_metadata(mf, app)
953         elif ext == 'json':
954             parse_json_metadata(mf, app)
955         elif ext == 'xml':
956             parse_xml_metadata(mf, app)
957         elif ext == 'yaml':
958             parse_yaml_metadata(mf, app)
959         else:
960             raise MetaDataException('Unknown metadata format: %s' % metadatapath)
961
962     post_metadata_parse(app)
963     return app
964
965
966 def parse_json_metadata(mf, app):
967
968     # fdroid metadata is only strings and booleans, no floats or ints. And
969     # json returns unicode, and fdroidserver still uses plain python strings
970     # TODO create schema using https://pypi.python.org/pypi/jsonschema
971     jsoninfo = json.load(mf, object_hook=_decode_dict,
972                          parse_int=lambda s: s,
973                          parse_float=lambda s: s)
974     app.update_fields(jsoninfo)
975     for f in ['Description', 'Maintainer Notes']:
976         v = app.get_field(f)
977         app.set_field(f, '\n'.join(v))
978     return app
979
980
981 def parse_xml_metadata(mf, app):
982
983     tree = ElementTree.ElementTree(file=mf)
984     root = tree.getroot()
985
986     if root.tag != 'resources':
987         raise MetaDataException('resources file does not have root element <resources/>')
988
989     for child in root:
990         if child.tag != 'builds':
991             # builds does not have name="" attrib
992             name = child.attrib['name']
993
994         if child.tag == 'string':
995             app.set_field(name, child.text)
996         elif child.tag == 'string-array':
997             for item in child:
998                 app.append_field(name, item.text)
999         elif child.tag == 'builds':
1000             for b in child:
1001                 build = Build()
1002                 for key in b:
1003                     build.set_flag(key.tag, key.text)
1004                 app.builds.append(build)
1005
1006     # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1007     if not isinstance(app.RequiresRoot, bool):
1008         app.RequiresRoot = app.RequiresRoot == 'true'
1009
1010     return app
1011
1012
1013 def parse_yaml_metadata(mf, app):
1014
1015     yamlinfo = yaml.load(mf, Loader=YamlLoader)
1016     app.update_fields(yamlinfo)
1017     return app
1018
1019
1020 build_line_sep = re.compile(r'(?<!\\),')
1021 build_cont = re.compile(r'^[ \t]')
1022
1023
1024 def parse_txt_metadata(mf, app):
1025
1026     linedesc = None
1027
1028     def add_buildflag(p, build):
1029         if not p.strip():
1030             raise MetaDataException("Empty build flag at {1}"
1031                                     .format(buildlines[0], linedesc))
1032         bv = p.split('=', 1)
1033         if len(bv) != 2:
1034             raise MetaDataException("Invalid build flag at {0} in {1}"
1035                                     .format(buildlines[0], linedesc))
1036
1037         pk, pv = bv
1038         pk = pk.lstrip()
1039         t = flagtype(pk)
1040         if t == TYPE_LIST:
1041             pv = split_list_values(pv)
1042             build.set_flag(pk, pv)
1043         elif t == TYPE_STRING or t == TYPE_SCRIPT:
1044             build.set_flag(pk, pv)
1045         elif t == TYPE_BOOL:
1046             build.set_flag(pk, _decode_bool(pv))
1047
1048     def parse_buildline(lines):
1049         v = "".join(lines)
1050         parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1051         if len(parts) < 3:
1052             raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
1053         build = Build()
1054         build.version = parts[0]
1055         build.vercode = parts[1]
1056         if parts[2].startswith('!'):
1057             # For backwards compatibility, handle old-style disabling,
1058             # including attempting to extract the commit from the message
1059             build.disable = parts[2][1:]
1060             commit = 'unknown - see disabled'
1061             index = parts[2].rfind('at ')
1062             if index != -1:
1063                 commit = parts[2][index + 3:]
1064                 if commit.endswith(')'):
1065                     commit = commit[:-1]
1066             build.commit = commit
1067         else:
1068             build.commit = parts[2]
1069         for p in parts[3:]:
1070             add_buildflag(p, build)
1071
1072         return build
1073
1074     def add_comments(key):
1075         if not curcomments:
1076             return
1077         app.comments[key] = list(curcomments)
1078         del curcomments[:]
1079
1080     mode = 0
1081     buildlines = []
1082     multiline_lines = []
1083     curcomments = []
1084     build = None
1085     vc_seen = set()
1086
1087     c = 0
1088     for line in mf:
1089         c += 1
1090         linedesc = "%s:%d" % (mf.name, c)
1091         line = line.rstrip('\r\n')
1092         if mode == 3:
1093             if build_cont.match(line):
1094                 if line.endswith('\\'):
1095                     buildlines.append(line[:-1].lstrip())
1096                 else:
1097                     buildlines.append(line.lstrip())
1098                     bl = ''.join(buildlines)
1099                     add_buildflag(bl, build)
1100                     del buildlines[:]
1101             else:
1102                 if not build.commit and not build.disable:
1103                     raise MetaDataException("No commit specified for {0} in {1}"
1104                                             .format(build.version, linedesc))
1105
1106                 app.builds.append(build)
1107                 add_comments('build:' + build.vercode)
1108                 mode = 0
1109
1110         if mode == 0:
1111             if not line:
1112                 continue
1113             if line.startswith("#"):
1114                 curcomments.append(line[1:].strip())
1115                 continue
1116             try:
1117                 f, v = line.split(':', 1)
1118             except ValueError:
1119                 raise MetaDataException("Invalid metadata in " + linedesc)
1120
1121             # Translate obsolete fields...
1122             if f == 'Market Version':
1123                 f = 'Current Version'
1124             if f == 'Market Version Code':
1125                 f = 'Current Version Code'
1126
1127             ftype = fieldtype(f)
1128             if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1129                 add_comments(f)
1130             if ftype == TYPE_MULTILINE:
1131                 mode = 1
1132                 if v:
1133                     raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1134             elif ftype == TYPE_STRING:
1135                 app.set_field(f, v)
1136             elif ftype == TYPE_LIST:
1137                 app.set_field(f, split_list_values(v))
1138             elif ftype == TYPE_BUILD:
1139                 if v.endswith("\\"):
1140                     mode = 2
1141                     del buildlines[:]
1142                     buildlines.append(v[:-1])
1143                 else:
1144                     build = parse_buildline([v])
1145                     app.builds.append(build)
1146                     add_comments('build:' + app.builds[-1].vercode)
1147             elif ftype == TYPE_BUILD_V2:
1148                 vv = v.split(',')
1149                 if len(vv) != 2:
1150                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1151                                             .format(v, linedesc))
1152                 build = Build()
1153                 build.version = vv[0]
1154                 build.vercode = vv[1]
1155                 if build.vercode in vc_seen:
1156                     raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1157                                             build.vercode, linedesc))
1158                 vc_seen.add(build.vercode)
1159                 del buildlines[:]
1160                 mode = 3
1161             elif ftype == TYPE_OBSOLETE:
1162                 pass        # Just throw it away!
1163             else:
1164                 raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
1165         elif mode == 1:     # Multiline field
1166             if line == '.':
1167                 mode = 0
1168                 app.set_field(f, '\n'.join(multiline_lines))
1169                 del multiline_lines[:]
1170             else:
1171                 multiline_lines.append(line)
1172         elif mode == 2:     # Line continuation mode in Build Version
1173             if line.endswith("\\"):
1174                 buildlines.append(line[:-1])
1175             else:
1176                 buildlines.append(line)
1177                 build = parse_buildline(buildlines)
1178                 app.builds.append(build)
1179                 add_comments('build:' + app.builds[-1].vercode)
1180                 mode = 0
1181     add_comments(None)
1182
1183     # Mode at end of file should always be 0
1184     if mode == 1:
1185         raise MetaDataException(f + " not terminated in " + mf.name)
1186     if mode == 2:
1187         raise MetaDataException("Unterminated continuation in " + mf.name)
1188     if mode == 3:
1189         raise MetaDataException("Unterminated build in " + mf.name)
1190
1191     return app
1192
1193
1194 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1195
1196     def w_comments(key):
1197         if key not in app.comments:
1198             return
1199         for line in app.comments[key]:
1200             w_comment(line)
1201
1202     def w_field_always(f, v=None):
1203         if v is None:
1204             v = app.get_field(f)
1205         w_comments(f)
1206         w_field(f, v)
1207
1208     def w_field_nonempty(f, v=None):
1209         if v is None:
1210             v = app.get_field(f)
1211         w_comments(f)
1212         if v:
1213             w_field(f, v)
1214
1215     w_field_nonempty('Disabled')
1216     w_field_nonempty('AntiFeatures')
1217     w_field_nonempty('Provides')
1218     w_field_always('Categories')
1219     w_field_always('License')
1220     w_field_nonempty('Author Name')
1221     w_field_nonempty('Author Email')
1222     w_field_always('Web Site')
1223     w_field_always('Source Code')
1224     w_field_always('Issue Tracker')
1225     w_field_nonempty('Changelog')
1226     w_field_nonempty('Donate')
1227     w_field_nonempty('FlattrID')
1228     w_field_nonempty('Bitcoin')
1229     w_field_nonempty('Litecoin')
1230     mf.write('\n')
1231     w_field_nonempty('Name')
1232     w_field_nonempty('Auto Name')
1233     w_field_always('Summary')
1234     w_field_always('Description', description_txt(app.Description))
1235     mf.write('\n')
1236     if app.RequiresRoot:
1237         w_field_always('Requires Root', 'yes')
1238         mf.write('\n')
1239     if app.RepoType:
1240         w_field_always('Repo Type')
1241         w_field_always('Repo')
1242         if app.Binaries:
1243             w_field_always('Binaries')
1244         mf.write('\n')
1245
1246     for build in sorted_builds(app.builds):
1247
1248         if build.version == "Ignore":
1249             continue
1250
1251         w_comments('build:' + build.vercode)
1252         w_build(build)
1253         mf.write('\n')
1254
1255     if app.MaintainerNotes:
1256         w_field_always('Maintainer Notes', app.MaintainerNotes)
1257         mf.write('\n')
1258
1259     w_field_nonempty('Archive Policy')
1260     w_field_always('Auto Update Mode')
1261     w_field_always('Update Check Mode')
1262     w_field_nonempty('Update Check Ignore')
1263     w_field_nonempty('Vercode Operation')
1264     w_field_nonempty('Update Check Name')
1265     w_field_nonempty('Update Check Data')
1266     if app.CurrentVersion:
1267         w_field_always('Current Version')
1268         w_field_always('Current Version Code')
1269     if app.NoSourceSince:
1270         mf.write('\n')
1271         w_field_always('No Source Since')
1272     w_comments(None)
1273
1274
1275 # Write a metadata file in txt format.
1276 #
1277 # 'mf'      - Writer interface (file, StringIO, ...)
1278 # 'app'     - The app data
1279 def write_txt_metadata(mf, app):
1280
1281     def w_comment(line):
1282         mf.write("# %s\n" % line)
1283
1284     def w_field(f, v):
1285         t = fieldtype(f)
1286         if t == TYPE_LIST:
1287             v = ','.join(v)
1288         elif t == TYPE_MULTILINE:
1289             v = '\n' + v + '\n.'
1290         mf.write("%s:%s\n" % (f, v))
1291
1292     def w_build(build):
1293         mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1294
1295         for f in build_flags_order:
1296             v = build.get_flag(f)
1297             if not v:
1298                 continue
1299
1300             t = flagtype(f)
1301             mf.write('    %s=' % f)
1302             if t == TYPE_STRING:
1303                 mf.write(v)
1304             elif t == TYPE_BOOL:
1305                 mf.write('yes')
1306             elif t == TYPE_SCRIPT:
1307                 first = True
1308                 for s in v.split(' && '):
1309                     if first:
1310                         first = False
1311                     else:
1312                         mf.write(' && \\\n        ')
1313                     mf.write(s)
1314             elif t == TYPE_LIST:
1315                 mf.write(','.join(v))
1316
1317             mf.write('\n')
1318
1319     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1320
1321
1322 def write_yaml_metadata(mf, app):
1323
1324     def w_comment(line):
1325         mf.write("# %s\n" % line)
1326
1327     def escape(v):
1328         if not v:
1329             return ''
1330         if any(c in v for c in [': ', '%', '@', '*']):
1331             return "'" + v.replace("'", "''") + "'"
1332         return v
1333
1334     def w_field(f, v, prefix='', t=None):
1335         if t is None:
1336             t = fieldtype(f)
1337         v = ''
1338         if t == TYPE_LIST:
1339             v = '\n'
1340             for e in v:
1341                 v += prefix + ' - ' + escape(e) + '\n'
1342         elif t == TYPE_MULTILINE:
1343             v = ' |\n'
1344             for l in v.splitlines():
1345                 if l:
1346                     v += prefix + '  ' + l + '\n'
1347                 else:
1348                     v += '\n'
1349         elif t == TYPE_BOOL:
1350             v = ' yes\n'
1351         elif t == TYPE_SCRIPT:
1352             cmds = [s + '&& \\' for s in v.split('&& ')]
1353             if len(cmds) > 0:
1354                 cmds[-1] = cmds[-1][:-len('&& \\')]
1355             w_field(f, cmds, prefix, 'multiline')
1356             return
1357         else:
1358             v = ' ' + escape(v) + '\n'
1359
1360         mf.write(prefix)
1361         mf.write(f)
1362         mf.write(":")
1363         mf.write(v)
1364
1365     global first_build
1366     first_build = True
1367
1368     def w_build(build):
1369         global first_build
1370         if first_build:
1371             mf.write("builds:\n")
1372             first_build = False
1373
1374         w_field('versionName', build.version, '  - ', TYPE_STRING)
1375         w_field('versionCode', build.vercode, '    ', TYPE_STRING)
1376         for f in build_flags_order:
1377             v = build.get_flag(f)
1378             if not v:
1379                 continue
1380
1381             w_field(f, v, '    ', flagtype(f))
1382
1383     write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1384
1385
1386 def write_metadata(fmt, mf, app):
1387     if fmt == 'txt':
1388         return write_txt_metadata(mf, app)
1389     if fmt == 'yaml':
1390         return write_yaml_metadata(mf, app)
1391     raise MetaDataException("Unknown metadata format given")