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