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