1 // -*- coding: utf-8 -*-
2 //
3 // AddonMetadataParser.cxx --- Parser for FlightGear add-on metadata files
4 // Copyright (C) 2018  Florent Rougon
5 //
6 // This program is free software; you can redistribute it and/or modify
7 // it under the terms of the GNU General Public License as published by
8 // the Free Software Foundation; either version 2 of the License, or
9 // (at your option) any later version.
10 //
11 // This program is distributed in the hope that it will be useful,
12 // but WITHOUT ANY WARRANTY; without even the implied warranty of
13 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 // GNU General Public License for more details.
15 //
16 // You should have received a copy of the GNU General Public License along
17 // with this program; if not, write to the Free Software Foundation, Inc.,
18 // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
19 
20 #include <regex>
21 #include <string>
22 #include <tuple>
23 #include <vector>
24 
25 #include <simgear/debug/logstream.hxx>
26 #include <simgear/misc/sg_path.hxx>
27 #include <simgear/misc/strutils.hxx>
28 #include <simgear/props/props.hxx>
29 #include <simgear/props/props_io.hxx>
30 
31 #include "addon_fwd.hxx"
32 #include "AddonMetadataParser.hxx"
33 #include "AddonVersion.hxx"
34 #include "contacts.hxx"
35 #include "exceptions.hxx"
36 #include "pointer_traits.hxx"
37 
38 #include <Main/globals.hxx>
39 #include <Main/locale.hxx>
40 
41 namespace strutils = simgear::strutils;
42 
43 using std::string;
44 using std::vector;
45 
46 namespace flightgear
47 {
48 
49 namespace addons
50 {
51 
52 // Static method
53 SGPath
getMetadataFile(const SGPath & addonPath)54 Addon::MetadataParser::getMetadataFile(const SGPath& addonPath)
55 {
56   return addonPath / "addon-metadata.xml";
57 }
58 
getMaybeLocalized(const string & tag,SGPropertyNode * base,SGPropertyNode * lang)59 static string getMaybeLocalized(const string& tag, SGPropertyNode* base, SGPropertyNode* lang)
60 {
61     if (lang) {
62         auto n = lang->getChild(tag);
63         if (n) {
64             return strutils::strip(n->getStringValue());
65         }
66     }
67 
68     auto n = base->getChild(tag);
69     if (n) {
70         return strutils::strip(n->getStringValue());
71     }
72 
73     return {};
74 }
75 
getAndCheckLocalizedNode(SGPropertyNode * addonNode,const SGPath & metadataFile)76 static SGPropertyNode* getAndCheckLocalizedNode(SGPropertyNode* addonNode,
77                                                 const SGPath& metadataFile)
78 {
79     const auto localizedNode = addonNode->getChild("localized");
80     if (!localizedNode) {
81         return nullptr;
82     }
83 
84     for (int i = 0; i < localizedNode->nChildren(); ++i) {
85         const auto node = localizedNode->getChild(i);
86         const string& name = node->getNameString();
87 
88         if (name.find('_') != string::npos) {
89             throw errors::error_loading_metadata_file(
90                 "underscores not allowed in names of children of <localized> "
91                 "(in add-on metadata file '" + metadataFile.utf8Str() + "'); "
92                 "hyphens should be used, as in 'fr-FR' or 'en-GB'");
93         }
94     }
95 
96     return localizedNode;
97 }
98 
99 // Static method
100 Addon::Metadata
parseMetadataFile(const SGPath & addonPath)101 Addon::MetadataParser::parseMetadataFile(const SGPath& addonPath)
102 {
103   SGPath metadataFile = getMetadataFile(addonPath);
104   SGPropertyNode addonRoot;
105   Addon::Metadata metadata;
106 
107   if (!metadataFile.exists()) {
108     throw errors::no_metadata_file_found(
109       "unable to find add-on metadata file '" + metadataFile.utf8Str() + "'");
110   }
111 
112   try {
113     readProperties(metadataFile, &addonRoot);
114   } catch (const sg_exception &e) {
115     throw errors::error_loading_metadata_file(
116       "unable to load add-on metadata file '" + metadataFile.utf8Str() + "': " +
117       e.getFormattedMessage());
118   }
119 
120   // Check the 'meta' section
121   SGPropertyNode *metaNode = addonRoot.getChild("meta");
122   if (metaNode == nullptr) {
123     throw errors::error_loading_metadata_file(
124       "no /meta node found in add-on metadata file '" +
125       metadataFile.utf8Str() + "'");
126   }
127 
128   // Check the file type
129   SGPropertyNode *fileTypeNode = metaNode->getChild("file-type");
130   if (fileTypeNode == nullptr) {
131     throw errors::error_loading_metadata_file(
132       "no /meta/file-type node found in add-on metadata file '" +
133       metadataFile.utf8Str() + "'");
134   }
135 
136   string fileType = fileTypeNode->getStringValue();
137   if (fileType != "FlightGear add-on metadata") {
138     throw errors::error_loading_metadata_file(
139       "Invalid /meta/file-type value for add-on metadata file '" +
140       metadataFile.utf8Str() + "': '" + fileType + "' "
141       "(expected 'FlightGear add-on metadata')");
142   }
143 
144   // Check the format version
145   SGPropertyNode *fmtVersionNode = metaNode->getChild("format-version");
146   if (fmtVersionNode == nullptr) {
147     throw errors::error_loading_metadata_file(
148       "no /meta/format-version node found in add-on metadata file '" +
149       metadataFile.utf8Str() + "'");
150   }
151 
152   int formatVersion = fmtVersionNode->getIntValue();
153   if (formatVersion != 1) {
154     throw errors::error_loading_metadata_file(
155       "unknown format version in add-on metadata file '" +
156       metadataFile.utf8Str() + "': " + std::to_string(formatVersion));
157   }
158 
159   // Now the data we are really interested in
160   SGPropertyNode *addonNode = addonRoot.getChild("addon");
161   if (addonNode == nullptr) {
162     throw errors::error_loading_metadata_file(
163       "no /addon node found in add-on metadata file '" +
164       metadataFile.utf8Str() + "'");
165   }
166 
167   const auto localizedNode = getAndCheckLocalizedNode(addonNode, metadataFile);
168   SGPropertyNode* langStringsNode = globals->get_locale()->selectLanguageNode(localizedNode);
169 
170   SGPropertyNode *idNode = addonNode->getChild("identifier");
171   if (idNode == nullptr) {
172     throw errors::error_loading_metadata_file(
173       "no /addon/identifier node found in add-on metadata file '" +
174       metadataFile.utf8Str() + "'");
175   }
176   metadata.id = strutils::strip(idNode->getStringValue());
177 
178   // Require a non-empty identifier for the add-on
179   if (metadata.id.empty()) {
180     throw errors::error_loading_metadata_file(
181       "empty or whitespace-only value for the /addon/identifier node in "
182       "add-on metadata file '" + metadataFile.utf8Str() + "'");
183   } else if (metadata.id.find('.') == string::npos) {
184     SG_LOG(SG_GENERAL, SG_WARN,
185            "Add-on identifier '" << metadata.id << "' does not use reverse DNS "
186            "style (e.g., org.flightgear.addons.MyAddon) in add-on metadata "
187            "file '" << metadataFile.utf8Str() + "'");
188   }
189 
190   SGPropertyNode *nameNode = addonNode->getChild("name");
191   if (nameNode == nullptr) {
192     throw errors::error_loading_metadata_file(
193       "no /addon/name node found in add-on metadata file '" +
194       metadataFile.utf8Str() + "'");
195   }
196 
197   metadata.name = getMaybeLocalized("name", addonNode, langStringsNode);
198 
199   // Require a non-empty name for the add-on
200   if (metadata.name.empty()) {
201     throw errors::error_loading_metadata_file(
202       "empty or whitespace-only value for the /addon/name node in add-on "
203       "metadata file '" + metadataFile.utf8Str() + "'");
204   }
205 
206   SGPropertyNode *versionNode = addonNode->getChild("version");
207   if (versionNode == nullptr) {
208     throw errors::error_loading_metadata_file(
209       "no /addon/version node found in add-on metadata file '" +
210       metadataFile.utf8Str() + "'");
211   }
212   metadata.version = AddonVersion{
213     strutils::strip(versionNode->getStringValue())};
214 
215   metadata.authors = parseContactsNode<Author>(metadataFile,
216                                                addonNode->getChild("authors"));
217   metadata.maintainers = parseContactsNode<Maintainer>(
218     metadataFile, addonNode->getChild("maintainers"));
219 
220   metadata.shortDescription = getMaybeLocalized("short-description", addonNode, langStringsNode);
221   metadata.longDescription = getMaybeLocalized("long-description", addonNode, langStringsNode);
222 
223   std::tie(metadata.licenseDesignation, metadata.licenseFile,
224            metadata.licenseUrl) = parseLicenseNode(addonPath, addonNode);
225 
226   SGPropertyNode *tagsNode = addonNode->getChild("tags");
227   if (tagsNode != nullptr) {
228     auto tagNodes = tagsNode->getChildren("tag");
229     for (const auto& node: tagNodes) {
230       metadata.tags.push_back(strutils::strip(node->getStringValue()));
231     }
232   }
233 
234   SGPropertyNode *minNode = addonNode->getChild("min-FG-version");
235   if (minNode != nullptr) {
236     metadata.minFGVersionRequired = strutils::strip(minNode->getStringValue());
237   } else {
238     metadata.minFGVersionRequired = string();
239   }
240 
241   SGPropertyNode *maxNode = addonNode->getChild("max-FG-version");
242   if (maxNode != nullptr) {
243     metadata.maxFGVersionRequired = strutils::strip(maxNode->getStringValue());
244   } else {
245     metadata.maxFGVersionRequired = string();
246   }
247 
248   metadata.homePage = metadata.downloadUrl = metadata.supportUrl =
249     metadata.codeRepositoryUrl = string(); // defaults
250   SGPropertyNode *urlsNode = addonNode->getChild("urls");
251   if (urlsNode != nullptr) {
252     SGPropertyNode *homePageNode = urlsNode->getChild("home-page");
253     if (homePageNode != nullptr) {
254       metadata.homePage = strutils::strip(homePageNode->getStringValue());
255     }
256 
257     SGPropertyNode *downloadUrlNode = urlsNode->getChild("download");
258     if (downloadUrlNode != nullptr) {
259       metadata.downloadUrl = strutils::strip(downloadUrlNode->getStringValue());
260     }
261 
262     SGPropertyNode *supportUrlNode = urlsNode->getChild("support");
263     if (supportUrlNode != nullptr) {
264       metadata.supportUrl = strutils::strip(supportUrlNode->getStringValue());
265     }
266 
267     SGPropertyNode *codeRepoUrlNode = urlsNode->getChild("code-repository");
268     if (codeRepoUrlNode != nullptr) {
269       metadata.codeRepositoryUrl =
270         strutils::strip(codeRepoUrlNode->getStringValue());
271     }
272   }
273 
274   SG_LOG(SG_GENERAL, SG_DEBUG,
275          "Parsed add-on metadata file: '" << metadataFile.utf8Str() + "'");
276 
277   return metadata;
278 }
279 
280 // Utility function for Addon::MetadataParser::parseContactsNode<>()
281 //
282 // Read a node such as "name", "email" or "url", child of a contact node (e.g.,
283 // of an "author" or "maintainer" node).
284 static string
parseContactsNode_readNode(const SGPath & metadataFile,SGPropertyNode * contactNode,string subnodeName,bool allowEmpty)285 parseContactsNode_readNode(const SGPath& metadataFile,
286                            SGPropertyNode* contactNode,
287                            string subnodeName, bool allowEmpty)
288 {
289   SGPropertyNode *node = contactNode->getChild(subnodeName);
290   string contents;
291 
292   if (node != nullptr) {
293     contents = simgear::strutils::strip(node->getStringValue());
294   }
295 
296   if (!allowEmpty && contents.empty()) {
297     throw errors::error_loading_metadata_file(
298       "in add-on metadata file '" + metadataFile.utf8Str() + "': "
299       "when the node " + contactNode->getPath(true) + " exists, it must have "
300       "a non-empty '" + subnodeName + "' child node");
301   }
302 
303   return contents;
304 };
305 
306 // Static method template (private and only used in this file)
307 template <class T>
308 vector<typename contact_traits<T>::strong_ref>
parseContactsNode(const SGPath & metadataFile,SGPropertyNode * mainNode)309 Addon::MetadataParser::parseContactsNode(const SGPath& metadataFile,
310                                          SGPropertyNode* mainNode)
311 {
312   using contactTraits = contact_traits<T>;
313   vector<typename contactTraits::strong_ref> res;
314 
315   if (mainNode != nullptr) {
316     auto contactNodes = mainNode->getChildren(contactTraits::xmlNodeName());
317     res.reserve(contactNodes.size());
318 
319     for (const auto& contactNode: contactNodes) {
320       string name, email, url;
321 
322       name = parseContactsNode_readNode(metadataFile, contactNode.get(),
323                                         "name", false /* allowEmpty */);
324       email = parseContactsNode_readNode(metadataFile, contactNode.get(),
325                                          "email", true);
326       url = parseContactsNode_readNode(metadataFile, contactNode.get(),
327                                        "url", true);
328 
329       using ptr_traits = shared_ptr_traits<typename contactTraits::strong_ref>;
330       res.push_back(ptr_traits::makeStrongRef(name, email, url));
331     }
332   }
333 
334   return res;
335 };
336 
337 // Static method
338 std::tuple<string, SGPath, string>
parseLicenseNode(const SGPath & addonPath,SGPropertyNode * addonNode)339 Addon::MetadataParser::parseLicenseNode(const SGPath& addonPath,
340                                         SGPropertyNode* addonNode)
341 {
342   SGPath metadataFile = getMetadataFile(addonPath);
343   string licenseDesignation;
344   SGPath licenseFile;
345   string licenseUrl;
346 
347   SGPropertyNode *licenseNode = addonNode->getChild("license");
348   if (licenseNode == nullptr) {
349     return std::tuple<string, SGPath, string>();
350   }
351 
352   SGPropertyNode *licenseDesigNode = licenseNode->getChild("designation");
353   if (licenseDesigNode != nullptr) {
354     licenseDesignation = strutils::strip(licenseDesigNode->getStringValue());
355   }
356 
357   SGPropertyNode *licenseFileNode = licenseNode->getChild("file");
358   if (licenseFileNode != nullptr) {
359     // This effectively disallows filenames starting or ending with whitespace
360     string licenseFile_s = strutils::strip(licenseFileNode->getStringValue());
361 
362     if (!licenseFile_s.empty()) {
363       if (licenseFile_s.find('\\') != string::npos) {
364         throw errors::error_loading_metadata_file(
365           "in add-on metadata file '" + metadataFile.utf8Str() + "': the "
366           "value of /addon/license/file contains '\\'; please use '/' "
367           "separators only");
368       }
369 
370       if (licenseFile_s.find_first_of("/\\") == 0) {
371         throw errors::error_loading_metadata_file(
372           "in add-on metadata file '" + metadataFile.utf8Str() + "': the "
373           "value of /addon/license/file must be relative to the add-on folder, "
374           "however it starts with '" + licenseFile_s[0] + "'");
375       }
376 
377 #ifdef HAVE_WORKING_STD_REGEX
378       std::regex winDriveRegexp("([a-zA-Z]:).*");
379       std::smatch results;
380 
381       if (std::regex_match(licenseFile_s, results, winDriveRegexp)) {
382         string winDrive = results.str(1);
383 #else // all this 'else' clause should be removed once we actually require C++11
384       if (licenseFile_s.size() >= 2 &&
385           (('a' <= licenseFile_s[0] && licenseFile_s[0] <= 'z') ||
386            ('A' <= licenseFile_s[0] && licenseFile_s[0] <= 'Z')) &&
387           licenseFile_s[1] == ':') {
388         string winDrive = licenseFile_s.substr(0, 2);
389 #endif
390         throw errors::error_loading_metadata_file(
391           "in add-on metadata file '" + metadataFile.utf8Str() + "': the "
392           "value of /addon/license/file must be relative to the add-on folder, "
393           "however it starts with a Windows drive letter (" + winDrive + ")");
394       }
395 
396       licenseFile = addonPath / licenseFile_s;
397       if ( !(licenseFile.exists() && licenseFile.isFile()) ) {
398         throw errors::error_loading_metadata_file(
399           "in add-on metadata file '" + metadataFile.utf8Str() + "': the "
400           "value of /addon/license/file (pointing to '" + licenseFile.utf8Str() +
401           "') doesn't correspond to an existing file");
402       }
403     } // of if (!licenseFile_s.empty())
404   }   // of if (licenseFileNode != nullptr)
405 
406   SGPropertyNode *licenseUrlNode = licenseNode->getChild("url");
407   if (licenseUrlNode != nullptr) {
408     licenseUrl = strutils::strip(licenseUrlNode->getStringValue());
409   }
410 
411   return std::make_tuple(licenseDesignation, licenseFile, licenseUrl);
412 }
413 
414 } // of namespace addons
415 
416 } // of namespace flightgear
417