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:
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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user