feat: 重构配置校验为 TypeBox + Ajv + semantic validator,严格禁止未知字段
- 新增 config-contract 模块(TypeBox fragments、Ajv 契约校验、ConfigValidationIssue) - CheckerDefinition 扩展为含 configKey、schemas、validate 的完整插件接口 - HTTP/Command 各自维护 contract.ts + validate.ts,校验从 resolve 中分离 - resolve 不再承担校验,只做默认值合并和路径/单位解析 - config-loader 流程: unknown → RawProbeConfig → ValidatedProbeConfig → ResolvedConfig - 导出 probe-config.schema.json,新增 schema/schema:check 脚本 - 更新 DEVELOPMENT.md 新增 1.7 开发新 Checker 完整指引 - 同步更新 4 个 main specs(probe-config、command-checker、expect-body-checkers、checker-runner-abstraction)
This commit is contained in:
7
src/server/checker/config-contract/export.ts
Normal file
7
src/server/checker/config-contract/export.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CheckerRegistry } from "../runner/registry";
|
||||
|
||||
import { createExternalProbeConfigSchema } from "./schema";
|
||||
|
||||
export function createProbeConfigJsonSchema(registry: CheckerRegistry): Record<string, unknown> {
|
||||
return createExternalProbeConfigSchema(registry.definitions);
|
||||
}
|
||||
98
src/server/checker/config-contract/fragments.ts
Normal file
98
src/server/checker/config-contract/fragments.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { JsonValue } from "./types";
|
||||
|
||||
export const HTTP_METHODS = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] as const;
|
||||
|
||||
export const BodyRuleTypeKeys = ["contains", "regex", "json", "css", "xpath"] as const;
|
||||
|
||||
export const OperatorKeys = ["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"] as const;
|
||||
|
||||
export const durationSchema = Type.String();
|
||||
|
||||
export const httpMethodSchema = Type.Union(
|
||||
HTTP_METHODS.map((method) => Type.Literal(method)) as unknown as [TSchema, ...TSchema[]],
|
||||
);
|
||||
|
||||
export const jsonValueSchema = Type.Unsafe<JsonValue>({
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "boolean" },
|
||||
{ type: "null" },
|
||||
{ items: {}, type: "array" },
|
||||
{ additionalProperties: {}, type: "object" },
|
||||
],
|
||||
});
|
||||
|
||||
export const sizeSchema = Type.Union([Type.String(), Type.Integer({ minimum: 0 })]);
|
||||
|
||||
export const statusCodePatternSchema = Type.Union([
|
||||
Type.Integer({ maximum: 599, minimum: 100 }),
|
||||
Type.String({ pattern: "^[1-5]xx$" }),
|
||||
]);
|
||||
|
||||
export const stringMapSchema = Type.Unsafe<Record<string, string>>({
|
||||
additionalProperties: { type: "string" },
|
||||
type: "object",
|
||||
});
|
||||
|
||||
export function createBodyRulesSchema(): TSchema {
|
||||
return Type.Array(
|
||||
Type.Object(
|
||||
{
|
||||
contains: Type.Optional(Type.String()),
|
||||
css: Type.Optional(
|
||||
Type.Object(
|
||||
{ attr: Type.Optional(Type.String()), selector: Type.String({ minLength: 1 }), ...operatorProperties() },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
json: Type.Optional(
|
||||
Type.Object({ path: Type.String(), ...operatorProperties() }, { additionalProperties: false }),
|
||||
),
|
||||
regex: Type.Optional(Type.String()),
|
||||
xpath: Type.Optional(
|
||||
Type.Object(
|
||||
{ path: Type.String({ minLength: 1 }), ...operatorProperties() },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function createHeaderExpectSchema(): TSchema {
|
||||
return Type.Unsafe<Record<string, unknown>>({
|
||||
additionalProperties: {
|
||||
anyOf: [{ type: "string" }, createPureOperatorSchema()],
|
||||
},
|
||||
type: "object",
|
||||
});
|
||||
}
|
||||
|
||||
export function createPureOperatorSchema(): TSchema {
|
||||
return Type.Object(operatorProperties(), { additionalProperties: false, minProperties: 1 });
|
||||
}
|
||||
|
||||
export function createTextRulesSchema(): TSchema {
|
||||
return Type.Array(createPureOperatorSchema());
|
||||
}
|
||||
|
||||
export function operatorProperties(): Record<string, TSchema> {
|
||||
return {
|
||||
contains: Type.Optional(Type.String()),
|
||||
empty: Type.Optional(Type.Boolean()),
|
||||
equals: Type.Optional(jsonValueSchema),
|
||||
exists: Type.Optional(Type.Boolean()),
|
||||
gt: Type.Optional(Type.Number()),
|
||||
gte: Type.Optional(Type.Number()),
|
||||
lt: Type.Optional(Type.Number()),
|
||||
lte: Type.Optional(Type.Number()),
|
||||
match: Type.Optional(Type.String()),
|
||||
};
|
||||
}
|
||||
37
src/server/checker/config-contract/issues.ts
Normal file
37
src/server/checker/config-contract/issues.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface ConfigValidationIssue {
|
||||
code: string;
|
||||
message: string;
|
||||
path: string;
|
||||
targetName?: string;
|
||||
}
|
||||
|
||||
export function formatConfigIssues(issues: ConfigValidationIssue[]): string {
|
||||
return issues.map(formatConfigIssue).join("\n");
|
||||
}
|
||||
|
||||
export function issue(code: string, path: string, message: string, targetName?: string): ConfigValidationIssue {
|
||||
return targetName === undefined ? { code, message, path } : { code, message, path, targetName };
|
||||
}
|
||||
|
||||
export function joinPath(base: string, key: string): string {
|
||||
if (base === "") return key;
|
||||
if (key.startsWith("[")) return `${base}${key}`;
|
||||
return `${base}.${key}`;
|
||||
}
|
||||
|
||||
export function renderPath(path: string): string {
|
||||
return path === "" ? "配置文件" : path;
|
||||
}
|
||||
|
||||
export function throwConfigIssues(issues: ConfigValidationIssue[]): never {
|
||||
throw new Error(formatConfigIssues(issues));
|
||||
}
|
||||
|
||||
function formatConfigIssue(issue: ConfigValidationIssue): string {
|
||||
if (issue.targetName) {
|
||||
const path = issue.path.replace(/^targets\[\d+\]\.?/, "");
|
||||
const renderedPath = path === "" ? "配置" : path;
|
||||
return `target "${issue.targetName}" 的 ${renderedPath} ${issue.message}`;
|
||||
}
|
||||
return `${renderPath(issue.path)} ${issue.message}`;
|
||||
}
|
||||
89
src/server/checker/config-contract/schema.ts
Normal file
89
src/server/checker/config-contract/schema.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerDefinition } from "../runner/types";
|
||||
|
||||
import { durationSchema } from "./fragments";
|
||||
|
||||
export function createExternalProbeConfigSchema(checkers: CheckerDefinition[]): Record<string, unknown> {
|
||||
return {
|
||||
...cloneSchema(createProbeConfigSchema(checkers, true)),
|
||||
$id: "https://dial.local/probe-config.schema.json",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
definitions: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function createProbeConfigSchema(checkers: CheckerDefinition[], external = false): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
defaults: Type.Optional(createDefaultsSchema(checkers)),
|
||||
runtime: Type.Optional(
|
||||
Type.Object(
|
||||
{ maxConcurrentChecks: Type.Optional(Type.Integer({ minimum: 1 })) },
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
server: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
dataDir: Type.Optional(Type.String()),
|
||||
host: Type.Optional(Type.String()),
|
||||
port: Type.Optional(Type.Integer({ maximum: 65535, minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
targets: Type.Array(external ? createExternalTargetSchema(checkers) : createBaseTargetSchema(checkers), {
|
||||
minItems: 1,
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function createTargetSchema(checker: CheckerDefinition): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
expect: Type.Optional(checker.schemas.expect),
|
||||
group: Type.Optional(Type.String()),
|
||||
interval: Type.Optional(durationSchema),
|
||||
name: Type.String({ minLength: 1 }),
|
||||
timeout: Type.Optional(durationSchema),
|
||||
type: Type.Literal(checker.type),
|
||||
};
|
||||
properties[checker.configKey] = checker.schemas.config;
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function cloneSchema(schema: TSchema): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(schema)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createBaseTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
return Type.Object(
|
||||
{
|
||||
group: Type.Optional(Type.String()),
|
||||
interval: Type.Optional(durationSchema),
|
||||
name: Type.String({ minLength: 1 }),
|
||||
timeout: Type.Optional(durationSchema),
|
||||
type: Type.Union(checkers.map((checker) => Type.Literal(checker.type)) as unknown as [TSchema, ...TSchema[]]),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
);
|
||||
}
|
||||
|
||||
function createDefaultsSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
const properties: Record<string, TSchema> = {
|
||||
interval: Type.Optional(durationSchema),
|
||||
timeout: Type.Optional(durationSchema),
|
||||
};
|
||||
for (const checker of checkers) {
|
||||
properties[checker.configKey] = Type.Optional(checker.schemas.defaults);
|
||||
}
|
||||
return Type.Object(properties, { additionalProperties: false });
|
||||
}
|
||||
|
||||
function createExternalTargetSchema(checkers: CheckerDefinition[]): TSchema {
|
||||
return Type.Union(checkers.map((checker) => createTargetSchema(checker)) as [TSchema, ...TSchema[]]);
|
||||
}
|
||||
13
src/server/checker/config-contract/types.ts
Normal file
13
src/server/checker/config-contract/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ProbeConfig } from "../types";
|
||||
|
||||
declare const validatedConfigBrand: unique symbol;
|
||||
|
||||
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
|
||||
|
||||
export type RawProbeConfig = ProbeConfig;
|
||||
|
||||
export type ValidatedProbeConfig = RawProbeConfig & { readonly [validatedConfigBrand]: true };
|
||||
|
||||
export function asValidatedConfig(config: RawProbeConfig): ValidatedProbeConfig {
|
||||
return config as ValidatedProbeConfig;
|
||||
}
|
||||
145
src/server/checker/config-contract/validate.ts
Normal file
145
src/server/checker/config-contract/validate.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { ErrorObject } from "ajv";
|
||||
|
||||
import Ajv from "ajv";
|
||||
|
||||
import type { CheckerRegistry } from "../runner/registry";
|
||||
import type { ConfigValidationIssue } from "./issues";
|
||||
import type { RawProbeConfig } from "./types";
|
||||
|
||||
import { issue } from "./issues";
|
||||
import { createProbeConfigSchema, createTargetSchema } from "./schema";
|
||||
|
||||
export function createConfigAjv(): Ajv {
|
||||
return new Ajv({ allErrors: true, coerceTypes: false, removeAdditional: false, strict: true, useDefaults: false });
|
||||
}
|
||||
|
||||
export function issuesFromAjvErrors(errors: ErrorObject[], root: unknown, basePath = ""): ConfigValidationIssue[] {
|
||||
return normalizeAjvErrors(errors, basePath).map((error) => issueFromAjvError(error, root, basePath));
|
||||
}
|
||||
|
||||
export function validateProbeConfigContract(
|
||||
config: unknown,
|
||||
registry: CheckerRegistry,
|
||||
): { config: null; issues: ConfigValidationIssue[] } | { config: RawProbeConfig; issues: [] } {
|
||||
const ajv = createConfigAjv();
|
||||
const checkers = registry.definitions;
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const rootValidate = ajv.compile(createProbeConfigSchema(checkers));
|
||||
if (!rootValidate(config)) {
|
||||
issues.push(...issuesFromAjvErrors(rootValidate.errors ?? [], config));
|
||||
}
|
||||
|
||||
if (isRecord(config) && isUnknownArray(config["targets"])) {
|
||||
const targets = config["targets"];
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const target = targets[i];
|
||||
if (!isRecord(target) || typeof target["type"] !== "string") continue;
|
||||
const checker = registry.tryGet(target["type"]);
|
||||
if (!checker) continue;
|
||||
const targetValidate = ajv.compile(createTargetSchema(checker));
|
||||
if (!targetValidate(target)) {
|
||||
issues.push(...issuesFromAjvErrors(targetValidate.errors ?? [], config, `targets[${i}]`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues.length > 0 ? { config: null, issues } : { config: config as RawProbeConfig, issues: [] };
|
||||
}
|
||||
|
||||
function buildIssuePath(basePath: string, error: ErrorObject): string {
|
||||
const pointerPath = jsonPointerToPath(error.instancePath);
|
||||
let path = basePath ? joinBasePath(basePath, pointerPath) : pointerPath;
|
||||
if (error.keyword === "required" && "missingProperty" in error.params) {
|
||||
path = joinBasePath(path, String(error.params["missingProperty"]));
|
||||
}
|
||||
if (error.keyword === "additionalProperties" && "additionalProperty" in error.params) {
|
||||
path = joinBasePath(path, String(error.params["additionalProperty"]));
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function hasMoreSpecificError(keywords: Set<string>): boolean {
|
||||
return ["const", "enum", "maximum", "minimum", "minLength", "pattern"].some((keyword) => keywords.has(keyword));
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function issueFromAjvError(error: ErrorObject, root: unknown, basePath: string): ConfigValidationIssue {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const targetName = targetNameFromPath(root, path);
|
||||
switch (error.keyword) {
|
||||
case "additionalProperties":
|
||||
return issue("unknown-field", path, "是未知字段", targetName);
|
||||
case "const":
|
||||
case "enum":
|
||||
return issue("invalid-value", path, "不在允许范围内", targetName);
|
||||
case "maximum":
|
||||
case "minimum":
|
||||
return issue("invalid-range", path, "数值范围不合法", targetName);
|
||||
case "minLength":
|
||||
return issue("invalid-format", path, "不能为空", targetName);
|
||||
case "pattern":
|
||||
return issue("invalid-format", path, "格式不合法", targetName);
|
||||
case "required":
|
||||
return issue("required", path, "缺少必填字段", targetName);
|
||||
case "type":
|
||||
return issue("invalid-type", path, "类型不合法", targetName);
|
||||
default:
|
||||
return issue("invalid-config", path, error.message ?? "配置不合法", targetName);
|
||||
}
|
||||
}
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function joinBasePath(basePath: string, path: string): string {
|
||||
if (basePath === "") return path;
|
||||
if (path === "") return basePath;
|
||||
if (path.startsWith("[")) return `${basePath}${path}`;
|
||||
return `${basePath}.${path}`;
|
||||
}
|
||||
|
||||
function jsonPointerToPath(pointer: string): string {
|
||||
if (pointer === "") return "";
|
||||
return pointer
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce((path, part) => (/^\d+$/.test(part) ? `${path}[${part}]` : joinBasePath(path, part)), "");
|
||||
}
|
||||
|
||||
function normalizeAjvErrors(errors: ErrorObject[], basePath: string): ErrorObject[] {
|
||||
const nonCompositeErrors = errors.filter((error) => error.keyword !== "anyOf" && error.keyword !== "oneOf");
|
||||
const candidates = nonCompositeErrors.length > 0 ? nonCompositeErrors : errors;
|
||||
const keywordsByPath = new Map<string, Set<string>>();
|
||||
|
||||
for (const error of candidates) {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
keywords.add(error.keyword);
|
||||
keywordsByPath.set(path, keywords);
|
||||
}
|
||||
|
||||
const seenValueErrors = new Set<string>();
|
||||
return candidates.filter((error) => {
|
||||
const path = buildIssuePath(basePath, error);
|
||||
const keywords = keywordsByPath.get(path) ?? new Set<string>();
|
||||
if (error.keyword === "type" && hasMoreSpecificError(keywords)) return false;
|
||||
if (error.keyword === "const" || error.keyword === "enum") {
|
||||
if (seenValueErrors.has(path)) return false;
|
||||
seenValueErrors.add(path);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function targetNameFromPath(root: unknown, path: string): string | undefined {
|
||||
const match = /^targets\[(\d+)\]/.exec(path);
|
||||
if (!match || !isRecord(root) || !isUnknownArray(root["targets"])) return undefined;
|
||||
const target = root["targets"][Number(match[1])];
|
||||
if (!isRecord(target) || typeof target["name"] !== "string") return undefined;
|
||||
return target["name"];
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import type { DefaultsConfig, EngineRuntimeConfig, ProbeConfig, ResolvedTarget, TargetConfig } from "./types";
|
||||
import type { ConfigValidationIssue } from "./config-contract/issues";
|
||||
import type { DefaultsConfig, EngineRuntimeConfig, ResolvedTarget, TargetConfig } from "./types";
|
||||
|
||||
import { issue, throwConfigIssues } from "./config-contract/issues";
|
||||
import { asValidatedConfig, type RawProbeConfig } from "./config-contract/types";
|
||||
import { validateProbeConfigContract } from "./config-contract/validate";
|
||||
import { checkerRegistry } from "./runner";
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
@@ -28,38 +32,68 @@ export async function loadConfig(configPath: string): Promise<ResolvedConfig> {
|
||||
}
|
||||
|
||||
const content = await file.text();
|
||||
const raw = Bun.YAML.parse(content) as null | ProbeConfig;
|
||||
const parsed = Bun.YAML.parse(content);
|
||||
|
||||
if (!raw) {
|
||||
if (!parsed) {
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
}
|
||||
|
||||
validateConfig(raw);
|
||||
const contractResult = validateProbeConfigContract(parsed, checkerRegistry);
|
||||
if (contractResult.config === null && !canRunSemanticValidation(parsed)) {
|
||||
throwConfigIssues(contractResult.issues);
|
||||
}
|
||||
const semanticInput = (contractResult.config ?? parsed) as RawProbeConfig;
|
||||
const validationIssues = validateConfig(semanticInput);
|
||||
|
||||
const allIssues = [...contractResult.issues, ...validationIssues];
|
||||
if (contractResult.config === null) {
|
||||
if (allIssues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(allIssues));
|
||||
}
|
||||
throw new Error("配置文件内容为空或格式无效");
|
||||
}
|
||||
|
||||
const raw = contractResult.config;
|
||||
|
||||
const validated = asValidatedConfig(raw);
|
||||
|
||||
const configDir = dirname(resolve(configPath));
|
||||
const server = raw.server ?? {};
|
||||
const runtime = raw.runtime ?? {};
|
||||
const defaults = raw.defaults ?? {};
|
||||
const server = validated.server ?? {};
|
||||
const runtime = validated.runtime ?? {};
|
||||
const defaults = validated.defaults ?? {};
|
||||
|
||||
const host = server.host ?? DEFAULT_HOST;
|
||||
const port = server.port ?? DEFAULT_PORT;
|
||||
const dataDir = server.dataDir ?? DEFAULT_DATA_DIR;
|
||||
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`无效端口号: ${port},需要 0-65535 之间的整数`);
|
||||
const maxConcurrentChecks = resolveMaxConcurrentChecks(runtime);
|
||||
|
||||
const allRuntimeIssues = [...allIssues];
|
||||
if (allRuntimeIssues.length > 0) {
|
||||
throwConfigIssues(dedupeIssues(allRuntimeIssues));
|
||||
}
|
||||
|
||||
const maxConcurrentChecks = validateRuntime(runtime);
|
||||
const defaultIntervalMs = parseDuration(defaults.interval ?? DEFAULT_INTERVAL);
|
||||
const defaultTimeoutMs = parseDuration(defaults.timeout ?? DEFAULT_TIMEOUT);
|
||||
|
||||
const targets: ResolvedTarget[] = raw.targets.map((target) =>
|
||||
const targets: ResolvedTarget[] = validated.targets.map((target) =>
|
||||
resolveTarget(target, defaults, defaultIntervalMs, defaultTimeoutMs, configDir),
|
||||
);
|
||||
|
||||
return { configDir, dataDir, host, maxConcurrentChecks, port, targets };
|
||||
}
|
||||
|
||||
function resolveMaxConcurrentChecks(runtime: EngineRuntimeConfig): number {
|
||||
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||
if (
|
||||
typeof runtime.maxConcurrentChecks !== "number" ||
|
||||
!Number.isInteger(runtime.maxConcurrentChecks) ||
|
||||
runtime.maxConcurrentChecks <= 0
|
||||
)
|
||||
return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||
return runtime.maxConcurrentChecks;
|
||||
}
|
||||
|
||||
function resolveTarget(
|
||||
target: TargetConfig,
|
||||
defaults: DefaultsConfig,
|
||||
@@ -79,56 +113,83 @@ function resolveTarget(
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateConfig(config: ProbeConfig): void {
|
||||
if (!config.targets || !Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
throw new Error("配置文件必须包含至少一个 target");
|
||||
function validateConfig(config: RawProbeConfig): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (!Array.isArray(config.targets) || config.targets.length === 0) {
|
||||
issues.push(issue("required", "targets", "配置文件必须包含至少一个 target"));
|
||||
return issues;
|
||||
}
|
||||
|
||||
const names = new Set<string>();
|
||||
const supportedTypes = checkerRegistry.supportedTypes;
|
||||
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const raw = config.targets[i] as unknown as Record<string, unknown>;
|
||||
const rawTarget = config.targets[i] as unknown;
|
||||
if (!isRecord(rawTarget)) {
|
||||
issues.push(issue("invalid-type", `targets[${i}]`, "必须为对象"));
|
||||
continue;
|
||||
}
|
||||
const raw = rawTarget;
|
||||
|
||||
const name = raw["name"];
|
||||
if (!name || typeof name !== "string" || name.trim() === "") {
|
||||
throw new Error(`第 ${i + 1} 个 target 缺少 name 字段`);
|
||||
issues.push(issue("required", `targets[${i}].name`, "缺少 name 字段"));
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = raw["type"];
|
||||
if (!type || typeof type !== "string") {
|
||||
throw new Error(`target "${name}" 缺少 type 字段`);
|
||||
issues.push(issue("required", `targets[${i}].type`, "缺少 type 字段", name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!supportedTypes.includes(type)) {
|
||||
throw new Error(`target "${name}" 使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`);
|
||||
issues.push(
|
||||
issue(
|
||||
"unsupported-type",
|
||||
`targets[${i}].type`,
|
||||
`使用不支持的 type: "${type}",支持: ${supportedTypes.join(", ")}`,
|
||||
name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const group = raw["group"];
|
||||
if (group !== undefined && typeof group !== "string") {
|
||||
throw new Error(`target "${name}" 的 group 字段必须为字符串`);
|
||||
issues.push(issue("invalid-type", `targets[${i}].group`, "必须为字符串", name));
|
||||
}
|
||||
|
||||
if (names.has(name)) {
|
||||
throw new Error(`target name 重复: "${name}"`);
|
||||
issues.push(issue("duplicate-name", `targets[${i}].name`, `target name 重复: "${name}"`, name));
|
||||
}
|
||||
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntime(runtime: EngineRuntimeConfig): number {
|
||||
if (runtime.maxConcurrentChecks === undefined) return DEFAULT_MAX_CONCURRENT_CHECKS;
|
||||
|
||||
if (
|
||||
typeof runtime.maxConcurrentChecks !== "number" ||
|
||||
!Number.isInteger(runtime.maxConcurrentChecks) ||
|
||||
runtime.maxConcurrentChecks <= 0
|
||||
) {
|
||||
throw new Error("runtime.maxConcurrentChecks 必须为正整数");
|
||||
for (const checker of checkerRegistry.definitions) {
|
||||
issues.push(...checker.validate({ defaults: config.defaults ?? {}, targets: config.targets }));
|
||||
}
|
||||
|
||||
return runtime.maxConcurrentChecks;
|
||||
validateDurationValue(config.defaults?.interval, "defaults.interval", issues);
|
||||
validateDurationValue(config.defaults?.timeout, "defaults.timeout", issues);
|
||||
for (let i = 0; i < config.targets.length; i++) {
|
||||
const target = config.targets[i] as unknown;
|
||||
if (!isRecord(target)) continue;
|
||||
const targetName = typeof target["name"] === "string" ? target["name"] : undefined;
|
||||
validateDurationValue(
|
||||
typeof target["interval"] === "string" ? target["interval"] : undefined,
|
||||
`targets[${i}].interval`,
|
||||
issues,
|
||||
targetName,
|
||||
);
|
||||
validateDurationValue(
|
||||
typeof target["timeout"] === "string" ? target["timeout"] : undefined,
|
||||
`targets[${i}].timeout`,
|
||||
issues,
|
||||
targetName,
|
||||
);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
const DURATION_REGEX = /^(\d+(?:\.\d+)?)(ms|s|m)$/;
|
||||
@@ -142,7 +203,43 @@ export function parseDuration(value: string): number {
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
if (unit === "ms") return num;
|
||||
if (unit === "s") return num * 1000;
|
||||
return num * 60 * 1000;
|
||||
const durationMs = unit === "ms" ? num : unit === "s" ? num * 1000 : num * 60 * 1000;
|
||||
if (!Number.isInteger(durationMs) || durationMs <= 0 || !Number.isFinite(durationMs)) {
|
||||
throw new Error(`无效的时长格式: "${value}",解析结果必须为正整数毫秒`);
|
||||
}
|
||||
return durationMs;
|
||||
}
|
||||
|
||||
function canRunSemanticValidation(value: unknown): boolean {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function dedupeIssues(issues: ConfigValidationIssue[]): ConfigValidationIssue[] {
|
||||
const seen = new Set<string>();
|
||||
const result: ConfigValidationIssue[] = [];
|
||||
for (const item of issues) {
|
||||
const key = `${item.code}:${item.path}:${item.message}:${item.targetName ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateDurationValue(
|
||||
value: string | undefined,
|
||||
path: string,
|
||||
issues: ConfigValidationIssue[],
|
||||
targetName?: string,
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
try {
|
||||
parseDuration(value);
|
||||
} catch (error) {
|
||||
issues.push(issue("invalid-duration", path, error instanceof Error ? error.message : "时长格式不合法", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
34
src/server/checker/runner/command/contract.ts
Normal file
34
src/server/checker/runner/command/contract.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import { createTextRulesSchema, sizeSchema, stringMapSchema } from "../../config-contract/fragments";
|
||||
|
||||
export const commandCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
{
|
||||
args: Type.Optional(Type.Array(Type.String())),
|
||||
cwd: Type.Optional(Type.String()),
|
||||
env: Type.Optional(stringMapSchema),
|
||||
exec: Type.String({ minLength: 1 }),
|
||||
maxOutputBytes: Type.Optional(sizeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
defaults: Type.Object(
|
||||
{
|
||||
cwd: Type.Optional(Type.String()),
|
||||
maxOutputBytes: Type.Optional(sizeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
exitCode: Type.Optional(Type.Array(Type.Integer())),
|
||||
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
stderr: Type.Optional(createTextRulesSchema()),
|
||||
stdout: Type.Optional(createTextRulesSchema()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
@@ -8,15 +8,21 @@ import type {
|
||||
ResolvedTarget,
|
||||
TargetConfig,
|
||||
} from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
|
||||
|
||||
import { parseSize } from "../../size";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { errorFailure } from "../shared/failure";
|
||||
import { checkTextRules } from "../shared/text";
|
||||
import { commandCheckerSchemas } from "./contract";
|
||||
import { checkExitCode } from "./expect";
|
||||
import { validateCommandConfig } from "./validate";
|
||||
|
||||
export class CommandChecker implements Checker {
|
||||
readonly configKey = "command";
|
||||
|
||||
readonly schemas = commandCheckerSchemas;
|
||||
|
||||
readonly type = "command";
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
@@ -29,7 +35,7 @@ export class CommandChecker implements Checker {
|
||||
try {
|
||||
proc = Bun.spawn([t.command.exec, ...t.command.args], {
|
||||
cwd: t.command.cwd,
|
||||
env: t.command.env,
|
||||
env: { ...process.env, ...t.command.env },
|
||||
stderr: "pipe",
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
@@ -172,10 +178,6 @@ export class CommandChecker implements Checker {
|
||||
const t = target as TargetConfig & { command: CommandTargetConfig; type: "command" };
|
||||
const commandDefaults = context.defaults.command;
|
||||
|
||||
if (!t.command.exec || t.command.exec.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 command.exec 字段`);
|
||||
}
|
||||
|
||||
const cwd = t.command.cwd ?? commandDefaults?.cwd ?? ".";
|
||||
const resolvedCwd = resolve(context.configDir, cwd);
|
||||
|
||||
@@ -214,6 +216,10 @@ export class CommandChecker implements Checker {
|
||||
target: `exec ${parts.join(" ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateCommandConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
async function readOutput(
|
||||
|
||||
93
src/server/checker/runner/command/validate.ts
Normal file
93
src/server/checker/runner/command/validate.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ConfigValidationIssue } from "../../config-contract/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
import { issue, joinPath } from "../../config-contract/issues";
|
||||
import { parseSize } from "../../size";
|
||||
import { validateTextRules } from "../shared/validate";
|
||||
|
||||
export function validateCommandConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const defaults =
|
||||
isRecord(input.defaults) && isRecord(input.defaults["command"]) ? input.defaults["command"] : undefined;
|
||||
|
||||
if (isSizeInput(defaults?.["maxOutputBytes"])) {
|
||||
issues.push(...validateSizeValue(defaults["maxOutputBytes"], "defaults.command.maxOutputBytes"));
|
||||
}
|
||||
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isRecord(target)) continue;
|
||||
if (target["type"] !== "command") continue;
|
||||
issues.push(...validateCommandTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
return typeof target["name"] === "string" ? target["name"] : undefined;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSizeInput(value: unknown): value is number | string {
|
||||
return typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
function validateCommandExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
if (expect["stdout"] !== undefined) {
|
||||
issues.push(...validateTextRules(expect["stdout"], joinPath(expectPath, "stdout"), targetName));
|
||||
}
|
||||
if (expect["stderr"] !== undefined) {
|
||||
issues.push(...validateTextRules(expect["stderr"], joinPath(expectPath, "stderr"), targetName));
|
||||
}
|
||||
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateCommandTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const command = target["command"];
|
||||
if (!isRecord(command)) {
|
||||
issues.push(issue("required", joinPath(path, "command"), "缺少 command.exec 字段", targetName));
|
||||
issues.push(...validateCommandExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
if (typeof command["exec"] !== "string" || command["exec"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "command"), "exec"), "缺少 command.exec 字段", targetName));
|
||||
}
|
||||
if (isSizeInput(command["maxOutputBytes"])) {
|
||||
issues.push(
|
||||
...validateSizeValue(
|
||||
command["maxOutputBytes"],
|
||||
joinPath(joinPath(path, "command"), "maxOutputBytes"),
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
issues.push(...validateCommandExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
try {
|
||||
parseSize(value);
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
|
||||
}
|
||||
}
|
||||
44
src/server/checker/runner/http/contract.ts
Normal file
44
src/server/checker/runner/http/contract.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { CheckerSchemas } from "../types";
|
||||
|
||||
import {
|
||||
createBodyRulesSchema,
|
||||
createHeaderExpectSchema,
|
||||
httpMethodSchema,
|
||||
sizeSchema,
|
||||
statusCodePatternSchema,
|
||||
stringMapSchema,
|
||||
} from "../../config-contract/fragments";
|
||||
|
||||
export const httpCheckerSchemas: CheckerSchemas = {
|
||||
config: Type.Object(
|
||||
{
|
||||
body: Type.Optional(Type.String()),
|
||||
headers: Type.Optional(stringMapSchema),
|
||||
ignoreSSL: Type.Optional(Type.Boolean()),
|
||||
maxBodyBytes: Type.Optional(sizeSchema),
|
||||
maxRedirects: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
method: Type.Optional(httpMethodSchema),
|
||||
url: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
defaults: Type.Object(
|
||||
{
|
||||
headers: Type.Optional(stringMapSchema),
|
||||
maxBodyBytes: Type.Optional(sizeSchema),
|
||||
method: Type.Optional(httpMethodSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
expect: Type.Object(
|
||||
{
|
||||
body: Type.Optional(createBodyRulesSchema()),
|
||||
headers: Type.Optional(createHeaderExpectSchema()),
|
||||
maxDurationMs: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
status: Type.Optional(Type.Array(statusCodePatternSchema)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
};
|
||||
@@ -1,22 +1,25 @@
|
||||
import { isError } from "es-toolkit";
|
||||
|
||||
import type { CheckResult, HttpTargetConfig, ResolvedHttpTarget, ResolvedTarget, TargetConfig } from "../../types";
|
||||
import type { Checker, CheckerContext, ResolveContext } from "../types";
|
||||
import type { Checker, CheckerContext, CheckerValidationInput, ResolveContext } from "../types";
|
||||
|
||||
import { parseSize } from "../../size";
|
||||
import { checkBodyExpect } from "../shared/body";
|
||||
import { checkDuration } from "../shared/duration";
|
||||
import { errorFailure, mismatchFailure } from "../shared/failure";
|
||||
import { httpCheckerSchemas } from "./contract";
|
||||
import { checkHeaders, checkStatus } from "./expect";
|
||||
import { validateHttpConfig, validateHttpExpect } from "./validate";
|
||||
import { validateHttpConfig } from "./validate";
|
||||
|
||||
const ALLOWED_METHODS = new Set(["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]);
|
||||
const CHARSET_RE = /charset="?([^";\s]+)"?/i;
|
||||
const URL_RE = /^https?:\/\/.+/;
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]);
|
||||
|
||||
export class HttpChecker implements Checker {
|
||||
readonly configKey = "http";
|
||||
|
||||
readonly schemas = httpCheckerSchemas;
|
||||
|
||||
readonly type = "http";
|
||||
|
||||
async execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult> {
|
||||
@@ -117,45 +120,7 @@ export class HttpChecker implements Checker {
|
||||
const t = target as TargetConfig & { http: HttpTargetConfig; type: "http" };
|
||||
const httpDefaults = context.defaults.http;
|
||||
|
||||
if (!t.http || typeof t.http !== "object") {
|
||||
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
|
||||
}
|
||||
|
||||
validateHttpConfig(t.http, t.name);
|
||||
|
||||
if (typeof t.http.url !== "string" || t.http.url.trim() === "") {
|
||||
throw new Error(`target "${t.name}" 缺少 http.url 字段`);
|
||||
}
|
||||
|
||||
const rawMethod = t.http.method ?? httpDefaults?.method ?? "GET";
|
||||
if (typeof rawMethod !== "string") {
|
||||
throw new Error(`target "${t.name}" 的 http.method 必须为字符串`);
|
||||
}
|
||||
|
||||
const method = rawMethod.toUpperCase();
|
||||
if (!ALLOWED_METHODS.has(method)) {
|
||||
throw new Error(
|
||||
`target "${t.name}" 的 http.method "${method}" 不合法,合法值: ${[...ALLOWED_METHODS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!URL_RE.test(t.http.url)) {
|
||||
throw new Error(`target "${t.name}" 的 http.url "${t.http.url}" 格式不合法,必须以 http:// 或 https:// 开头`);
|
||||
}
|
||||
|
||||
if (t.http.ignoreSSL !== undefined && typeof t.http.ignoreSSL !== "boolean") {
|
||||
throw new Error(`target "${t.name}" 的 http.ignoreSSL 必须为布尔值`);
|
||||
}
|
||||
|
||||
if (
|
||||
t.http.maxRedirects !== undefined &&
|
||||
(typeof t.http.maxRedirects !== "number" || !Number.isInteger(t.http.maxRedirects) || t.http.maxRedirects < 0)
|
||||
) {
|
||||
throw new Error(`target "${t.name}" 的 http.maxRedirects 必须为非负整数`);
|
||||
}
|
||||
|
||||
validateHttpExpect(target.expect, t.name);
|
||||
|
||||
const method = t.http.method ?? httpDefaults?.method ?? "GET";
|
||||
const maxBodyBytes = parseSize(t.http.maxBodyBytes ?? httpDefaults?.maxBodyBytes ?? "100MB");
|
||||
|
||||
return {
|
||||
@@ -192,6 +157,10 @@ export class HttpChecker implements Checker {
|
||||
target: t.http.url,
|
||||
};
|
||||
}
|
||||
|
||||
validate(input: CheckerValidationInput) {
|
||||
return validateHttpConfig(input);
|
||||
}
|
||||
}
|
||||
|
||||
function buildRedirectInit(init: RequestInit, statusCode: number, fromUrl: string, toUrl: string): RequestInit {
|
||||
|
||||
@@ -1,251 +1,139 @@
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import * as xpath from "xpath";
|
||||
import type { ConfigValidationIssue } from "../../config-contract/issues";
|
||||
import type { CheckerValidationInput } from "../types";
|
||||
|
||||
const BODY_RULE_TYPES = ["contains", "regex", "json", "css", "xpath"];
|
||||
import { issue, joinPath } from "../../config-contract/issues";
|
||||
import { parseSize } from "../../size";
|
||||
import { validateBodyRules, validateOperatorObject } from "../shared/validate";
|
||||
|
||||
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
|
||||
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
|
||||
export function validateHttpConfig(http: unknown, targetName: string): void {
|
||||
if (!http || typeof http !== "object") {
|
||||
throw new Error(`target "${targetName}" 缺少 http 配置`);
|
||||
export function validateHttpConfig(input: CheckerValidationInput): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const defaults = isRecord(input.defaults) && isRecord(input.defaults["http"]) ? input.defaults["http"] : undefined;
|
||||
|
||||
if (isSizeInput(defaults?.["maxBodyBytes"])) {
|
||||
issues.push(...validateSizeValue(defaults["maxBodyBytes"], "defaults.http.maxBodyBytes"));
|
||||
}
|
||||
|
||||
const h = http as Record<string, unknown>;
|
||||
for (let i = 0; i < input.targets.length; i++) {
|
||||
const target = input.targets[i] as unknown;
|
||||
if (!isRecord(target)) continue;
|
||||
if (target["type"] !== "http") continue;
|
||||
issues.push(...validateHttpTarget(target, `targets[${i}]`));
|
||||
}
|
||||
|
||||
if ("headers" in h && h["headers"] !== undefined) {
|
||||
if (typeof h["headers"] !== "object" || h["headers"] === null || Array.isArray(h["headers"])) {
|
||||
throw new Error(`target "${targetName}" 的 http.headers 必须为对象`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function getTargetName(target: Record<string, unknown>): string | undefined {
|
||||
return typeof target["name"] === "string" ? target["name"] : undefined;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): boolean {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSizeInput(value: unknown): value is number | string {
|
||||
return typeof value === "number" || typeof value === "string";
|
||||
}
|
||||
|
||||
function validateHttpExpect(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const targetName = getTargetName(target);
|
||||
const expect = target["expect"];
|
||||
if (expect === undefined || expect === null || !isRecord(expect)) return [];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const expectPath = joinPath(path, "expect");
|
||||
|
||||
if (isRecord(expect["headers"])) {
|
||||
for (const [key, value] of Object.entries(expect["headers"])) {
|
||||
if (typeof value === "string") continue;
|
||||
issues.push(...validateOperatorObject(value, joinPath(joinPath(expectPath, "headers"), key), targetName));
|
||||
}
|
||||
for (const [key, value] of Object.entries(h["headers"] as Record<string, unknown>)) {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 http.headers.${key} 必须为字符串`);
|
||||
}
|
||||
|
||||
if (expect["body"] !== undefined) {
|
||||
issues.push(...validateBodyRules(expect["body"], joinPath(expectPath, "body"), targetName));
|
||||
}
|
||||
|
||||
if (Array.isArray(expect["status"])) {
|
||||
issues.push(...validateStatusValues(expect["status"], joinPath(expectPath, "status"), targetName));
|
||||
}
|
||||
|
||||
if (expect["maxDurationMs"] !== undefined && !isNonNegativeFiniteNumber(expect["maxDurationMs"])) {
|
||||
issues.push(issue("invalid-type", joinPath(expectPath, "maxDurationMs"), "必须为非负有限数字", targetName));
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateHttpTarget(target: Record<string, unknown>, path: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const targetName = getTargetName(target);
|
||||
const http = target["http"];
|
||||
if (!isRecord(http)) {
|
||||
issues.push(issue("required", joinPath(path, "http"), "缺少 http.url 字段", targetName));
|
||||
issues.push(...validateHttpExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
if (typeof http["url"] !== "string" || http["url"].trim() === "") {
|
||||
issues.push(issue("required", joinPath(joinPath(path, "http"), "url"), "缺少 http.url 字段", targetName));
|
||||
} else {
|
||||
try {
|
||||
const url = new URL(http["url"]);
|
||||
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
|
||||
issues.push(
|
||||
issue(
|
||||
"invalid-url",
|
||||
joinPath(joinPath(path, "http"), "url"),
|
||||
"格式不合法,必须以 http:// 或 https:// 开头",
|
||||
targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
issues.push(issue("invalid-url", joinPath(joinPath(path, "http"), "url"), "格式不合法", targetName));
|
||||
}
|
||||
}
|
||||
|
||||
if ("body" in h && h["body"] !== undefined) {
|
||||
if (typeof h["body"] !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 http.body 必须为字符串`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateHttpExpect(expect: unknown, targetName: string): void {
|
||||
if (expect === undefined || expect === null) return;
|
||||
if (typeof expect !== "object" || Array.isArray(expect)) {
|
||||
throw new Error(`target "${targetName}" 的 expect 必须为对象`);
|
||||
}
|
||||
|
||||
const e = expect as Record<string, unknown>;
|
||||
|
||||
if ("status" in e) validateStatus(e["status"], targetName);
|
||||
if ("maxDurationMs" in e) validateMaxDurationMs(e["maxDurationMs"], targetName);
|
||||
if ("headers" in e) validateExpectHeaders(e["headers"], targetName);
|
||||
if ("body" in e) validateBodyRules(e["body"], targetName);
|
||||
}
|
||||
|
||||
function validateBodyRules(body: unknown, targetName: string): void {
|
||||
if (!Array.isArray(body)) {
|
||||
throw new Error(`target "${targetName}" 的 expect.body 必须为数组`);
|
||||
}
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
validateSingleBodyRule(body[i], i, targetName);
|
||||
}
|
||||
}
|
||||
|
||||
function validateExpectHeaders(headers: unknown, targetName: string): void {
|
||||
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
|
||||
throw new Error(`target "${targetName}" 的 expect.headers 必须为对象`);
|
||||
}
|
||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
||||
if (typeof value === "string") continue;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
validateOperators(value as Record<string, unknown>, targetName, `expect.headers.${key}`);
|
||||
} else {
|
||||
throw new Error(`target "${targetName}" 的 expect.headers.${key} 必须为字符串或操作符对象`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateJsonPath(path: string, targetName: string, rulePath: string): void {
|
||||
const segments = path.slice(2).split(".");
|
||||
for (const seg of segments) {
|
||||
if (seg === "") {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.path 包含空段`);
|
||||
}
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch?.[1]!.trim() === "") {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.path 数组访问缺少属性名`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateMaxDurationMs(value: unknown, targetName: string): void {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`target "${targetName}" 的 expect.maxDurationMs 必须为非负有限数字`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOperators(ops: Record<string, unknown>, targetName: string, path: string): void {
|
||||
for (const [key, value] of Object.entries(ops)) {
|
||||
if (!OPERATOR_KEYS.has(key)) continue;
|
||||
switch (key) {
|
||||
case "contains":
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.contains 必须为字符串`);
|
||||
}
|
||||
break;
|
||||
case "empty":
|
||||
case "exists":
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为布尔值`);
|
||||
}
|
||||
break;
|
||||
case "equals":
|
||||
if (typeof value !== "boolean" && typeof value !== "number" && typeof value !== "string" && value !== null) {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.equals 类型不合法`);
|
||||
}
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.equals 不能为 NaN 或 Infinity`);
|
||||
}
|
||||
break;
|
||||
case "gt":
|
||||
case "gte":
|
||||
case "lt":
|
||||
case "lte":
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.${key} 必须为有限数字`);
|
||||
}
|
||||
break;
|
||||
case "match":
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.match 必须为字符串`);
|
||||
}
|
||||
try {
|
||||
new RegExp(value);
|
||||
} catch {
|
||||
throw new Error(`target "${targetName}" 的 ${path}.match 正则不合法`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateSingleBodyRule(rule: unknown, index: number, targetName: string): void {
|
||||
if (typeof rule !== "object" || rule === null) {
|
||||
throw new Error(`target "${targetName}" 的 expect.body[${index}] 必须为对象`);
|
||||
}
|
||||
|
||||
const ruleObj = rule as Record<string, unknown>;
|
||||
const found: string[] = [];
|
||||
|
||||
for (const type of BODY_RULE_TYPES) {
|
||||
if (type in ruleObj) found.push(type);
|
||||
}
|
||||
|
||||
if (found.length === 0) {
|
||||
throw new Error(
|
||||
`target "${targetName}" 的 expect.body[${index}] 缺少支持的规则类型(contains/regex/json/css/xpath)`,
|
||||
);
|
||||
}
|
||||
if (found.length > 1) {
|
||||
throw new Error(
|
||||
`target "${targetName}" 的 expect.body[${index}] 只能配置一种规则类型,当前包含: ${found.join(", ")}`,
|
||||
if (isSizeInput(http["maxBodyBytes"])) {
|
||||
issues.push(
|
||||
...validateSizeValue(http["maxBodyBytes"], joinPath(joinPath(path, "http"), "maxBodyBytes"), targetName),
|
||||
);
|
||||
}
|
||||
issues.push(...validateHttpExpect(target, path));
|
||||
return issues;
|
||||
}
|
||||
|
||||
const ruleType = found[0]!;
|
||||
const rulePath = `expect.body[${index}]`;
|
||||
|
||||
switch (ruleType) {
|
||||
case "contains":
|
||||
if (typeof ruleObj["contains"] !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.contains 必须为字符串`);
|
||||
}
|
||||
break;
|
||||
case "css": {
|
||||
const cssRule = ruleObj["css"];
|
||||
if (typeof cssRule !== "object" || cssRule === null) {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.css 必须为对象`);
|
||||
}
|
||||
const cr = cssRule as Record<string, unknown>;
|
||||
if (typeof cr["selector"] !== "string" || cr["selector"].trim() === "") {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.css.selector 必须为非空字符串`);
|
||||
}
|
||||
const cssOps: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(cr)) {
|
||||
if (k !== "selector" && k !== "attr") cssOps[k] = v;
|
||||
}
|
||||
validateOperators(cssOps, targetName, `${rulePath}.css`);
|
||||
break;
|
||||
}
|
||||
case "json": {
|
||||
const jsonRule = ruleObj["json"];
|
||||
if (typeof jsonRule !== "object" || jsonRule === null) {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.json 必须为对象`);
|
||||
}
|
||||
const jr = jsonRule as Record<string, unknown>;
|
||||
if (typeof jr["path"] !== "string" || !jr["path"].startsWith("$.") || jr["path"].length <= 2) {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.json.path 必须为以 "$." 开头的有效 JSONPath`);
|
||||
}
|
||||
validateJsonPath(jr["path"], targetName, `${rulePath}.json`);
|
||||
const jsonOps: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(jr)) {
|
||||
if (k !== "path") jsonOps[k] = v;
|
||||
}
|
||||
validateOperators(jsonOps, targetName, `${rulePath}.json`);
|
||||
break;
|
||||
}
|
||||
case "regex":
|
||||
if (typeof ruleObj["regex"] !== "string") {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 必须为字符串`);
|
||||
}
|
||||
try {
|
||||
new RegExp(ruleObj["regex"]);
|
||||
} catch {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.regex 正则不合法`);
|
||||
}
|
||||
break;
|
||||
case "xpath": {
|
||||
const xpathRule = ruleObj["xpath"];
|
||||
if (typeof xpathRule !== "object" || xpathRule === null) {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath 必须为对象`);
|
||||
}
|
||||
const xr = xpathRule as Record<string, unknown>;
|
||||
if (typeof xr["path"] !== "string" || xr["path"].trim() === "") {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path 必须为非空字符串`);
|
||||
}
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
|
||||
xpath.select(xr["path"], doc as unknown as Node);
|
||||
} catch {
|
||||
throw new Error(`target "${targetName}" 的 ${rulePath}.xpath.path xpath 不合法`);
|
||||
}
|
||||
const xpathOps: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(xr)) {
|
||||
if (k !== "path") xpathOps[k] = v;
|
||||
}
|
||||
validateOperators(xpathOps, targetName, `${rulePath}.xpath`);
|
||||
break;
|
||||
}
|
||||
function validateSizeValue(value: number | string, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
try {
|
||||
parseSize(value);
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [issue("invalid-size", path, error instanceof Error ? error.message : "size 格式不合法", targetName)];
|
||||
}
|
||||
}
|
||||
|
||||
function validateStatus(status: unknown, targetName: string): void {
|
||||
if (!Array.isArray(status)) {
|
||||
throw new Error(`target "${targetName}" 的 expect.status 必须为数组`);
|
||||
}
|
||||
for (const p of status) {
|
||||
if (typeof p === "number") {
|
||||
if (!Number.isInteger(p) || p < 100 || p > 599) {
|
||||
throw new Error(`target "${targetName}" 的 expect.status 数字 ${p} 不合法,必须为 100-599 之间的整数`);
|
||||
function validateStatusValues(values: unknown[], path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const value = values[i];
|
||||
const itemPath = `${path}[${i}]`;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isInteger(value) || value < 100 || value > 599) {
|
||||
issues.push(issue("invalid-status", itemPath, "status 数字必须为 100-599 之间的整数", targetName));
|
||||
}
|
||||
} else if (typeof p === "string") {
|
||||
if (!/^[1-5]xx$/.test(p)) {
|
||||
throw new Error(`target "${targetName}" 的 expect.status 模式 "${p}" 不合法,字符串必须为 "1xx" 到 "5xx" 格式`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`target "${targetName}" 的 expect.status 只能包含数字或范围模式字符串`);
|
||||
continue;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (!/^[1-5]xx$/.test(value)) {
|
||||
issues.push(issue("invalid-status", itemPath, "status 模式必须为 1xx 到 5xx", targetName));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
issues.push(issue("invalid-status", itemPath, "status 必须为整数或 1xx 到 5xx 模式", targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { CommandChecker } from "./command/runner";
|
||||
import { HttpChecker } from "./http/runner";
|
||||
import { checkerRegistry } from "./registry";
|
||||
import { CheckerRegistry, checkerRegistry } from "./registry";
|
||||
|
||||
export function registerCheckers(): void {
|
||||
checkerRegistry.register(new HttpChecker());
|
||||
checkerRegistry.register(new CommandChecker());
|
||||
export function createDefaultCheckerRegistry(): CheckerRegistry {
|
||||
const registry = new CheckerRegistry();
|
||||
registerCheckers(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function registerCheckers(registry = checkerRegistry): void {
|
||||
registry.register(new HttpChecker());
|
||||
registry.register(new CommandChecker());
|
||||
}
|
||||
|
||||
export { checkerRegistry } from "./registry";
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Checker } from "./types";
|
||||
import type { CheckerDefinition } from "./types";
|
||||
|
||||
export class CheckerRegistry {
|
||||
get definitions(): CheckerDefinition[] {
|
||||
return [...this.checkers.values()];
|
||||
}
|
||||
|
||||
get supportedTypes(): string[] {
|
||||
return [...this.checkers.keys()];
|
||||
}
|
||||
|
||||
private checkers = new Map<string, Checker>();
|
||||
private checkers = new Map<string, CheckerDefinition>();
|
||||
|
||||
get(type: string): Checker {
|
||||
get(type: string): CheckerDefinition {
|
||||
const checker = this.checkers.get(type);
|
||||
if (!checker) {
|
||||
throw new Error(`不支持的 probe type: "${type}"`);
|
||||
@@ -15,12 +19,16 @@ export class CheckerRegistry {
|
||||
return checker;
|
||||
}
|
||||
|
||||
register(checker: Checker): void {
|
||||
register(checker: CheckerDefinition): void {
|
||||
if (this.checkers.has(checker.type)) {
|
||||
throw new Error(`Checker type "${checker.type}" 已注册`);
|
||||
}
|
||||
this.checkers.set(checker.type, checker);
|
||||
}
|
||||
|
||||
tryGet(type: string): CheckerDefinition | undefined {
|
||||
return this.checkers.get(type);
|
||||
}
|
||||
}
|
||||
|
||||
export const checkerRegistry = new CheckerRegistry();
|
||||
|
||||
@@ -2,6 +2,8 @@ import { isEmptyObject, isEqual, isNil, isPlainObject } from "es-toolkit";
|
||||
|
||||
import type { ExpectOperator, ExpectValue } from "../../types";
|
||||
|
||||
const OPERATOR_KEYS = new Set(["contains", "empty", "equals", "exists", "gt", "gte", "lt", "lte", "match"]);
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
@@ -48,10 +50,10 @@ export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
}
|
||||
|
||||
export function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isPlainObject(expected)) {
|
||||
return applyOperator(actual, expected);
|
||||
if (isPlainObject(expected) && Object.keys(expected).some((key) => OPERATOR_KEYS.has(key))) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected });
|
||||
return applyOperator(actual, { equals: expected as Exclude<ExpectValue, ExpectOperator> });
|
||||
}
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
|
||||
223
src/server/checker/runner/shared/validate.ts
Normal file
223
src/server/checker/runner/shared/validate.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
import * as xpath from "xpath";
|
||||
|
||||
import type { ConfigValidationIssue } from "../../config-contract/issues";
|
||||
import type { JsonValue } from "../../types";
|
||||
|
||||
import { BodyRuleTypeKeys, OperatorKeys } from "../../config-contract/fragments";
|
||||
import { issue, joinPath } from "../../config-contract/issues";
|
||||
|
||||
const OPERATOR_KEY_SET = new Set<string>(OperatorKeys);
|
||||
|
||||
export function isJsonValue(value: unknown): value is JsonValue {
|
||||
if (value === null) return true;
|
||||
if (typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.every(isJsonValue);
|
||||
if (typeof value === "object") {
|
||||
return Object.values(value as Record<string, unknown>).every(isJsonValue);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateBodyRules(body: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!Array.isArray(body)) return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||
return body.flatMap((rule, index) => validateSingleBodyRule(rule, `${path}[${index}]`, targetName));
|
||||
}
|
||||
|
||||
export function validateJsonPath(path: string, rulePath: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!path.startsWith("$.") || path.length <= 2) {
|
||||
return [issue("invalid-jsonpath", joinPath(rulePath, "path"), '必须为以 "$." 开头的有效 JSONPath', targetName)];
|
||||
}
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const segments = path.slice(2).split(".");
|
||||
for (const seg of segments) {
|
||||
if (seg === "") {
|
||||
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "包含空段", targetName));
|
||||
}
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch?.[1]!.trim() === "") {
|
||||
issues.push(issue("invalid-jsonpath", joinPath(rulePath, "path"), "数组访问缺少属性名", targetName));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateOperatorObject(
|
||||
operators: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
options: { requireAtLeastOne: boolean } = { requireAtLeastOne: true },
|
||||
): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(operators)) return [issue("invalid-type", path, "必须为操作符对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
let found = 0;
|
||||
for (const [key, value] of Object.entries(operators)) {
|
||||
if (!OPERATOR_KEY_SET.has(key)) {
|
||||
issues.push(issue("unknown-operator", joinPath(path, key), "是未知 operator", targetName));
|
||||
continue;
|
||||
}
|
||||
if (value === undefined) continue;
|
||||
found++;
|
||||
issues.push(...validateOperatorValue(key, value, joinPath(path, key), targetName));
|
||||
}
|
||||
if (options.requireAtLeastOne && found === 0) {
|
||||
issues.push(issue("empty-operator", path, "必须包含至少一个合法 operator", targetName));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateTextRules(rules: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!Array.isArray(rules)) return [issue("invalid-type", path, "必须为数组", targetName)];
|
||||
return rules.flatMap((rule, index) => validateOperatorObject(rule, `${path}[${index}]`, targetName));
|
||||
}
|
||||
|
||||
function collectOperatorObject(
|
||||
object: Record<string, unknown>,
|
||||
allowedKeys: Set<string>,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): { issues: ConfigValidationIssue[]; operators: Record<string, unknown> } {
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
const operators: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
if (allowedKeys.has(key)) continue;
|
||||
if (OPERATOR_KEY_SET.has(key)) {
|
||||
operators[key] = value;
|
||||
} else {
|
||||
issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
|
||||
}
|
||||
}
|
||||
return { issues, operators };
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateCssRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (typeof rule["selector"] !== "string" || rule["selector"].trim() === "") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "selector"), "必须为非空字符串", targetName));
|
||||
}
|
||||
if ("attr" in rule && typeof rule["attr"] !== "string") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "attr"), "必须为字符串", targetName));
|
||||
}
|
||||
const result = collectOperatorObject(rule, new Set(["attr", "selector"]), path, targetName);
|
||||
issues.push(
|
||||
...result.issues,
|
||||
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateJsonRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (typeof rule["path"] !== "string") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为字符串", targetName));
|
||||
} else {
|
||||
issues.push(...validateJsonPath(rule["path"], path, targetName));
|
||||
}
|
||||
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
|
||||
issues.push(
|
||||
...result.issues,
|
||||
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateOperatorValue(
|
||||
key: string,
|
||||
value: unknown,
|
||||
path: string,
|
||||
targetName?: string,
|
||||
): ConfigValidationIssue[] {
|
||||
switch (key) {
|
||||
case "contains":
|
||||
return typeof value === "string" ? [] : [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||
case "empty":
|
||||
case "exists":
|
||||
return typeof value === "boolean" ? [] : [issue("invalid-type", path, "必须为布尔值", targetName)];
|
||||
case "equals":
|
||||
return isJsonValue(value) ? [] : [issue("invalid-type", path, "必须为 JSON value", targetName)];
|
||||
case "gt":
|
||||
case "gte":
|
||||
case "lt":
|
||||
case "lte":
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? []
|
||||
: [issue("invalid-type", path, "必须为有限数字", targetName)];
|
||||
case "match":
|
||||
if (typeof value !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||
try {
|
||||
new RegExp(value);
|
||||
return [];
|
||||
} catch {
|
||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||
}
|
||||
default:
|
||||
return [issue("unknown-operator", path, "是未知 operator", targetName)];
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegexRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (typeof rule !== "string") return [issue("invalid-type", path, "必须为字符串", targetName)];
|
||||
try {
|
||||
new RegExp(rule);
|
||||
return [];
|
||||
} catch {
|
||||
return [issue("invalid-regex", path, "正则不合法", targetName)];
|
||||
}
|
||||
}
|
||||
|
||||
function validateSingleBodyRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const found = BodyRuleTypeKeys.filter((type) => type in rule);
|
||||
if (found.length === 0) return [issue("missing-body-rule", path, "缺少支持的规则类型", targetName)];
|
||||
if (found.length > 1) return [issue("multiple-body-rules", path, "只能配置一种规则类型", targetName)];
|
||||
|
||||
const ruleType = found[0]!;
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (const key of Object.keys(rule)) {
|
||||
if (key !== ruleType) issues.push(issue("unknown-field", joinPath(path, key), "是未知字段", targetName));
|
||||
}
|
||||
if (issues.length > 0) return issues;
|
||||
|
||||
switch (ruleType) {
|
||||
case "contains":
|
||||
return typeof rule["contains"] === "string"
|
||||
? []
|
||||
: [issue("invalid-type", joinPath(path, "contains"), "必须为字符串", targetName)];
|
||||
case "css":
|
||||
return validateCssRule(rule["css"], joinPath(path, "css"), targetName);
|
||||
case "json":
|
||||
return validateJsonRule(rule["json"], joinPath(path, "json"), targetName);
|
||||
case "regex":
|
||||
return validateRegexRule(rule["regex"], joinPath(path, "regex"), targetName);
|
||||
case "xpath":
|
||||
return validateXpathRule(rule["xpath"], joinPath(path, "xpath"), targetName);
|
||||
}
|
||||
}
|
||||
|
||||
function validateXpathRule(rule: unknown, path: string, targetName?: string): ConfigValidationIssue[] {
|
||||
if (!isPlainRecord(rule)) return [issue("invalid-type", path, "必须为对象", targetName)];
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
if (typeof rule["path"] !== "string" || rule["path"].trim() === "") {
|
||||
issues.push(issue("invalid-type", joinPath(path, "path"), "必须为非空字符串", targetName));
|
||||
} else {
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString("<x/>", "text/xml");
|
||||
xpath.select(rule["path"], doc as unknown as Node);
|
||||
} catch {
|
||||
issues.push(issue("invalid-xpath", joinPath(path, "path"), "xpath 不合法", targetName));
|
||||
}
|
||||
}
|
||||
const result = collectOperatorObject(rule, new Set(["path"]), path, targetName);
|
||||
issues.push(
|
||||
...result.issues,
|
||||
...validateOperatorObject(result.operators, path, targetName, { requireAtLeastOne: false }),
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
|
||||
import type { ConfigValidationIssue } from "../config-contract/issues";
|
||||
import type { CheckResult, DefaultsConfig, ResolvedTarget, TargetConfig } from "../types";
|
||||
|
||||
export interface Checker {
|
||||
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||
serialize(target: ResolvedTarget): { config: string; target: string };
|
||||
readonly type: string;
|
||||
}
|
||||
export type Checker = CheckerDefinition;
|
||||
|
||||
export interface CheckerContext {
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface CheckerDefinition {
|
||||
readonly configKey: string;
|
||||
execute(target: ResolvedTarget, ctx: CheckerContext): Promise<CheckResult>;
|
||||
resolve(target: TargetConfig, context: ResolveContext): ResolvedTarget;
|
||||
readonly schemas: CheckerSchemas;
|
||||
serialize(target: ResolvedTarget): { config: string; target: string };
|
||||
readonly type: string;
|
||||
validate(input: CheckerValidationInput): ConfigValidationIssue[];
|
||||
}
|
||||
|
||||
export interface CheckerSchemas {
|
||||
config: TSchema;
|
||||
defaults: TSchema;
|
||||
expect: TSchema;
|
||||
}
|
||||
|
||||
export interface CheckerValidationInput {
|
||||
defaults: DefaultsConfig;
|
||||
targets: TargetConfig[];
|
||||
}
|
||||
|
||||
export interface ResolveContext {
|
||||
configDir: string;
|
||||
defaultIntervalMs: number;
|
||||
|
||||
@@ -16,8 +16,10 @@ export function parseSize(value: number | string): number {
|
||||
const num = parseFloat(match[1]!);
|
||||
const unit = match[2]!;
|
||||
|
||||
if (unit === "B") return num;
|
||||
if (unit === "KB") return num * 1024;
|
||||
if (unit === "MB") return num * 1024 * 1024;
|
||||
return num * 1024 * 1024 * 1024;
|
||||
const bytes =
|
||||
unit === "B" ? num : unit === "KB" ? num * 1024 : unit === "MB" ? num * 1024 * 1024 : num * 1024 * 1024 * 1024;
|
||||
if (!Number.isInteger(bytes) || bytes < 0 || !Number.isSafeInteger(bytes)) {
|
||||
throw new Error(`无效的 size 数值: ${value},必须解析为非负安全整数字节数`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export type ExpectConfig = CommandExpectConfig | HttpExpectConfig;
|
||||
export interface ExpectOperator {
|
||||
contains?: string;
|
||||
empty?: boolean;
|
||||
equals?: boolean | null | number | string;
|
||||
equals?: JsonValue;
|
||||
exists?: boolean;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
@@ -58,7 +58,7 @@ export interface ExpectOperator {
|
||||
match?: string;
|
||||
}
|
||||
|
||||
export type ExpectValue = boolean | ExpectOperator | null | number | string;
|
||||
export type ExpectValue = ExpectOperator | JsonValue;
|
||||
|
||||
export type HeaderExpect = ExpectOperator | string;
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface HttpTargetConfig {
|
||||
|
||||
export type JsonRule = ExpectOperator & { path: string };
|
||||
|
||||
export type JsonValue = boolean | JsonValue[] | null | number | string | { [key: string]: JsonValue };
|
||||
|
||||
export interface ProbeConfig {
|
||||
defaults?: DefaultsConfig;
|
||||
runtime?: EngineRuntimeConfig;
|
||||
@@ -165,9 +167,8 @@ export interface StoredTarget {
|
||||
export type TargetConfig = BaseTargetConfig &
|
||||
({ command: CommandTargetConfig; type: "command" } | { http: HttpTargetConfig; type: "http" });
|
||||
|
||||
export type TargetType = "command" | "http";
|
||||
|
||||
export type { CheckFailure };
|
||||
export type TargetType = "command" | "http";
|
||||
export type TextRule = ExpectOperator;
|
||||
|
||||
export type XpathRule = ExpectOperator & { path: string };
|
||||
|
||||
Reference in New Issue
Block a user