UNPKG

17.5 kBPlain TextView Raw
1// *****************************************************************************
2// Copyright (C) 2022 Ericsson and others.
3//
4// This program and the accompanying materials are made available under the
5// terms of the Eclipse Public License v. 2.0 which is available at
6// http://www.eclipse.org/legal/epl-2.0.
7//
8// This Source Code may also be made available under the following Secondary
9// Licenses when the conditions for such availability set forth in the Eclipse
10// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11// with the GNU Classpath Exception which is available at
12// https://www.gnu.org/software/classpath/license.html.
13//
14// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15// *****************************************************************************
16
17import { PreferenceItem } from '../../common/preferences/preference-schema';
18import { JSONObject, JSONValue } from '../../../shared/@phosphor/coreutils';
19import { PreferenceSchemaProvider } from './preference-contribution';
20import { PreferenceLanguageOverrideService } from './preference-language-override-service';
21import { inject, injectable } from '../../../shared/inversify';
22import { IJSONSchema, JsonType } from '../../common/json-schema';
23import { deepClone, unreachable } from '../../common';
24import { PreferenceProvider } from './preference-provider';
25
26export interface PreferenceValidator<T> {
27 name: string;
28 validate(value: unknown): T;
29}
30
31export interface ValidatablePreference extends IJSONSchema, Pick<PreferenceItem, 'defaultValue'> { }
32export type ValueValidator = (value: JSONValue) => JSONValue;
33
34export interface PreferenceValidationResult<T extends JSONValue> {
35 original: JSONValue | undefined;
36 valid: T;
37 messages: string[];
38}
39
40type ValidatablePreferenceTuple = ValidatablePreference & ({ items: ValidatablePreference[] } | { prefixItems: ValidatablePreference[] });
41
42@injectable()
43export class PreferenceValidationService {
44 @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider;
45 @inject(PreferenceLanguageOverrideService) protected readonly languageOverrideService: PreferenceLanguageOverrideService;
46
47 validateOptions(options: Record<string, JSONValue>): Record<string, JSONValue> {
48 const valid: Record<string, JSONValue> = {};
49 let problemsDetected = false;
50 for (const [preferenceName, value] of Object.entries(options)) {
51 const validValue = this.validateByName(preferenceName, value);
52 if (validValue !== value) {
53 problemsDetected = true;
54 }
55 valid[preferenceName] = validValue;
56 }
57 return problemsDetected ? valid : options;
58 }
59
60 validateByName(preferenceName: string, value: JSONValue): JSONValue {
61 const validValue = this.doValidateByName(preferenceName, value);
62 // If value is undefined, it means the preference wasn't set, not that a bad value was set.
63 if (validValue !== value && value !== undefined) {
64 console.warn(`While validating options, found impermissible value for ${preferenceName}. Using valid value`, validValue, 'instead of configured value', value);
65 }
66 return validValue;
67 }
68
69 protected doValidateByName(preferenceName: string, value: JSONValue): JSONValue {
70 const schema = this.getSchema(preferenceName);
71 return this.validateBySchema(preferenceName, value, schema);
72 }
73
74 validateBySchema(key: string, value: JSONValue, schema: ValidatablePreference | undefined): JSONValue {
75 try {
76 if (!schema) {
77 console.warn('Request to validate preference with no schema registered:', key);
78 return value;
79 }
80 if (schema.const !== undefined) {
81 return this.validateConst(key, value, schema as ValidatablePreference & { const: JSONValue });
82 }
83 if (Array.isArray(schema.enum)) {
84 return this.validateEnum(key, value, schema as ValidatablePreference & { enum: JSONValue[] });
85 }
86 if (Array.isArray(schema.anyOf)) {
87 return this.validateAnyOf(key, value, schema as ValidatablePreference & { anyOf: ValidatablePreference[] });
88 }
89 if (Array.isArray(schema.oneOf)) {
90 return this.validateOneOf(key, value, schema as ValidatablePreference & { oneOf: ValidatablePreference[] });
91 }
92 if (schema.type === undefined) {
93 console.warn('Request to validate preference with no type information:', key);
94 return value;
95 }
96 if (Array.isArray(schema.type)) {
97 return this.validateMultiple(key, value, schema as ValidatablePreference & { type: JsonType[] });
98 }
99 switch (schema.type) {
100 case 'array':
101 return this.validateArray(key, value, schema);
102 case 'boolean':
103 return this.validateBoolean(key, value, schema);
104 case 'integer':
105 return this.validateInteger(key, value, schema);
106 case 'null':
107 return null; // eslint-disable-line no-null/no-null
108 case 'number':
109 return this.validateNumber(key, value, schema);
110 case 'object':
111 return this.validateObject(key, value, schema);
112 case 'string':
113 return this.validateString(key, value, schema);
114 default:
115 unreachable(schema.type, `Request to validate preference with unknown type in schema: ${key}`);
116 }
117 } catch (e) {
118 console.error('Encountered an error while validating', key, 'with value', value, 'against schema', schema, e);
119 return value;
120 }
121 }
122
123 protected getSchema(name: string): ValidatablePreference | undefined {
124 const combinedSchema = this.schemaProvider.getCombinedSchema().properties;
125 if (combinedSchema[name]) {
126 return combinedSchema[name];
127 }
128 const baseName = this.languageOverrideService.overriddenPreferenceName(name)?.preferenceName;
129 return baseName !== undefined ? combinedSchema[baseName] : undefined;
130 }
131
132 protected validateMultiple(key: string, value: JSONValue, schema: ValidatablePreference & { type: JsonType[] }): JSONValue {
133 const validation: ValidatablePreference = deepClone(schema);
134 const candidate = this.mapValidators(key, value, (function* (this: PreferenceValidationService): Iterable<ValueValidator> {
135 for (const type of schema.type) {
136 validation.type = type as JsonType;
137 yield toValidate => this.validateBySchema(key, toValidate, validation);
138 }
139 }).bind(this)());
140 if (candidate !== value && (schema.default !== undefined || schema.defaultValue !== undefined)) {
141 const configuredDefault = this.getDefaultFromSchema(schema);
142 return this.validateMultiple(key, configuredDefault, { ...schema, default: undefined, defaultValue: undefined });
143 }
144 return candidate;
145 }
146
147 protected validateAnyOf(key: string, value: JSONValue, schema: ValidatablePreference & { anyOf: ValidatablePreference[] }): JSONValue {
148 const candidate = this.mapValidators(key, value, (function* (this: PreferenceValidationService): Iterable<ValueValidator> {
149 for (const option of schema.anyOf) {
150 yield toValidate => this.validateBySchema(key, toValidate, option);
151 }
152 }).bind(this)());
153 if (candidate !== value && (schema.default !== undefined || schema.defaultValue !== undefined)) {
154 const configuredDefault = this.getDefaultFromSchema(schema);
155 return this.validateAnyOf(key, configuredDefault, { ...schema, default: undefined, defaultValue: undefined });
156 }
157 return candidate;
158 }
159
160 protected validateOneOf(key: string, value: JSONValue, schema: ValidatablePreference & { oneOf: ValidatablePreference[] }): JSONValue {
161 let passed = false;
162 for (const subSchema of schema.oneOf) {
163 const validValue = this.validateBySchema(key, value, subSchema);
164 if (!passed && validValue === value) {
165 passed = true;
166 } else if (passed && validValue === value) {
167 passed = false;
168 break;
169 }
170 }
171 if (passed) {
172 return value;
173 }
174 if (schema.default !== undefined || schema.defaultValue !== undefined) {
175 const configuredDefault = this.getDefaultFromSchema(schema);
176 return this.validateOneOf(key, configuredDefault, { ...schema, default: undefined, defaultValue: undefined });
177 }
178 console.log(`While validating ${key}, failed to find a valid value or default value. Using configured value ${value}.`);
179 return value;
180 }
181
182 protected mapValidators(key: string, value: JSONValue, validators: Iterable<(value: JSONValue) => JSONValue>): JSONValue {
183 const candidates = [];
184 for (const validator of validators) {
185 const candidate = validator(value);
186 if (candidate === value) {
187 return candidate;
188 }
189 candidates.push(candidate);
190 }
191 return candidates[0];
192 }
193 protected validateArray(key: string, value: JSONValue, schema: ValidatablePreference): JSONValue[] {
194 const candidate = Array.isArray(value) ? value : this.getDefaultFromSchema(schema);
195 if (!Array.isArray(candidate)) {
196 return [];
197 }
198 if (!schema.items && !schema.prefixItems) {
199 console.warn('Requested validation of array without item specification:', key);
200 return candidate;
201 }
202 if (Array.isArray(schema.items) || Array.isArray(schema.prefixItems)) {
203 return this.validateTuple(key, value, schema as ValidatablePreferenceTuple);
204 }
205 const itemSchema = schema.items!;
206 const valid = candidate.filter(item => this.validateBySchema(key, item, itemSchema) === item);
207 return valid.length === candidate.length ? candidate : valid;
208 }
209
210 protected validateTuple(key: string, value: JSONValue, schema: ValidatablePreferenceTuple): JSONValue[] {
211 const defaultValue = this.getDefaultFromSchema(schema);
212 const maybeCandidate = Array.isArray(value) ? value : defaultValue;
213 // If we find that the provided value is not valid, we immediately bail and try the default value instead.
214 const shouldTryDefault = Array.isArray(schema.defaultValue ?? schema.default) && !PreferenceProvider.deepEqual(defaultValue, maybeCandidate);
215 const tryDefault = () => this.validateTuple(key, defaultValue, schema);
216 const candidate = Array.isArray(maybeCandidate) ? maybeCandidate : [];
217 // Only `prefixItems` is officially part of the JSON Schema spec, but `items` as array was part of a draft and was used by VSCode.
218 const tuple = (schema.prefixItems ?? schema.items) as Required<ValidatablePreference>['prefixItems'];
219 const lengthIsWrong = candidate.length < tuple.length || (candidate.length > tuple.length && !schema.additionalItems);
220 if (lengthIsWrong && shouldTryDefault) { return tryDefault(); }
221 let valid = true;
222 const validItems: JSONValue[] = [];
223 for (const [index, subschema] of tuple.entries()) {
224 const targetItem = candidate[index];
225 const validatedItem = targetItem === undefined ? this.getDefaultFromSchema(subschema) : this.validateBySchema(key, targetItem, subschema);
226 valid &&= validatedItem === targetItem;
227 if (!valid && shouldTryDefault) { return tryDefault(); }
228 validItems.push(validatedItem);
229 };
230 if (candidate.length > tuple.length) {
231 if (!schema.additionalItems) {
232 return validItems;
233 } else if (schema.additionalItems === true && !valid) {
234 validItems.push(...candidate.slice(tuple.length));
235 return validItems;
236 } else if (schema.additionalItems !== true) {
237 const applicableSchema = schema.additionalItems;
238 for (let i = tuple.length; i < candidate.length; i++) {
239 const targetItem = candidate[i];
240 const validatedItem = this.validateBySchema(key, targetItem, applicableSchema);
241 if (validatedItem === targetItem) {
242 validItems.push(targetItem);
243 } else {
244 valid = false;
245 if (shouldTryDefault) { return tryDefault(); }
246 }
247 }
248 }
249 }
250 return valid ? candidate : validItems;
251 }
252
253 protected validateConst(key: string, value: JSONValue, schema: ValidatablePreference & { const: JSONValue }): JSONValue {
254 if (PreferenceProvider.deepEqual(value, schema.const)) {
255 return value;
256 }
257 return schema.const;
258 }
259
260 protected validateEnum(key: string, value: JSONValue, schema: ValidatablePreference & { enum: JSONValue[] }): JSONValue {
261 const options = schema.enum;
262 if (options.some(option => PreferenceProvider.deepEqual(option, value))) {
263 return value;
264 }
265 const configuredDefault = this.getDefaultFromSchema(schema);
266 if (options.some(option => PreferenceProvider.deepEqual(option, configuredDefault))) {
267 return configuredDefault;
268 }
269 return options[0];
270 }
271
272 protected validateBoolean(key: string, value: JSONValue, schema: ValidatablePreference): boolean {
273 if (value === true || value === false) {
274 return value;
275 }
276 if (value === 'true') {
277 return true;
278 }
279 if (value === 'false') {
280 return false;
281 }
282 return Boolean(this.getDefaultFromSchema(schema));
283 }
284
285 protected validateInteger(key: string, value: JSONValue, schema: ValidatablePreference): number {
286 return Math.round(this.validateNumber(key, value, schema));
287 }
288
289 protected validateNumber(key: string, value: JSONValue, schema: ValidatablePreference): number {
290 let validated = Number(value);
291 if (isNaN(validated)) {
292 const configuredDefault = Number(this.getDefaultFromSchema(schema));
293 validated = isNaN(configuredDefault) ? 0 : configuredDefault;
294 }
295 if (schema.minimum !== undefined) {
296 validated = Math.max(validated, schema.minimum);
297 }
298 if (schema.maximum !== undefined) {
299 validated = Math.min(validated, schema.maximum);
300 }
301 return validated;
302 }
303
304 protected validateObject(key: string, value: JSONValue, schema: ValidatablePreference): JSONObject {
305 if (this.objectMatchesSchema(key, value, schema)) {
306 return value;
307 }
308 const configuredDefault = this.getDefaultFromSchema(schema);
309 if (this.objectMatchesSchema(key, configuredDefault, schema)) {
310 return configuredDefault;
311 }
312 return {};
313 }
314
315 // This evaluates most of the fields that commonly appear on PreferenceItem, but it could be improved to evaluate all possible JSON schema specifications.
316 protected objectMatchesSchema(key: string, value: JSONValue, schema: ValidatablePreference): value is JSONObject {
317 if (!value || typeof value !== 'object') {
318 return false;
319 }
320 if (schema.required && schema.required.some(requiredField => !(requiredField in value))) {
321 return false;
322 }
323 if (schema.additionalProperties === false && schema.properties && Object.keys(value).some(fieldKey => !(fieldKey in schema.properties!))) {
324 return false;
325 }
326 const additionalPropertyValidator = schema.additionalProperties !== true && !!schema.additionalProperties && schema.additionalProperties as IJSONSchema;
327 for (const [fieldKey, fieldValue] of Object.entries(value)) {
328 const fieldLabel = `${key}#${fieldKey}`;
329 if (schema.properties && fieldKey in schema.properties) {
330 const valid = this.validateBySchema(fieldLabel, fieldValue, schema.properties[fieldKey]);
331 if (valid !== fieldValue) {
332 return false;
333 }
334 } else if (additionalPropertyValidator) {
335 const valid = this.validateBySchema(fieldLabel, fieldValue, additionalPropertyValidator);
336 if (valid !== fieldValue) {
337 return false;
338 }
339 }
340 }
341 return true;
342 }
343
344 protected validateString(key: string, value: JSONValue, schema: ValidatablePreference): string {
345 if (typeof value === 'string') {
346 return value;
347 }
348 if (value instanceof String) {
349 return value.toString();
350 }
351 const configuredDefault = this.getDefaultFromSchema(schema);
352 return (configuredDefault ?? '').toString();
353 }
354
355 protected getDefaultFromSchema(schema: ValidatablePreference): JSONValue {
356 return this.schemaProvider.getDefaultValue(schema as PreferenceItem);
357 }
358}