1 | /*
|
2 | * Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved.
|
3 | *
|
4 | * Permission is hereby granted, free of charge, to any person obtaining a
|
5 | * copy of this software and associated documentation files (the "Software"),
|
6 | * to deal in the Software without restriction, including without limitation
|
7 | * the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
8 | * and/or sell copies of the Software, and to permit persons to whom the
|
9 | * Software is furnished to do so, subject to the following conditions:
|
10 | *
|
11 | * The above copyright notice and this permission notice shall be included in
|
12 | * all copies or substantial portions of the Software.
|
13 | *
|
14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
19 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
20 | * DEALINGS IN THE SOFTWARE.
|
21 | *
|
22 | */
|
23 |
|
24 |
|
25 | /*jslint vars: true, plusplus: true, devel: true, node: true, nomen: true,
|
26 | indent: 4, maxerr: 50, regexp: true */
|
27 |
|
28 | ;
|
29 |
|
30 | var DecompressZip = require("decompress-zip"),
|
31 | semver = require("semver"),
|
32 | path = require("path"),
|
33 | temp = require("temp"),
|
34 | fs = require("fs-extra");
|
35 |
|
36 | // Track and cleanup files at exit
|
37 | temp.track();
|
38 |
|
39 | var Errors = {
|
40 | NOT_FOUND_ERR: "NOT_FOUND_ERR", // {0} is path where ZIP file was expected
|
41 | INVALID_ZIP_FILE: "INVALID_ZIP_FILE", // {0} is path to ZIP file
|
42 | INVALID_PACKAGE_JSON: "INVALID_PACKAGE_JSON", // {0} is JSON parse error, {1} is path to ZIP file
|
43 | MISSING_PACKAGE_NAME: "MISSING_PACKAGE_NAME", // {0} is path to ZIP file
|
44 | BAD_PACKAGE_NAME: "BAD_PACKAGE_NAME", // {0} is the name
|
45 | MISSING_PACKAGE_VERSION: "MISSING_PACKAGE_VERSION", // {0} is path to ZIP file
|
46 | INVALID_VERSION_NUMBER: "INVALID_VERSION_NUMBER", // {0} is version string in JSON, {1} is path to ZIP file
|
47 | MISSING_MAIN: "MISSING_MAIN", // {0} is path to ZIP file
|
48 | MISSING_PACKAGE_JSON: "MISSING_PACKAGE_JSON", // {0} is path to ZIP file
|
49 | INVALID_BRACKETS_VERSION: "INVALID_BRACKETS_VERSION", // {0} is the version string in JSON, {1} is the path to the zip file,
|
50 | DISALLOWED_WORDS: "DISALLOWED_WORDS" // {0} is the field with the word, {1} is a string list of words that were in violation, {2} is the path to the zip file
|
51 | };
|
52 |
|
53 | /*
|
54 | * Directories to ignore when determining whether the contents of an extension are
|
55 | * in a subfolder.
|
56 | */
|
57 | var ignoredFolders = [ "__MACOSX" ];
|
58 |
|
59 | /**
|
60 | * Returns true if the name presented is acceptable as a package name. This enforces the
|
61 | * requirement as presented in the CommonJS spec: http://wiki.commonjs.org/wiki/Packages/1.0
|
62 | * which states:
|
63 | *
|
64 | * "This must be a unique, lowercase alpha-numeric name without spaces. It may include "." or "_" or "-" characters."
|
65 | *
|
66 | * We add the additional requirement that the first character must be a letter or number
|
67 | * (there's a security implication to allowing a name like "..", because the name is
|
68 | * used in directory names).
|
69 | *
|
70 | * @param {string} name to test
|
71 | * @return {boolean} true if the name is valid
|
72 | */
|
73 | function validateName(name) {
|
74 | if (/^[a-z0-9][a-z0-9._\-]*$/.exec(name)) {
|
75 | return true;
|
76 | }
|
77 | return false;
|
78 | }
|
79 |
|
80 | // Parses strings of the form "name <email> (url)" where email and url are optional
|
81 | var _personRegex = /^([^<\(]+)(?:\s+<([^>]+)>)?(?:\s+\(([^\)]+)\))?$/;
|
82 |
|
83 | /**
|
84 | * Normalizes person fields from package.json.
|
85 | *
|
86 | * These fields can be an object with name, email and url properties or a
|
87 | * string of the form "name <email> <url>". This does a tolerant parsing of
|
88 | * the data to try to return an object with name and optional email and url.
|
89 | * If the string does not match the format, the string is returned as the
|
90 | * name on the resulting object.
|
91 | *
|
92 | * If an object other than a string is passed in, it's returned as is.
|
93 | *
|
94 | * @param <String|Object> obj to normalize
|
95 | * @return {Object} person object with name and optional email and url
|
96 | */
|
97 | function parsePersonString(obj) {
|
98 | if (typeof (obj) === "string") {
|
99 | var parts = _personRegex.exec(obj);
|
100 |
|
101 | // No regex match, so we just synthesize an object with an opaque name string
|
102 | if (!parts) {
|
103 | return {
|
104 | name: obj
|
105 | };
|
106 | } else {
|
107 | var result = {
|
108 | name: parts[1]
|
109 | };
|
110 | if (parts[2]) {
|
111 | result.email = parts[2];
|
112 | }
|
113 | if (parts[3]) {
|
114 | result.url = parts[3];
|
115 | }
|
116 | return result;
|
117 | }
|
118 | } else {
|
119 | // obj is not a string, so return as is
|
120 | return obj;
|
121 | }
|
122 | }
|
123 |
|
124 | /**
|
125 | * Determines if any of the words in wordlist appear in str.
|
126 | *
|
127 | * @param {String[]} wordlist list of words to check
|
128 | * @param {String} str to check for words
|
129 | * @return {String[]} words that matched
|
130 | */
|
131 | function containsWords(wordlist, str) {
|
132 | var i;
|
133 | var matches = [];
|
134 | for (i = 0; i < wordlist.length; i++) {
|
135 | var re = new RegExp("\\b" + wordlist[i] + "\\b", "i");
|
136 | if (re.exec(str)) {
|
137 | matches.push(wordlist[i]);
|
138 | }
|
139 | }
|
140 | return matches;
|
141 | }
|
142 |
|
143 | /**
|
144 | * Finds the common prefix, if any, for the files in a package file.
|
145 | *
|
146 | * In some package files, all of the files are contained in a subdirectory, and this function
|
147 | * will identify that directory if it exists.
|
148 | *
|
149 | * @param {string} extractDir directory into which the package was extracted
|
150 | * @param {function(Error, string)} callback function to accept err, commonPrefix (which will be "" if there is none)
|
151 | */
|
152 | function findCommonPrefix(extractDir, callback) {
|
153 | fs.readdir(extractDir, function (err, files) {
|
154 | ignoredFolders.forEach(function (folder) {
|
155 | var index = files.indexOf(folder);
|
156 | if (index !== -1) {
|
157 | files.splice(index, 1);
|
158 | }
|
159 | });
|
160 | if (err) {
|
161 | callback(err);
|
162 | } else if (files.length === 1) {
|
163 | var name = files[0];
|
164 | if (fs.statSync(path.join(extractDir, name)).isDirectory()) {
|
165 | callback(null, name);
|
166 | } else {
|
167 | callback(null, "");
|
168 | }
|
169 | } else {
|
170 | callback(null, "");
|
171 | }
|
172 | });
|
173 | }
|
174 |
|
175 | /**
|
176 | * Validates the contents of package.json.
|
177 | *
|
178 | * @param {string} path path to package file (used in error reporting)
|
179 | * @param {string} packageJSON path to the package.json file to check
|
180 | * @param {Object} options validation options passed to `validate()`
|
181 | * @param {function(Error, Array.<Array.<string, ...>>, Object)} callback function to call with array of errors and metadata
|
182 | */
|
183 | function validatePackageJSON(path, packageJSON, options, callback) {
|
184 | var errors = [];
|
185 | if (fs.existsSync(packageJSON)) {
|
186 | fs.readFile(packageJSON, {
|
187 | encoding: "utf8"
|
188 | }, function (err, data) {
|
189 | if (err) {
|
190 | callback(err, null, null);
|
191 | return;
|
192 | }
|
193 |
|
194 | var metadata;
|
195 |
|
196 | try {
|
197 | metadata = JSON.parse(data);
|
198 | } catch (e) {
|
199 | errors.push([Errors.INVALID_PACKAGE_JSON, e.toString(), path]);
|
200 | callback(null, errors, undefined);
|
201 | return;
|
202 | }
|
203 |
|
204 | // confirm required fields in the metadata
|
205 | if (!metadata.name) {
|
206 | errors.push([Errors.MISSING_PACKAGE_NAME, path]);
|
207 | } else if (!validateName(metadata.name)) {
|
208 | errors.push([Errors.BAD_PACKAGE_NAME, metadata.name]);
|
209 | }
|
210 | if (!metadata.version) {
|
211 | errors.push([Errors.MISSING_PACKAGE_VERSION, path]);
|
212 | } else if (!semver.valid(metadata.version)) {
|
213 | errors.push([Errors.INVALID_VERSION_NUMBER, metadata.version, path]);
|
214 | }
|
215 |
|
216 | // normalize the author
|
217 | if (metadata.author) {
|
218 | metadata.author = parsePersonString(metadata.author);
|
219 | }
|
220 |
|
221 | // contributors should be an array of people.
|
222 | // normalize each entry.
|
223 | if (metadata.contributors) {
|
224 | if (metadata.contributors.map) {
|
225 | metadata.contributors = metadata.contributors.map(function (person) {
|
226 | return parsePersonString(person);
|
227 | });
|
228 | } else {
|
229 | metadata.contributors = [
|
230 | parsePersonString(metadata.contributors)
|
231 | ];
|
232 | }
|
233 | }
|
234 |
|
235 | if (metadata.engines && metadata.engines.brackets) {
|
236 | var range = metadata.engines.brackets;
|
237 | if (!semver.validRange(range)) {
|
238 | errors.push([Errors.INVALID_BRACKETS_VERSION, range, path]);
|
239 | }
|
240 | }
|
241 |
|
242 | if (options.disallowedWords) {
|
243 | ["title", "description", "name"].forEach(function (field) {
|
244 | var words = containsWords(options.disallowedWords, metadata[field]);
|
245 | if (words.length > 0) {
|
246 | errors.push([Errors.DISALLOWED_WORDS, field, words.toString(), path]);
|
247 | }
|
248 | });
|
249 | }
|
250 | callback(null, errors, metadata);
|
251 | });
|
252 | } else {
|
253 | if (options.requirePackageJSON) {
|
254 | errors.push([Errors.MISSING_PACKAGE_JSON, path]);
|
255 | }
|
256 | callback(null, errors, null);
|
257 | }
|
258 | }
|
259 |
|
260 | /**
|
261 | * Extracts the package into the given directory and then validates it.
|
262 | *
|
263 | * @param {string} zipPath path to package zip file
|
264 | * @param {string} extractDir directory to extract package into
|
265 | * @param {Object} options validation options
|
266 | * @param {function(Error, {errors: Array, metadata: Object, commonPrefix: string, extractDir: string})} callback function to call with the result
|
267 | */
|
268 | function extractAndValidateFiles(zipPath, extractDir, options, callback) {
|
269 | var unzipper = new DecompressZip(zipPath);
|
270 | unzipper.on("error", function (err) {
|
271 | // General error to report for problems reading the file
|
272 | callback(null, {
|
273 | errors: [[Errors.INVALID_ZIP_FILE, zipPath, err]]
|
274 | });
|
275 | return;
|
276 | });
|
277 |
|
278 | unzipper.on("extract", function (log) {
|
279 | findCommonPrefix(extractDir, function (err, commonPrefix) {
|
280 | if (err) {
|
281 | callback(err, null);
|
282 | return;
|
283 | }
|
284 | var packageJSON = path.join(extractDir, commonPrefix, "package.json");
|
285 | validatePackageJSON(zipPath, packageJSON, options, function (err, errors, metadata) {
|
286 | if (err) {
|
287 | callback(err, null);
|
288 | return;
|
289 | }
|
290 | var mainJS = path.join(extractDir, commonPrefix, "main.js"),
|
291 | isTheme = metadata && metadata.theme;
|
292 |
|
293 | // Throw missing main.js file only for non-theme extensions
|
294 | if (!isTheme && !fs.existsSync(mainJS)) {
|
295 | errors.push([Errors.MISSING_MAIN, zipPath, mainJS]);
|
296 | }
|
297 | callback(null, {
|
298 | errors: errors,
|
299 | metadata: metadata,
|
300 | commonPrefix: commonPrefix,
|
301 | extractDir: extractDir
|
302 | });
|
303 | });
|
304 | });
|
305 | });
|
306 |
|
307 | unzipper.extract({
|
308 | path: extractDir,
|
309 | filter: function (file) {
|
310 | return file.type !== "SymbolicLink";
|
311 | }
|
312 | });
|
313 | }
|
314 |
|
315 | /**
|
316 | * Implements the "validate" command in the "extensions" domain.
|
317 | * Validates the zipped package at path.
|
318 | *
|
319 | * The "err" parameter of the callback is only set if there was an
|
320 | * unexpected error. Otherwise, errors are reported in the result.
|
321 | *
|
322 | * The result object has an "errors" property. It is an array of
|
323 | * arrays of strings. Each array in the array is a set of parameters
|
324 | * that can be passed to StringUtils.format for internationalization.
|
325 | * The array will be empty if there are no errors.
|
326 | *
|
327 | * The result will have a "metadata" property if the metadata was
|
328 | * read successfully from package.json in the zip file.
|
329 | *
|
330 | * @param {string} path Absolute path to the package zip file
|
331 | * @param {{requirePackageJSON: ?boolean, disallowedWords: ?Array.<string>}} options for validation
|
332 | * @param {function} callback (err, result)
|
333 | */
|
334 | function validate(path, options, callback) {
|
335 | options = options || {};
|
336 | fs.exists(path, function (doesExist) {
|
337 | if (!doesExist) {
|
338 | callback(null, {
|
339 | errors: [[Errors.NOT_FOUND_ERR, path]]
|
340 | });
|
341 | return;
|
342 | }
|
343 | temp.mkdir("bracketsPackage_", function _tempDirCreated(err, extractDir) {
|
344 | if (err) {
|
345 | callback(err, null);
|
346 | return;
|
347 | }
|
348 | extractAndValidateFiles(path, extractDir, options, callback);
|
349 | });
|
350 | });
|
351 | }
|
352 |
|
353 | // exported for unit testing
|
354 | exports._parsePersonString = parsePersonString;
|
355 |
|
356 | exports.errors = Errors;
|
357 | exports.validate = validate;
|