diff --git a/packages/plugin-manifest-validator/manifest-schema.json b/packages/plugin-manifest-validator/manifest-schema.json index c9e85e9a6a..fafda11cd9 100644 --- a/packages/plugin-manifest-validator/manifest-schema.json +++ b/packages/plugin-manifest-validator/manifest-schema.json @@ -73,7 +73,8 @@ "description": "internal only", "minLength": 1, "format": "relative-path", - "maxFileSize": "20MB" + "maxFileSize": "20MB", + "fileExists": true }, "homepage_url": { "type": "object", @@ -129,7 +130,8 @@ "type": "string", "format": "relative-path", "maxFileSize": "65535B", - "minLength": 1 + "minLength": 1, + "fileExists": true }, "js": { "$ref": "#/definitions/resources" @@ -162,7 +164,8 @@ }, { "format": "relative-path", - "maxFileSize": "20MB" + "maxFileSize": "20MB", + "fileExists": true } ] }, diff --git a/packages/plugin-manifest-validator/src/__tests__/index.ts b/packages/plugin-manifest-validator/src/__tests__/index.ts index 147aed27ea..5387034e08 100644 --- a/packages/plugin-manifest-validator/src/__tests__/index.ts +++ b/packages/plugin-manifest-validator/src/__tests__/index.ts @@ -290,6 +290,30 @@ describe("validator", () => { }); }); + it("should throw the custom message when there is one invalid file size and the custom message is specified", () => { + const customMessage = "A custom message: invalid file size"; + const actual = validator(json({}), { + maxFileSize: (maxFileSizeInBytes, path) => { + return { + valid: false, + message: customMessage, + }; + }, + }); + + assert(actual.valid === false); + assert(actual.errors?.length === 1); + assert.deepStrictEqual(actual.errors[0], { + instancePath: "/icon", + keyword: "maxFileSize", + message: customMessage, + params: { + limit: MAX_FILE_SIZE, + }, + schemaPath: "#/properties/icon/maxFileSize", + }); + }); + it("mobile", () => { const actual = validator( json({ @@ -303,6 +327,122 @@ describe("validator", () => { assert(actual.errors === null); }); }); + + describe("fileExists", () => { + it("should return no error when all files exist", () => { + const actual = validator(json({}), { + fileExists: (path) => { + return true; + }, + }); + assert(actual.valid === true); + assert(actual.errors?.length === undefined); + }); + + it.each` + filePath | instancePath | schemaPath + ${"icon.png"} | ${"/icon"} | ${"#/properties/icon/fileExists"} + ${"desktop/js/desktop.js"} | ${"/desktop/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"desktop/css/style.css"} | ${"/desktop/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"config/config.html"} | ${"/config/html"} | ${"#/properties/config/properties/html/fileExists"} + ${"config/js/config.js"} | ${"/config/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"config/css/style.css"} | ${"/config/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"mobile/js/mobile.js"} | ${"/mobile/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"mobile/css/style.css"} | ${"/mobile/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + `( + "should throw the default message when there is non-existent file at $instancePath", + ({ filePath, instancePath, schemaPath }) => { + const properties = instancePath.split("/"); + const path1 = properties[1]; + const path2 = properties[2]; + + let source = {}; + switch (path2) { + case undefined: + // "icon" config + source = { [path1]: filePath }; + break; + case "html": + source = { [path1]: { [path2]: filePath } }; + break; + default: + source = { [path1]: { [path2]: [filePath] } }; + } + + const actual = validator(json(source), { + fileExists: (path) => { + return path.indexOf(filePath) === -1; + }, + }); + const error = actual.errors?.[1] ?? actual.errors?.[0]; + + assert(actual.valid === false); + assert.deepStrictEqual(error, { + instancePath, + keyword: "fileExists", + message: `File not found: ${filePath}`, + schemaPath, + }); + } + ); + + it.each` + filePath | instancePath | schemaPath + ${"icon.png"} | ${"/icon"} | ${"#/properties/icon/fileExists"} + ${"desktop/js/desktop.js"} | ${"/desktop/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"desktop/css/style.css"} | ${"/desktop/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"config/config.html"} | ${"/config/html"} | ${"#/properties/config/properties/html/fileExists"} + ${"config/js/config.js"} | ${"/config/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"config/css/style.css"} | ${"/config/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"mobile/js/mobile.js"} | ${"/mobile/js/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + ${"mobile/css/style.css"} | ${"/mobile/css/0"} | ${"#/definitions/resources/items/anyOf/1/fileExists"} + `( + `should throw the custom message when there is non-existent file at $instancePath and the custom message is specified`, + ({ filePath, instancePath, schemaPath }) => { + const properties = instancePath.split("/"); + const path1 = properties[1]; + const path2 = properties[2]; + + let source = {}; + let errorIndex = 0; + switch (path2) { + case undefined: + // "icon" config + source = { [path1]: filePath }; + errorIndex = 0; + break; + case "html": + source = { [path1]: { [path2]: filePath } }; + errorIndex = 0; + break; + default: + source = { [path1]: { [path2]: [filePath] } }; + errorIndex = 1; + } + + const customMessage = "Custom message: File not found 404"; + const actual = validator(json(source), { + fileExists: (path) => { + return { + valid: path.indexOf(filePath) === -1, + message: customMessage, + }; + }, + }); + + const error = actual.errors?.[errorIndex]; + + assert(actual.valid === false); + assert.deepStrictEqual(error, { + instancePath, + keyword: "fileExists", + message: customMessage, + schemaPath, + }); + } + ); + }); + describe("maxItems", () => { it("exceed the max item counts", () => { const urls = [...new Array(100)].map( diff --git a/packages/plugin-manifest-validator/src/index.ts b/packages/plugin-manifest-validator/src/index.ts index 37a775ebca..b378584a47 100644 --- a/packages/plugin-manifest-validator/src/index.ts +++ b/packages/plugin-manifest-validator/src/index.ts @@ -1,6 +1,6 @@ "use strict"; -import type { ErrorObject } from "ajv"; +import type { ErrorObject, SchemaValidateFunction } from "ajv"; import Ajv from "ajv"; import bytes from "bytes"; import jsonSchema from "../manifest-schema.json"; @@ -13,10 +13,17 @@ type ValidateResult = { // https://ajv.js.org/docs/keywords.html#define-keyword-with-validation-function // FIXME: use the type definition that Ajv provides if https://github.com/ajv-validator/ajv/pull/1460 has been merged -interface SchemaValidateFunction { - (schema: string, data: string): boolean; - errors?: Array>; -} + +type ValidatorResult = + | boolean + | { valid: true } + | { valid: false; message?: string }; + +type Options = { + relativePath?: (filePath: string) => boolean; + maxFileSize?: (maxBytes: number, filePath: string) => ValidatorResult; + fileExists?: (filePath: string) => ValidatorResult; +}; /** * @param {Object} json @@ -25,16 +32,21 @@ interface SchemaValidateFunction { */ export default ( json: Record, - options: { [s: string]: (...args: any) => boolean } = {} + options: Options = {} ): ValidateResult => { - let relativePath = (...args: any) => true; - let maxFileSize = (...args: any) => true; + let relativePath: Options["relativePath"] = () => true; + let maxFileSize: Options["maxFileSize"]; + let fileExists: Options["fileExists"]; + if (typeof options.relativePath === "function") { relativePath = options.relativePath; } if (typeof options.maxFileSize === "function") { maxFileSize = options.maxFileSize; } + if (typeof options.fileExists === "function") { + fileExists = options.fileExists; + } const ajv = new Ajv({ allErrors: true, @@ -47,24 +59,79 @@ export default ( const validateMaxFileSize: SchemaValidateFunction = ( schema: string, - data: string + filePath: string ) => { // schema: max file size like "512KB" or 123 (in bytes) // data: path to the file + if (maxFileSize === undefined) { + return true; + } + const maxBytes = bytes.parse(schema); - const valid = maxFileSize(maxBytes, data); - if (!valid) { + const result = maxFileSize(maxBytes, filePath); + const defaultMessage = `file size should be <= ${schema}`; + + if (result === false) { validateMaxFileSize.errors = [ { keyword: "maxFileSize", params: { limit: maxBytes, }, - message: `file size should be <= ${schema}`, + message: defaultMessage, + }, + ]; + return false; + } + + if (typeof result === "object" && !result.valid) { + validateMaxFileSize.errors = [ + { + keyword: "maxFileSize", + params: { + limit: maxBytes, + }, + message: result.message ?? defaultMessage, + }, + ]; + return false; + } + + return true; + }; + + const validateFileExists: SchemaValidateFunction = ( + schema: boolean, + filePath: string + ) => { + if (fileExists === undefined || !schema) { + return true; + } + + const result = fileExists(filePath); + const defaultMessage = `File not found: ${filePath}`; + + if (result === false) { + validateFileExists.errors = [ + { + keyword: "fileExists", + message: defaultMessage, }, ]; + return false; } - return valid; + + if (typeof result === "object" && !result.valid) { + validateFileExists.errors = [ + { + keyword: "fileExists", + message: result.message ?? defaultMessage, + }, + ]; + return false; + } + + return true; }; ajv.addKeyword({ @@ -72,6 +139,11 @@ export default ( validate: validateMaxFileSize, }); + ajv.addKeyword({ + keyword: "fileExists", + validate: validateFileExists, + }); + const validate = ajv.compile(jsonSchema); const valid = validate(json); return { valid, errors: transformErrors(validate.errors) };