feat(nacos): add config syntax validation with error location
This commit is contained in:
14 files changed
+604
-4
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateNacosConfig, validateNacosConfigContent } from "@/lib/nacos/nacosConfigValidation";
|
||||
|
||||
describe("Nacos config validation", () => {
|
||||
it("accepts valid structured formats", () => {
|
||||
expect(validateNacosConfig('{"enabled":true}', "json")).toBeNull();
|
||||
expect(validateNacosConfig("service:\n port: 8080", "yaml")).toBeNull();
|
||||
expect(validateNacosConfig("<root><value>1</value></root>", "xml")).toBeNull();
|
||||
expect(validateNacosConfig("<html><body>ok</body></html>", "html")).toBeNull();
|
||||
expect(validateNacosConfig('<!doctype html><html><head><meta charset="utf-8"><title>DBX</title></head><body><ul><li>one<li>two</ul><br></body></html>', "html")).toBeNull();
|
||||
expect(validateNacosConfig("server.port=8080", "properties")).toBeNull();
|
||||
expect(validateNacosConfig("feature.enabled", "properties")).toBeNull();
|
||||
expect(validateNacosConfig("key=\\\\u12xz", "properties")).toBeNull();
|
||||
expect(validateNacosConfig("[server]\nport = 8080", "toml")).toBeNull();
|
||||
expect(validateNacosConfig("ports = [\n 8000,\n 8001,\n]", "toml")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports JSON and YAML locations", () => {
|
||||
const json = validateNacosConfig('{\n "Version": "1",\n "name": "zhangsan",\n}', "json");
|
||||
expect(json?.message).toContain("Trailing comma");
|
||||
expect(json?.line).toBe(3);
|
||||
expect(json?.column).toBe(21);
|
||||
expect(json?.from).toBe(40);
|
||||
expect(json?.to).toBe(41);
|
||||
|
||||
const yaml = validateNacosConfig("service:\n\tport: 8080", "yaml");
|
||||
expect(yaml?.message).toContain("spaces");
|
||||
expect(yaml?.line).toBe(2);
|
||||
expect(yaml?.column).toBe(1);
|
||||
});
|
||||
|
||||
it("reports every recoverable JSON syntax error", () => {
|
||||
const content = `{
|
||||
"Version": "1",
|
||||
"Statement": [
|
||||
{
|
||||
"Action": [
|
||||
"pai:*"1
|
||||
],
|
||||
"Resource": "acs:paidsw:*:*:*",
|
||||
"Effect": "Allow",
|
||||
"Condition": {
|
||||
"StringEquals": {
|
||||
"pai:Accessibility": "PRIVATE",
|
||||
"pai:EntityAccessType": "CREATOR"
|
||||
}
|
||||
}
|
||||
},2
|
||||
{
|
||||
"Action": "next"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
const diagnostics = validateNacosConfigContent(content, "json");
|
||||
|
||||
expect(diagnostics).toHaveLength(2);
|
||||
expect(diagnostics[0]).toMatchObject({ line: 6, column: 16, from: content.indexOf("1\n") });
|
||||
expect(diagnostics[1]).toMatchObject({ line: 16, column: 7, from: content.indexOf("},2") + 2 });
|
||||
});
|
||||
|
||||
it("rejects malformed XML, HTML, Properties, and TOML", () => {
|
||||
expect(validateNacosConfig("<root><child></root>", "xml")?.message).toContain("XML");
|
||||
expect(validateNacosConfig("<div>", "html")?.message).toContain("unclosed tag");
|
||||
expect(validateNacosConfig("<div><span></div>", "html")?.message).toContain("HTML");
|
||||
expect(validateNacosConfig("<script>if (a < b) {}</script>", "html")).toBeNull();
|
||||
expect(validateNacosConfig("key=\\u12xz", "properties")?.message).toContain("Unicode");
|
||||
expect(validateNacosConfig("server = [", "toml")?.message).toContain("TOML");
|
||||
expect(validateNacosConfig('title = "unterminated', "toml")?.message).toContain("TOML");
|
||||
expect(validateNacosConfig('title = "invalid\\q"', "toml")?.message).toContain("TOML");
|
||||
expect(validateNacosConfig("ports = [1 2]", "toml")?.message).toContain("TOML");
|
||||
expect(validateNacosConfig("enabled = truth", "toml")?.message).toContain("TOML");
|
||||
expect(validateNacosConfig("[server] trailing", "toml")?.message).toContain("TOML");
|
||||
expect(validateNacosConfig('name = "dbx"\nname = "duplicate"', "toml")?.message).toContain("TOML");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { htmlLanguage } from "@codemirror/lang-html";
|
||||
import { jsonLanguage } from "@codemirror/lang-json";
|
||||
import { XMLValidator } from "fast-xml-parser";
|
||||
import { parse as parseToml, TomlError } from "smol-toml";
|
||||
import { parseDocument } from "yaml";
|
||||
|
||||
export type NacosConfigValidationFormat = "text" | "json" | "xml" | "yaml" | "html" | "properties" | "toml";
|
||||
|
||||
export interface NacosConfigDiagnostic {
|
||||
message: string;
|
||||
line: number;
|
||||
column: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
function lineColumnAt(text: string, offset: number) {
|
||||
const bounded = Math.max(0, Math.min(offset, text.length));
|
||||
const before = text.slice(0, bounded);
|
||||
const lineStart = before.lastIndexOf("\n") + 1;
|
||||
return { line: before.split("\n").length, column: bounded - lineStart + 1 };
|
||||
}
|
||||
|
||||
function diagnosticAt(text: string, offset: number, message: string, length = 1): NacosConfigDiagnostic {
|
||||
const position = lineColumnAt(text, offset);
|
||||
return { message, ...position, from: offset, to: Math.min(text.length, Math.max(offset + length, offset + 1)) };
|
||||
}
|
||||
|
||||
function offsetAtLineColumn(text: string, line: number, column: number): number {
|
||||
let offset = 0;
|
||||
for (let currentLine = 1; currentLine < line && offset < text.length; currentLine += 1) {
|
||||
const newline = text.indexOf("\n", offset);
|
||||
offset = newline < 0 ? text.length : newline + 1;
|
||||
}
|
||||
return Math.min(text.length, offset + Math.max(0, column - 1));
|
||||
}
|
||||
|
||||
type SyntaxErrorRange = { from: number; to: number };
|
||||
type HtmlOpenTag = { name: string; offset: number };
|
||||
|
||||
const HTML_VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
||||
const HTML_OPTIONAL_END_TAGS = new Set(["html", "head", "body", "colgroup", "li", "dt", "dd", "p", "rt", "rp", "optgroup", "option", "thead", "tbody", "tfoot", "tr", "td", "th"]);
|
||||
const HTML_RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title"]);
|
||||
const HTML_BLOCK_TAGS_THAT_CLOSE_P = new Set(["address", "article", "aside", "blockquote", "div", "dl", "fieldset", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul"]);
|
||||
|
||||
function syntaxErrors(text: string, language: { parser: { parse: (source: string) => { iterate: (spec: { enter: (node: { type: { isError: boolean }; from: number; to: number }) => void }) => void } } }): SyntaxErrorRange[] {
|
||||
const errors: SyntaxErrorRange[] = [];
|
||||
language.parser.parse(text).iterate({
|
||||
enter(node) {
|
||||
if (node.type.isError) errors.push({ from: node.from, to: node.to });
|
||||
},
|
||||
});
|
||||
return errors.filter((error, index) => errors.findIndex((candidate) => candidate.from === error.from && candidate.to === error.to) === index);
|
||||
}
|
||||
|
||||
function firstSyntaxError(text: string, language: { parser: { parse: (source: string) => { iterate: (spec: { enter: (node: { type: { isError: boolean }; from: number; to: number }) => void }) => void } } }, label: string) {
|
||||
const error = syntaxErrors(text, language)[0];
|
||||
return error ? diagnosticAt(text, error.from, `Invalid ${label} syntax`, Math.max(1, error.to - error.from)) : null;
|
||||
}
|
||||
|
||||
function htmlTagEnd(text: string, start: number): number {
|
||||
let quote = "";
|
||||
for (let index = start; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (quote) {
|
||||
if (character === quote) quote = "";
|
||||
} else if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
} else if (character === ">") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function closeImplicitHtmlTags(stack: HtmlOpenTag[], openingTag: string) {
|
||||
const current = stack[stack.length - 1]?.name;
|
||||
if (!current) return;
|
||||
if (openingTag === "li" && current === "li") stack.pop();
|
||||
else if (["dt", "dd"].includes(openingTag) && ["dt", "dd"].includes(current)) stack.pop();
|
||||
else if (openingTag === "option" && current === "option") stack.pop();
|
||||
else if (openingTag === "optgroup" && ["option", "optgroup"].includes(current)) stack.pop();
|
||||
else if (["thead", "tbody", "tfoot"].includes(openingTag) && ["thead", "tbody", "tfoot"].includes(current)) stack.pop();
|
||||
else if (openingTag === "tr" && current === "tr") stack.pop();
|
||||
else if (["td", "th"].includes(openingTag) && ["td", "th"].includes(current)) stack.pop();
|
||||
else if (current === "p" && HTML_BLOCK_TAGS_THAT_CLOSE_P.has(openingTag)) stack.pop();
|
||||
}
|
||||
|
||||
function validateHtml(text: string): NacosConfigDiagnostic | null {
|
||||
const syntaxError = firstSyntaxError(text, htmlLanguage, "HTML");
|
||||
if (syntaxError) return syntaxError;
|
||||
|
||||
const stack: HtmlOpenTag[] = [];
|
||||
for (let offset = 0; offset < text.length; ) {
|
||||
if (text[offset] !== "<") {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("<!--", offset)) {
|
||||
const end = text.indexOf("-->", offset + 4);
|
||||
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed comment");
|
||||
offset = end + 3;
|
||||
continue;
|
||||
}
|
||||
if (text.startsWith("<![CDATA[", offset)) {
|
||||
const end = text.indexOf("]]>", offset + 9);
|
||||
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed CDATA section");
|
||||
offset = end + 3;
|
||||
continue;
|
||||
}
|
||||
if (text[offset + 1] === "!" || text[offset + 1] === "?") {
|
||||
const end = htmlTagEnd(text, offset + 2);
|
||||
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed declaration");
|
||||
offset = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const closing = text[offset + 1] === "/";
|
||||
const nameStart = offset + (closing ? 2 : 1);
|
||||
const name = /^[A-Za-z][A-Za-z0-9:_-]*/.exec(text.slice(nameStart))?.[0]?.toLowerCase();
|
||||
if (!name) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const end = htmlTagEnd(text, nameStart + name.length);
|
||||
if (end < 0) return diagnosticAt(text, offset, "Invalid HTML syntax: unclosed tag");
|
||||
const selfClosing = /\/\s*$/.test(text.slice(nameStart + name.length, end));
|
||||
offset = end + 1;
|
||||
|
||||
if (closing) {
|
||||
while (stack.length && stack[stack.length - 1]?.name !== name && HTML_OPTIONAL_END_TAGS.has(stack[stack.length - 1]?.name || "")) stack.pop();
|
||||
const openTag = stack[stack.length - 1];
|
||||
if (!openTag) return diagnosticAt(text, nameStart, `Invalid HTML syntax: closing tag '${name}' has not been opened`, name.length);
|
||||
if (openTag.name !== name) return diagnosticAt(text, nameStart, `Invalid HTML syntax: expected closing tag '${openTag.name}'`, name.length);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
|
||||
closeImplicitHtmlTags(stack, name);
|
||||
if (!selfClosing && !HTML_VOID_TAGS.has(name)) stack.push({ name, offset });
|
||||
if (HTML_RAW_TEXT_TAGS.has(name)) {
|
||||
const closingTag = new RegExp(`</${name}\\s*>`, "i");
|
||||
const match = closingTag.exec(text.slice(offset));
|
||||
if (!match || match.index === undefined) return diagnosticAt(text, offset - 1, `Invalid HTML syntax: unclosed tag '${name}'`, name.length);
|
||||
offset += match.index;
|
||||
}
|
||||
}
|
||||
|
||||
const unclosed = [...stack].reverse().find((tag) => !HTML_OPTIONAL_END_TAGS.has(tag.name));
|
||||
return unclosed ? diagnosticAt(text, unclosed.offset, `Invalid HTML syntax: unclosed tag '${unclosed.name}'`, unclosed.name.length) : null;
|
||||
}
|
||||
|
||||
function jsonErrorOffset(text: string, error: SyntaxErrorRange): number {
|
||||
if (error.to > error.from) return error.from;
|
||||
const previous = previousNonWhitespaceOffset(text, error.from);
|
||||
const next = nextNonWhitespaceOffset(text, error.from);
|
||||
if (previous >= 0 && text[previous] === "," && next < text.length && (text[next] === "}" || text[next] === "]")) return previous;
|
||||
if (previous >= 0 && (next >= text.length || "{[}]".includes(text[next]))) return previous;
|
||||
return next < text.length ? next : Math.max(0, previous);
|
||||
}
|
||||
|
||||
function trailingCommaOffset(text: string, offset: number): number | null {
|
||||
const previous = previousNonWhitespaceOffset(text, offset);
|
||||
const next = nextNonWhitespaceOffset(text, offset);
|
||||
return previous >= 0 && text[previous] === "," && next < text.length && (text[next] === "}" || text[next] === "]") ? previous : null;
|
||||
}
|
||||
|
||||
function validateJson(text: string): NacosConfigDiagnostic[] {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return [];
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const errors = syntaxErrors(text, jsonLanguage);
|
||||
if (errors.length) {
|
||||
return errors.map((syntaxError, index) => {
|
||||
const offset = jsonErrorOffset(text, syntaxError);
|
||||
const trailingComma = trailingCommaOffset(text, syntaxError.from) ?? trailingCommaOffset(text, offset);
|
||||
if (trailingComma !== null) return diagnosticAt(text, trailingComma, "Trailing comma is not allowed in JSON");
|
||||
return diagnosticAt(text, offset, index === 0 ? message : "Invalid JSON syntax", Math.max(1, syntaxError.to - syntaxError.from));
|
||||
});
|
||||
}
|
||||
const match = /position\s+(\d+)/i.exec(message);
|
||||
return [diagnosticAt(text, match ? Number(match[1]) : 0, message)];
|
||||
}
|
||||
}
|
||||
|
||||
function previousNonWhitespaceOffset(text: string, offset: number): number {
|
||||
let index = Math.min(offset, text.length) - 1;
|
||||
while (index >= 0 && /\s/.test(text[index])) index--;
|
||||
return index;
|
||||
}
|
||||
|
||||
function nextNonWhitespaceOffset(text: string, offset: number): number {
|
||||
let index = Math.max(0, offset);
|
||||
while (index < text.length && /\s/.test(text[index])) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
function validateYaml(text: string): NacosConfigDiagnostic | null {
|
||||
const lines = text.split(/\r?\n/);
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
const indentation = /^[ \t]*/.exec(line)?.[0] ?? "";
|
||||
const tabIndex = indentation.indexOf("\t");
|
||||
if (tabIndex >= 0) {
|
||||
return diagnosticAt(text, offset + tabIndex, "YAML indentation must use spaces, not tabs");
|
||||
}
|
||||
offset += line.length + 1;
|
||||
}
|
||||
const document = parseDocument(text);
|
||||
const error = document.errors[0];
|
||||
if (error) {
|
||||
const offsetFromError = Array.isArray(error.pos) ? error.pos[0] : 0;
|
||||
return diagnosticAt(text, offsetFromError, error.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateXml(text: string): NacosConfigDiagnostic | null {
|
||||
const result = XMLValidator.validate(text);
|
||||
if (result === true) return null;
|
||||
const { err } = result;
|
||||
return diagnosticAt(text, offsetAtLineColumn(text, err.line, err.col), `Invalid XML syntax: ${err.msg}`);
|
||||
}
|
||||
|
||||
function validateProperties(text: string): NacosConfigDiagnostic | null {
|
||||
for (let offset = 0; offset < text.length; offset += 1) {
|
||||
if (text[offset] !== "\\") continue;
|
||||
if (text[offset + 1] !== "u") {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
const unicode = text.slice(offset + 2, offset + 6);
|
||||
if (!/^[0-9a-f]{4}$/i.test(unicode)) {
|
||||
return diagnosticAt(text, offset, "Invalid Properties Unicode escape", Math.min(6, text.length - offset));
|
||||
}
|
||||
offset += 5;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateToml(text: string): NacosConfigDiagnostic | null {
|
||||
try {
|
||||
parseToml(text);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split("\n\n", 1)[0] : String(error);
|
||||
const position = error instanceof TomlError ? offsetAtLineColumn(text, error.line, error.column) : 0;
|
||||
return diagnosticAt(text, position, `Invalid TOML syntax: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateNacosConfigContent(text: string, format: string): NacosConfigDiagnostic[] {
|
||||
const rawFormat = format.trim().toLowerCase();
|
||||
const normalized = (rawFormat === "yml" ? "yaml" : rawFormat === "props" ? "properties" : rawFormat) as NacosConfigValidationFormat;
|
||||
let diagnostic: NacosConfigDiagnostic | null = null;
|
||||
if (normalized === "json") return validateJson(text);
|
||||
else if (normalized === "yaml") diagnostic = validateYaml(text);
|
||||
else if (normalized === "xml") diagnostic = validateXml(text);
|
||||
else if (normalized === "html") diagnostic = validateHtml(text);
|
||||
else if (normalized === "properties") diagnostic = validateProperties(text);
|
||||
else if (normalized === "toml") diagnostic = validateToml(text);
|
||||
return diagnostic ? [diagnostic] : [];
|
||||
}
|
||||
|
||||
export function validateNacosConfig(text: string, format: string): NacosConfigDiagnostic | null {
|
||||
return validateNacosConfigContent(text, format)[0] ?? null;
|
||||
}
|
||||
Reference in new issue
Block a user