UNPKG

13.4 kBJavaScriptView Raw
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,
26indent: 4, maxerr: 50, regexp: true */
27
28"use strict";
29
30var 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
37temp.track();
38
39var 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 */
57var 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 */
73function 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
81var _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 */
97function 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 */
131function 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 */
152function 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 */
183function 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 */
268function 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 */
334function 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
354exports._parsePersonString = parsePersonString;
355
356exports.errors = Errors;
357exports.validate = validate;