cfpathcheck
Version:
Check CFML files for correct paths in cfinclude/cfimport tags
241 lines (205 loc) • 6.52 kB
JavaScript
import { existsSync } from 'node:fs';
import path from 'node:path';
import { getFiles, readFile } from './file.js';
import {
containsObject, checkIsXMLFile, matchAll,
} from './utils.js';
/**
* Compares two arrays of cfml taglib prefixes to see if there are any mismatches.
*
* @param {Array<string>} prefixArray1 - The first array of taglib prefixes.
* @param {Array<string>} prefixArray2 - The second array of taglib prefixes.
* @param {string} message - The message to display in case of a violation.
* @param {string} severity - The violation severity.
*
* @returns {Array<object>}
*/
export const comparePrefixArrays = (prefixArray1, prefixArray2, message, severity) => {
const prefixedViolations = {};
const prefixManifest = {};
const violations = [];
for (const value of prefixArray1) {
if (
!Object.hasOwn(prefixedViolations, value.prefix)
) {
prefixedViolations[value.prefix] = [];
}
const formattedMessage = message.replace('{2}', value.prefix);
for (const value2 of prefixArray2) {
// Key not found
if (value.prefix === value2.prefix) {
prefixManifest[value.prefix] = true;
} else if (
!Object.hasOwn(prefixManifest, value.prefix)
|| !prefixManifest[value.prefix]
) {
// Don't override a true value with false - true is sticky
prefixManifest[value.prefix] = false;
}
const messageObject = {
line: value.line,
column: value.column || 1,
message: formattedMessage,
severity,
};
if (!containsObject(messageObject, prefixedViolations[value.prefix])) {
prefixedViolations[value.prefix].push(messageObject);
}
}
if (prefixArray2.length === 0) {
prefixManifest[value.prefix] = false;
const messageObject = {
line: value.line,
column: value.column || 1,
message: formattedMessage,
severity,
};
if (!containsObject(messageObject, prefixedViolations[value.prefix])) {
prefixedViolations[value.prefix].push(messageObject);
}
}
}
// Delete the array of messages for prefix keys that *were* found
for (const prefix in prefixManifest) {
if (Object.hasOwn(prefixManifest, prefix)) {
if (prefixManifest[prefix]) {
delete prefixedViolations[prefix];
} else {
for (const value of prefixedViolations[prefix]) {
violations.push(value);
}
}
}
}
return violations;
};
/**
* Checks a file for missing paths.
*
* @param {string} filePath - The path of the file to check.
*
* @returns {Array<object>}
*/
export const checkFile = (filePath) => {
const templatePathViolations = [];
const taglibPathViolations = [];
const prefixes = [];
const prefixUsages = [];
let isXMLFile = false;
let lineNumber = 1;
const lines = readFile(filePath).split('\n');
// Cache the dirname of the file being analysed
const fileDirname = path.dirname(filePath);
for (const line of lines) {
isXMLFile = checkIsXMLFile(line);
// Exclude @usage doc lines including code snippets
const isUsageLine = line.startsWith('@usage');
const importSearch = line.match(/prefix=["'](?<import>[A-Za-z\d]+)["']/);
if (importSearch !== null) {
prefixes.push({
prefix: importSearch.groups.import,
line: lineNumber,
column: importSearch.index + 1,
});
}
const namespaceSearch = matchAll(line, /<(?<namespace>[A-Za-z\d]+):/g);
// We're outputting an XML file - don't attempt to collate namespace prefix usages
if (!isXMLFile && !isUsageLine) {
for (const value of namespaceSearch) {
prefixUsages.push({
prefix: value.groups.namespace,
line: lineNumber,
column: value.index + 1,
});
}
}
const taglibMatches = matchAll(line, /taglib=["'](?<taglib>[^"']+)["']/g);
for (const taglibMatch of taglibMatches) {
let taglibPath = taglibMatch.groups.taglib;
if (!path.isAbsolute(taglibPath)) {
taglibPath = path.resolve(fileDirname, taglibPath);
}
if (!existsSync(taglibPath)) {
taglibPathViolations.push({
line: lineNumber,
column: taglibMatch.index + 1,
message: `cfimport taglib path ${taglibPath} not found`,
severity: 'error',
});
}
}
// Checks <cfinclude template="$path" />
const cfIncludeMatches = matchAll(line, /template=["'](?<path>[^"']+)["']/g);
// Checks include '$path'; (inside <cfscript>)
// @TODO fix vulnerable RegExp
const includeMatches = matchAll(line, /\binclude\s['"](?<path>.*\.cfm)['"]/g);
for (const includeMatch of [...cfIncludeMatches, ...includeMatches]) {
// Dynamic path (contains # or &): all we can check is the non-dynamic part,
// wound back to the last slash
let templatePath = includeMatch.groups.path;
const hashPos = templatePath.indexOf('#');
const ampersandPos = templatePath.indexOf('&');
if (hashPos !== -1 || ampersandPos !== -1) {
const searchPos = hashPos === -1 ? ampersandPos : hashPos;
const lastSlashPos = templatePath.lastIndexOf('/', searchPos);
templatePath = path.dirname(templatePath.slice(0, lastSlashPos));
}
// Can't work with webroot-virtual paths, e.g. /missing.cfm
if (templatePath.slice(0, 1) !== '/') {
if (!path.isAbsolute(templatePath)) {
// Resolve the templatePath relative to the dirname of the including file
templatePath = path.resolve(fileDirname, templatePath);
}
if (!existsSync(templatePath)) {
templatePathViolations.push({
line: lineNumber,
column: includeMatch.index + 1,
message: `cfinclude/include template path ${templatePath} not found`,
severity: 'error',
});
}
}
}
lineNumber += 1;
}
const unusedPrefixViolations = comparePrefixArrays(
prefixes,
prefixUsages,
'cfimported namespace prefix "{2}" not used',
'warning',
);
const unimportedPrefixViolations = comparePrefixArrays(
prefixUsages,
prefixes,
'used namespace prefix "{2}" not cfimported',
'error',
);
return [
...unimportedPrefixViolations,
...unusedPrefixViolations,
...templatePathViolations,
...taglibPathViolations,
];
};
/**
* Checks a file or files for path errors.
*
* @param {string} filePath - The full path of the file to check.
*
* @returns {Array<object>}
*/
export const check = (filePath) => {
const violations = [];
const fileNames = getFiles(filePath);
// Loop over our file list, checking each file
for (const fileName of fileNames) {
const fileViolations = checkFile(fileName);
if (fileViolations.length > 0) {
violations.push({
filename: fileName,
messages: fileViolations,
});
}
}
return violations;
};