708 lines
20 KiB
JavaScript
708 lines
20 KiB
JavaScript
/**
|
|
* Copyright IBM Corp. 2021, 2026
|
|
* SPDX-License-Identifier: BUSL-1.1
|
|
*/
|
|
import {
|
|
GRANT_FIELDS,
|
|
TEMPLATE_RESOURCE_TYPES,
|
|
normalizeGrantsSchema,
|
|
parseGrantLine,
|
|
getValidActions,
|
|
getValidOutputFields,
|
|
} from './grant-parser';
|
|
|
|
const createDeleteTextAction = (name) => ({
|
|
name,
|
|
apply(view, from, to) {
|
|
view.dispatch({
|
|
changes: { from, to },
|
|
});
|
|
},
|
|
});
|
|
|
|
const createAddTextAction = (name) => ({
|
|
name,
|
|
apply(view, from) {
|
|
view.dispatch({
|
|
changes: { from, insert: 'no-op,' },
|
|
});
|
|
},
|
|
});
|
|
|
|
const grantLinter = (context, schema, translate) => {
|
|
const diagnostics = [];
|
|
const doc = context.state.doc.toString();
|
|
const lines = doc.split('\n');
|
|
|
|
let currentPos = 0;
|
|
|
|
lines.forEach((line) => {
|
|
const trimmedLine = line.trim();
|
|
const lineStart = currentPos;
|
|
const lineEnd = currentPos + line.length;
|
|
|
|
// Check for whitespace
|
|
const whitespaceMatches = line.matchAll(/\s+/g);
|
|
for (const match of whitespaceMatches) {
|
|
const spaceStart = lineStart + match.index;
|
|
const spaceEnd = spaceStart + match[0].length;
|
|
diagnostics.push({
|
|
from: spaceStart,
|
|
to: spaceEnd,
|
|
severity: 'error',
|
|
message: translate('general.whitespace-not-allowed'),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
}
|
|
|
|
const trimStart = line.indexOf(trimmedLine);
|
|
// Check for leading semicolon
|
|
if (trimmedLine.startsWith(';')) {
|
|
diagnostics.push({
|
|
from: lineStart,
|
|
to: lineStart + trimStart + 1,
|
|
severity: 'error',
|
|
message: translate('general.leading-semicolon-not-allowed'),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
}
|
|
|
|
// Check for trailing semicolon
|
|
if (trimmedLine.endsWith(';')) {
|
|
const semicolonPos = lineStart + line.lastIndexOf(';');
|
|
diagnostics.push({
|
|
from: semicolonPos,
|
|
to: semicolonPos + 1,
|
|
severity: 'error',
|
|
message: translate('general.trailing-semicolon-not-allowed'),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
}
|
|
|
|
// Check for invalid characters (only allow alphanumeric characters, equal signs, commas, semicolons, underscores, hyphens, curly braces, and periods)
|
|
const invalidCharMatch = line.match(/[^a-zA-Z0-9=,;:_*\-.{}\s]/g) || [];
|
|
for (const invalidChar of invalidCharMatch) {
|
|
const invalidCharIndex = lineStart + line.indexOf(invalidChar);
|
|
diagnostics.push({
|
|
from: invalidCharIndex,
|
|
to: invalidCharIndex + 1,
|
|
severity: 'error',
|
|
message: translate('general.invalid-character', {
|
|
character: invalidChar,
|
|
}),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
}
|
|
|
|
// Parse key-value pairs separated by semicolons
|
|
const {
|
|
fields: allFields,
|
|
fieldMap: parsedFields,
|
|
fieldPositions,
|
|
fieldKeyCount,
|
|
} = parseGrantLine(line, lineStart);
|
|
|
|
// Check for invalid format (no '=' in a pair or empty key)
|
|
for (const field of allFields) {
|
|
if (field.value === undefined || !field.key) {
|
|
diagnostics.push({
|
|
from: field.pos.keyStart,
|
|
to: field.pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('general.invalid-format'),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate there are no duplicate fields
|
|
for (const field of allFields) {
|
|
if (fieldKeyCount[field.key] > 1) {
|
|
diagnostics.push({
|
|
from: field.pos.keyStart,
|
|
to: field.pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('fields.duplicate-field', {
|
|
field: field.key,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate all fields are recognized
|
|
for (const field of allFields) {
|
|
if (!GRANT_FIELDS.includes(field.key)) {
|
|
diagnostics.push({
|
|
from: field.pos.keyStart,
|
|
to: field.pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('fields.unknown-field', { field: field.key }),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate field values are not empty
|
|
for (const field of Object.keys(fieldPositions)) {
|
|
if (!parsedFields[field]) {
|
|
const pos = fieldPositions[field];
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('fields.empty-value', { field }),
|
|
});
|
|
}
|
|
}
|
|
|
|
const validatedFields = {};
|
|
|
|
// Validate type field value is a valid resource type
|
|
if (parsedFields.type) {
|
|
validatedFields.type = { value: '', wildcard: false };
|
|
const typeValue = parsedFields.type;
|
|
const pos = fieldPositions.type;
|
|
if (!schema.validResourceTypes.includes(typeValue)) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('type.invalid-type', { type: typeValue }),
|
|
});
|
|
} else {
|
|
validatedFields.type.value = typeValue;
|
|
if (!fieldPositions.ids) {
|
|
if (!schema.topLevelTypes.includes(typeValue)) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('type.not-top-level', {
|
|
type: typeValue,
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
validatedFields.type.wildcard = typeValue === '*';
|
|
}
|
|
|
|
// Validate ids field
|
|
if (parsedFields.ids) {
|
|
validatedFields.ids = { value: [], knownType: '', wildcard: false };
|
|
const pos = fieldPositions.ids;
|
|
const idsList = validateListField(
|
|
parsedFields.ids,
|
|
pos,
|
|
diagnostics,
|
|
translate,
|
|
);
|
|
if (idsList.length === 0) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.empty-list'),
|
|
});
|
|
} else if (idsList.includes('*')) {
|
|
if (idsList.length > 1) {
|
|
const wildCardIndex = pos.valueStart + parsedFields.ids.indexOf('*');
|
|
diagnostics.push({
|
|
from: wildCardIndex,
|
|
to: wildCardIndex + 1, // Include wildcard and comma after it
|
|
severity: 'error',
|
|
message: translate('ids.wildcard-cannot-combine'),
|
|
});
|
|
} else {
|
|
validatedFields.ids.wildcard = true;
|
|
if (!fieldPositions.type) {
|
|
diagnostics.push({
|
|
from: lineStart,
|
|
to: lineEnd,
|
|
severity: 'error',
|
|
message: translate('type.missing-for-wildcard-ids'),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
const validIdType = validateIdsField(
|
|
schema,
|
|
diagnostics,
|
|
idsList,
|
|
validatedFields.type,
|
|
pos,
|
|
fieldPositions.type,
|
|
translate,
|
|
);
|
|
if (validIdType) {
|
|
validatedFields.ids.knownType = validIdType;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!validatedFields.ids) {
|
|
if (!fieldPositions.type) {
|
|
diagnostics.push({
|
|
from: lineStart,
|
|
to: lineEnd,
|
|
severity: 'error',
|
|
message: translate('fields.missing-ids-or-type'),
|
|
});
|
|
} else if (validatedFields.type?.wildcard) {
|
|
diagnostics.push({
|
|
from: lineStart,
|
|
to: lineEnd,
|
|
severity: 'error',
|
|
message: translate('type.wildcard-requires-ids'),
|
|
});
|
|
} else {
|
|
if (!fieldPositions.actions && !fieldPositions.output_fields) {
|
|
diagnostics.push({
|
|
from: lineStart,
|
|
to: lineEnd,
|
|
severity: 'error',
|
|
message: translate('fields.missing-actions-or-output-fields'),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
if (!fieldPositions.actions && !fieldPositions.output_fields) {
|
|
diagnostics.push({
|
|
from: lineStart,
|
|
to: lineEnd,
|
|
severity: 'error',
|
|
message: translate('fields.missing-actions'),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Validate actions field
|
|
if (parsedFields.actions) {
|
|
const pos = fieldPositions.actions;
|
|
const actionList = validateListField(
|
|
parsedFields.actions,
|
|
pos,
|
|
diagnostics,
|
|
translate,
|
|
);
|
|
if (actionList.length === 0) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('actions.empty-list'),
|
|
});
|
|
} else if (actionList.includes('*') && actionList.length > 1) {
|
|
const wildcardIndex =
|
|
pos.valueStart + parsedFields.actions.indexOf('*');
|
|
diagnostics.push({
|
|
from: wildcardIndex,
|
|
to: wildcardIndex + 1,
|
|
severity: 'error',
|
|
message: translate('actions.wildcard-cannot-combine'),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
} else {
|
|
// Validate individual actions against the resource type
|
|
validateActionsField(
|
|
schema,
|
|
diagnostics,
|
|
actionList,
|
|
validatedFields,
|
|
pos,
|
|
translate,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Validate output_fields field
|
|
if (parsedFields.output_fields) {
|
|
const pos = fieldPositions.output_fields;
|
|
const outputFieldList = validateListField(
|
|
parsedFields.output_fields,
|
|
pos,
|
|
diagnostics,
|
|
translate,
|
|
);
|
|
if (outputFieldList.length === 0) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('output-fields.empty-list'),
|
|
});
|
|
} else if (outputFieldList.includes('*') && outputFieldList.length > 1) {
|
|
const wildcardIndex =
|
|
pos.valueStart + parsedFields.output_fields.indexOf('*');
|
|
diagnostics.push({
|
|
from: wildcardIndex,
|
|
to: wildcardIndex + 1,
|
|
severity: 'error',
|
|
message: translate('output-fields.wildcard-cannot-combine'),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
} else {
|
|
validateOutputFieldsField(
|
|
schema,
|
|
diagnostics,
|
|
outputFieldList,
|
|
validatedFields,
|
|
pos,
|
|
translate,
|
|
);
|
|
}
|
|
}
|
|
currentPos += line.length + 1; // +1 for newline
|
|
});
|
|
|
|
return diagnostics;
|
|
};
|
|
|
|
const validateListField = (fieldValue, pos, diagnostics, translate) => {
|
|
const unfilteredVals = fieldValue.split(',');
|
|
const valList = [];
|
|
let segmentStart = 0;
|
|
const maxIndex = unfilteredVals.length - 1;
|
|
for (const [index, val] of unfilteredVals.entries()) {
|
|
const emptyVal = val.match(/^\s*$/);
|
|
if (!emptyVal) {
|
|
valList.push(val);
|
|
}
|
|
if (index < maxIndex) {
|
|
const emptyNextVal = unfilteredVals[index + 1].match(/^\s*$/);
|
|
if (emptyVal || emptyNextVal) {
|
|
const commaPos = segmentStart + val.length;
|
|
diagnostics.push({
|
|
from: pos.valueStart + commaPos,
|
|
to: pos.valueStart + commaPos + 1, // +1 for comma
|
|
severity: 'error',
|
|
message: translate('general.invalid-syntax'),
|
|
actions: [createDeleteTextAction(translate('editor.remove-comma'))],
|
|
});
|
|
}
|
|
}
|
|
segmentStart += val.length + 1; // +1 for comma delimiter
|
|
}
|
|
|
|
return valList;
|
|
};
|
|
|
|
// Returns 'template', resource type, or undefined
|
|
const idType = (schema, id) => {
|
|
if (id.startsWith('{{') && id.endsWith('}}')) {
|
|
return 'template';
|
|
}
|
|
const prefix = id.split('_')[0];
|
|
return schema.resourceTypesByIdPrefix[prefix];
|
|
};
|
|
|
|
const validateIdsField = (
|
|
schema,
|
|
diagnostics,
|
|
idsList,
|
|
validatedFieldsType = {},
|
|
idsPos,
|
|
typePos,
|
|
translate,
|
|
) => {
|
|
if (
|
|
idsList.includes('{{.Account.Id}}') &&
|
|
idsList.includes('{{account.id}}')
|
|
) {
|
|
diagnostics.push({
|
|
from: idsPos.valueStart,
|
|
to: idsPos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.duplicate-template', {
|
|
primary: '{{.Account.Id}}',
|
|
secondary: '{{account.id}}',
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
if (idsList.includes('{{.User.Id}}') && idsList.includes('{{user.id}}')) {
|
|
diagnostics.push({
|
|
from: idsPos.valueStart,
|
|
to: idsPos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.duplicate-template', {
|
|
primary: '{{.User.Id}}',
|
|
secondary: '{{user.id}}',
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
let segmentStart = idsPos.valueStart;
|
|
let seenIdType;
|
|
for (const [i, id] of idsList.entries()) {
|
|
let segmentEnd = segmentStart + id.length;
|
|
if (i === 0) {
|
|
seenIdType = idType(schema, id);
|
|
}
|
|
if (i < idsList.length - 1) {
|
|
segmentEnd += 1; // Include comma delimiter for all but last id
|
|
}
|
|
if (idsList.slice(i + 1).includes(id)) {
|
|
diagnostics.push({
|
|
from: segmentStart,
|
|
to: segmentEnd,
|
|
severity: 'error',
|
|
message: translate('ids.duplicate-id', { id }),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
return;
|
|
}
|
|
const currentIdType = idType(schema, id);
|
|
if (currentIdType === 'template') {
|
|
if (!TEMPLATE_RESOURCE_TYPES[id]) {
|
|
diagnostics.push({
|
|
from: segmentStart,
|
|
to: segmentEnd,
|
|
severity: 'error',
|
|
message: translate('ids.unknown-template', { id }),
|
|
});
|
|
return;
|
|
}
|
|
} else {
|
|
if (!currentIdType) {
|
|
diagnostics.push({
|
|
from: segmentStart,
|
|
to: segmentEnd,
|
|
severity: 'error',
|
|
message: translate('ids.invalid-id', { id }),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
if (currentIdType !== seenIdType) {
|
|
diagnostics.push({
|
|
from: idsPos.valueStart,
|
|
to: idsPos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.mismatched-types', { id }),
|
|
});
|
|
return;
|
|
}
|
|
if (validatedFieldsType.wildcard) {
|
|
// If type is wildcard, then pinned id(s) must support child types
|
|
if (!schema.parentTypes.includes(currentIdType)) {
|
|
diagnostics.push({
|
|
from: idsPos.valueStart,
|
|
to: idsPos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.must-support-child-types', { id }),
|
|
});
|
|
return;
|
|
}
|
|
} else if (validatedFieldsType.value) {
|
|
// If type is known and not wildcard, then pinned id(s) must match the type
|
|
const parentType =
|
|
schema.resourcesByType[validatedFieldsType.value]?.parentType;
|
|
if (!parentType) {
|
|
diagnostics.push({
|
|
from: typePos.valueStart,
|
|
to: typePos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('type.not-child-type', {
|
|
type: validatedFieldsType.value,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
if (currentIdType !== parentType) {
|
|
diagnostics.push({
|
|
from: idsPos.valueStart,
|
|
to: idsPos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.must-match-parent-type', {
|
|
id,
|
|
parentType,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
segmentStart += id.length + 1; // +1 for comma
|
|
}
|
|
if (seenIdType === 'template') {
|
|
if (idsList.length > 1) {
|
|
diagnostics.push({
|
|
from: idsPos.valueStart,
|
|
to: idsPos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('ids.templates-cannot-combine'),
|
|
});
|
|
return;
|
|
}
|
|
return TEMPLATE_RESOURCE_TYPES[idsList[0]];
|
|
}
|
|
return seenIdType;
|
|
};
|
|
|
|
const validateActionsField = (
|
|
schema,
|
|
diagnostics,
|
|
actionList,
|
|
validatedFields,
|
|
pos,
|
|
translate,
|
|
) => {
|
|
let validActions = ['*', 'no-op', ...schema.validActions];
|
|
let errorMessage = (action) =>
|
|
translate('actions.invalid-action', { action });
|
|
const { actionsType, actions } = getValidActions(schema, {
|
|
typeValue: validatedFields.type?.value,
|
|
idsValue: validatedFields.ids?.value,
|
|
idsWildcard: validatedFields.ids?.wildcard,
|
|
idsKnownType: validatedFields.ids?.knownType,
|
|
});
|
|
validActions = actions.length > 0 ? actions : validActions;
|
|
|
|
switch (actionsType) {
|
|
case 'id':
|
|
errorMessage = (action) =>
|
|
translate('actions.id-only', {
|
|
type: validatedFields.ids.knownType,
|
|
action,
|
|
});
|
|
break;
|
|
case 'collection':
|
|
errorMessage = (action) =>
|
|
translate('actions.collection-only', { action });
|
|
break;
|
|
case 'child':
|
|
case 'type':
|
|
case 'all':
|
|
validActions = ['no-op', ...validActions];
|
|
break;
|
|
}
|
|
|
|
let segmentStart = pos.valueStart;
|
|
for (const [i, action] of actionList.entries()) {
|
|
let segmentEnd = segmentStart + action.length;
|
|
if (i < actionList.length - 1) {
|
|
segmentEnd += 1; // Include comma delimiter for all but last action
|
|
}
|
|
if (actionList.slice(i + 1).includes(action)) {
|
|
diagnostics.push({
|
|
from: segmentStart,
|
|
to: segmentEnd,
|
|
severity: 'error',
|
|
message: translate('actions.duplicate-action', { action }),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
return;
|
|
}
|
|
if (!validActions.includes(action)) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: errorMessage(action),
|
|
});
|
|
return;
|
|
}
|
|
segmentStart += action.length + 1; // +1 for comma
|
|
}
|
|
|
|
// no-op should be used with list action or else it has no effect.
|
|
// Also, no-op is not necessary if there is already any other action specified.
|
|
const noopAllowed = validActions.includes('no-op');
|
|
const hasNoOp = actionList.includes('no-op');
|
|
const hasList = actionList.includes('list');
|
|
const hasOtherActions = actionList.some(
|
|
(action) => action !== 'list' && action !== 'no-op',
|
|
);
|
|
if (noopAllowed) {
|
|
if (hasNoOp) {
|
|
if (!hasList) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'warning',
|
|
message: translate('actions.noop-requires-list'),
|
|
});
|
|
return;
|
|
}
|
|
if (hasOtherActions) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: translate('actions.noop-unnecessary'),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
if (hasList) {
|
|
if (!hasNoOp && !hasOtherActions) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'warning',
|
|
message: translate('actions.list-requires-noop'),
|
|
actions: [createAddTextAction(translate('editor.add-noop'))],
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const validateOutputFieldsField = (
|
|
schema,
|
|
diagnostics,
|
|
outputFieldList,
|
|
validatedFields,
|
|
pos,
|
|
translate,
|
|
) => {
|
|
const {
|
|
outputFieldsType,
|
|
outputFields: validOutputFields,
|
|
resolvedType,
|
|
} = getValidOutputFields(schema, {
|
|
typeValue: validatedFields.type?.value,
|
|
idsKnownType: validatedFields.ids?.knownType,
|
|
});
|
|
const errorMessage =
|
|
outputFieldsType === 'type'
|
|
? (field) =>
|
|
translate('output-fields.invalid-field-for-type', {
|
|
field,
|
|
type: resolvedType,
|
|
})
|
|
: (field) => translate('output-fields.invalid-field', { field });
|
|
|
|
let segmentStart = pos.valueStart;
|
|
for (const [i, field] of outputFieldList.entries()) {
|
|
let segmentEnd = segmentStart + field.length;
|
|
if (i < outputFieldList.length - 1) {
|
|
segmentEnd += 1;
|
|
}
|
|
if (outputFieldList.slice(i + 1).includes(field)) {
|
|
diagnostics.push({
|
|
from: segmentStart,
|
|
to: segmentEnd,
|
|
severity: 'error',
|
|
message: translate('output-fields.duplicate-field', { field }),
|
|
actions: [createDeleteTextAction(translate('editor.remove'))],
|
|
});
|
|
return;
|
|
}
|
|
if (!validOutputFields.includes(field)) {
|
|
diagnostics.push({
|
|
from: pos.valueStart,
|
|
to: pos.valueEnd,
|
|
severity: 'error',
|
|
message: errorMessage(field),
|
|
});
|
|
return;
|
|
}
|
|
segmentStart += field.length + 1;
|
|
}
|
|
};
|
|
|
|
export const createGrantLinter = (grantsSchema, translate) => {
|
|
const schema = normalizeGrantsSchema(grantsSchema);
|
|
return (context) => grantLinter(context, schema, translate);
|
|
};
|