fix: harden simulator data contracts
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
# Contracts
|
||||
|
||||
네이티브 JSON Schema와 브라우저용 관계 검증기를 함께 둡니다. JSON Schema는 파일 형식과 기본
|
||||
범위를 정의하고 `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼 스키마만으로
|
||||
표현하기 불편한 조건을 검사합니다.
|
||||
네이티브 JSON Schema와 브라우저용 검증기를 함께 둡니다. `schema-validator.js`가 저장소의 실제
|
||||
JSON Schema를 실행하고, `validation.js`는 중복 id, Nyquist, 시간 구간, 참조 무결성처럼
|
||||
스키마만으로 표현하기 불편한 조건을 추가 검사합니다.
|
||||
|
||||
계약 버전은 `1.0.0`, 길이 단위는 m, 좌표계는 배열 중심을 원점으로 하는 우수 좌표계입니다.
|
||||
`+x`는 오른쪽, `+y`는 위쪽, `+z`는 배열에서 장면 쪽입니다.
|
||||
|
||||
저장 의미와 canonical hash 규칙은 `docs/data-contracts.md`를 참고하세요.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function valuesEqual(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function resolvePointer(rootSchema, reference) {
|
||||
if (!reference.startsWith("#/")) throw new Error(`외부 schema ref는 지원하지 않습니다: ${reference}`);
|
||||
return reference
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce((current, key) => current?.[key], rootSchema);
|
||||
}
|
||||
|
||||
function matchesType(value, type) {
|
||||
if (type === "null") return value === null;
|
||||
if (type === "array") return Array.isArray(value);
|
||||
if (type === "object") return isRecord(value);
|
||||
if (type === "integer") return Number.isInteger(value);
|
||||
if (type === "number") return typeof value === "number" && Number.isFinite(value);
|
||||
return typeof value === type;
|
||||
}
|
||||
|
||||
function addIssue(issues, path, message) {
|
||||
issues.push({ path, message });
|
||||
}
|
||||
|
||||
function validateNode(value, schema, rootSchema, path, issues) {
|
||||
if (schema === false) {
|
||||
addIssue(issues, path, "허용되지 않는 값입니다.");
|
||||
return;
|
||||
}
|
||||
if (schema === true) return;
|
||||
if (schema.$ref) {
|
||||
const target = resolvePointer(rootSchema, schema.$ref);
|
||||
if (!target) throw new Error(`schema ref를 찾을 수 없습니다: ${schema.$ref}`);
|
||||
validateNode(value, target, rootSchema, path, issues);
|
||||
}
|
||||
if (schema.oneOf) {
|
||||
const branchResults = schema.oneOf.map((branch) => {
|
||||
const branchIssues = [];
|
||||
validateNode(value, branch, rootSchema, path, branchIssues);
|
||||
return branchIssues;
|
||||
});
|
||||
if (branchResults.filter((branchIssues) => branchIssues.length === 0).length !== 1) {
|
||||
addIssue(issues, path, "허용된 형식 중 정확히 하나와 일치해야 합니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Object.hasOwn(schema, "const") && !valuesEqual(value, schema.const)) {
|
||||
addIssue(issues, path, `${JSON.stringify(schema.const)} 값이어야 합니다.`);
|
||||
}
|
||||
if (schema.enum && !schema.enum.some((candidate) => valuesEqual(value, candidate))) {
|
||||
addIssue(issues, path, `허용 값: ${schema.enum.join(", ")}`);
|
||||
}
|
||||
|
||||
const expectedTypes = schema.type === undefined ? [] : Array.isArray(schema.type) ? schema.type : [schema.type];
|
||||
if (expectedTypes.length > 0 && !expectedTypes.some((type) => matchesType(value, type))) {
|
||||
addIssue(issues, path, `${expectedTypes.join(" 또는 ")} 형식이어야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
if (schema.minimum !== undefined && value < schema.minimum) addIssue(issues, path, `${schema.minimum} 이상이어야 합니다.`);
|
||||
if (schema.maximum !== undefined && value > schema.maximum) addIssue(issues, path, `${schema.maximum} 이하여야 합니다.`);
|
||||
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
|
||||
addIssue(issues, path, `${schema.exclusiveMinimum}보다 커야 합니다.`);
|
||||
}
|
||||
if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
|
||||
addIssue(issues, path, `${schema.exclusiveMaximum}보다 작아야 합니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (schema.minLength !== undefined && value.length < schema.minLength) addIssue(issues, path, `길이가 ${schema.minLength} 이상이어야 합니다.`);
|
||||
if (schema.maxLength !== undefined && value.length > schema.maxLength) addIssue(issues, path, `길이가 ${schema.maxLength} 이하여야 합니다.`);
|
||||
if (schema.pattern && !new RegExp(schema.pattern, "u").test(value)) addIssue(issues, path, `형식 ${schema.pattern}과 일치해야 합니다.`);
|
||||
if (schema.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T/.test(value) || !Number.isFinite(Date.parse(value)))) {
|
||||
addIssue(issues, path, "유효한 ISO 8601 date-time이어야 합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (schema.minItems !== undefined && value.length < schema.minItems) addIssue(issues, path, `항목이 ${schema.minItems}개 이상이어야 합니다.`);
|
||||
if (schema.maxItems !== undefined && value.length > schema.maxItems) addIssue(issues, path, `항목이 ${schema.maxItems}개 이하여야 합니다.`);
|
||||
if (schema.prefixItems) {
|
||||
schema.prefixItems.forEach((itemSchema, index) => {
|
||||
if (index < value.length) validateNode(value[index], itemSchema, rootSchema, `${path}[${index}]`, issues);
|
||||
});
|
||||
}
|
||||
if (schema.items !== undefined) {
|
||||
const startIndex = schema.prefixItems?.length ?? 0;
|
||||
for (let index = startIndex; index < value.length; index += 1) {
|
||||
validateNode(value[index], schema.items, rootSchema, `${path}[${index}]`, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
for (const requiredKey of schema.required ?? []) {
|
||||
if (!Object.hasOwn(value, requiredKey)) addIssue(issues, `${path}.${requiredKey}`, "필수 항목입니다.");
|
||||
}
|
||||
const properties = schema.properties ?? {};
|
||||
for (const [key, childValue] of Object.entries(value)) {
|
||||
if (Object.hasOwn(properties, key)) {
|
||||
validateNode(childValue, properties[key], rootSchema, `${path}.${key}`, issues);
|
||||
} else if (schema.additionalProperties === false) {
|
||||
addIssue(issues, `${path}.${key}`, "알 수 없는 항목입니다.");
|
||||
} else if (isRecord(schema.additionalProperties)) {
|
||||
validateNode(childValue, schema.additionalProperties, rootSchema, `${path}.${key}`, issues);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAgainstSchema(value, schema) {
|
||||
const issues = [];
|
||||
validateNode(value, schema, schema, "$", issues);
|
||||
return issues;
|
||||
}
|
||||
|
||||
const schemaPaths = {
|
||||
array: "/schemas/array.schema.json",
|
||||
scene: "/schemas/scene.schema.json",
|
||||
experiment: "/schemas/experiment.schema.json",
|
||||
result: "/schemas/result.schema.json",
|
||||
};
|
||||
const schemaPromises = new Map();
|
||||
|
||||
export async function loadSchema(name) {
|
||||
if (!Object.hasOwn(schemaPaths, name)) throw new Error(`알 수 없는 schema: ${name}`);
|
||||
if (!schemaPromises.has(name)) {
|
||||
schemaPromises.set(
|
||||
name,
|
||||
fetch(schemaPaths[name]).then((response) => {
|
||||
if (!response.ok) throw new Error(`${schemaPaths[name]} 로드 실패: HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}),
|
||||
);
|
||||
}
|
||||
return schemaPromises.get(name);
|
||||
}
|
||||
|
||||
export async function validateDocumentStructure(name, value) {
|
||||
return validateAgainstSchema(value, await loadSchema(name));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { validateDocumentStructure } from "./schema-validator.js";
|
||||
|
||||
export const SCHEMA_VERSION = "1.0.0";
|
||||
const MAX_MICROPHONES = 1024;
|
||||
const MAX_SOURCES = 32;
|
||||
@@ -84,7 +86,16 @@ function findDuplicates(values) {
|
||||
return [...duplicates];
|
||||
}
|
||||
|
||||
export function validateArrayDefinition(value) {
|
||||
function isSafeRelativeAssetPath(value) {
|
||||
if (typeof value !== "string" || value.trim() === "") return false;
|
||||
const normalized = value.split(String.fromCharCode(92)).join("/");
|
||||
return !normalized.startsWith("/") &&
|
||||
!/^[a-z][a-z0-9+.-]*:/i.test(normalized) &&
|
||||
!normalized.split("/").includes("..") &&
|
||||
!normalized.includes("\0");
|
||||
}
|
||||
|
||||
function validateArrayRelationships(value) {
|
||||
/** @type {ValidationIssue[]} */
|
||||
const issues = [];
|
||||
if (!validateHeader(value, "micarrsim.array", issues)) return issues;
|
||||
@@ -127,7 +138,7 @@ export function validateArrayDefinition(value) {
|
||||
return issues;
|
||||
}
|
||||
|
||||
function validateSignal(signal, path, issues, assetIds, sampleRate) {
|
||||
function validateSignal(signal, path, issues, assetsById, sampleRate) {
|
||||
if (!requireRecord(signal, path, issues)) return;
|
||||
if (signal.type === "tone") {
|
||||
if (requireFinite(signal.frequency, `${path}.frequency`, issues, { min: 0, exclusiveMin: true })) {
|
||||
@@ -143,15 +154,17 @@ function validateSignal(signal, path, issues, assetIds, sampleRate) {
|
||||
addIssue(issues, `${path}.color`, "white 또는 pink여야 합니다.");
|
||||
}
|
||||
} else if (signal.type === "asset") {
|
||||
if (requireString(signal.assetId, `${path}.assetId`, issues) && !assetIds.has(signal.assetId)) {
|
||||
addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
|
||||
if (requireString(signal.assetId, `${path}.assetId`, issues)) {
|
||||
const asset = assetsById.get(signal.assetId);
|
||||
if (!asset) addIssue(issues, `${path}.assetId`, `정의되지 않은 asset: ${signal.assetId}`);
|
||||
else if (asset.kind !== "audio") addIssue(issues, `${path}.assetId`, "audio asset을 참조해야 합니다.");
|
||||
}
|
||||
} else {
|
||||
addIssue(issues, `${path}.type`, "tone, noise 또는 asset이어야 합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateScene(value) {
|
||||
function validateSceneRelationships(value) {
|
||||
/** @type {ValidationIssue[]} */
|
||||
const issues = [];
|
||||
if (!validateHeader(value, "micarrsim.scene", issues)) return issues;
|
||||
@@ -166,24 +179,28 @@ export function validateScene(value) {
|
||||
requireInteger(value.seed, "$.seed", issues, { min: 0, max: 0xffffffff });
|
||||
|
||||
const assets = Array.isArray(value.assets) ? value.assets : [];
|
||||
const assetIds = new Set();
|
||||
const assetsById = new Map();
|
||||
const allIds = [];
|
||||
assets.forEach((asset, index) => {
|
||||
const path = `$.assets[${index}]`;
|
||||
if (!requireRecord(asset, path, issues)) return;
|
||||
if (requireString(asset.id, `${path}.id`, issues)) {
|
||||
assetIds.add(asset.id);
|
||||
assetsById.set(asset.id, asset);
|
||||
allIds.push(asset.id);
|
||||
}
|
||||
requireString(asset.fileName, `${path}.fileName`, issues);
|
||||
if (requireString(asset.fileName, `${path}.fileName`, issues) && !isSafeRelativeAssetPath(asset.fileName)) {
|
||||
addIssue(issues, `${path}.fileName`, "manifest 기준의 안전한 상대 경로여야 합니다.");
|
||||
}
|
||||
requireString(asset.mimeType, `${path}.mimeType`, issues);
|
||||
if (!["audio", "image"].includes(asset.kind)) addIssue(issues, `${path}.kind`, "audio 또는 image여야 합니다.");
|
||||
});
|
||||
for (const duplicate of findDuplicates(allIds)) addIssue(issues, "$.assets", `중복 asset id: ${duplicate}`);
|
||||
|
||||
if (value.image !== undefined && requireRecord(value.image, "$.image", issues)) {
|
||||
if (requireString(value.image.assetId, "$.image.assetId", issues) && !assetIds.has(value.image.assetId)) {
|
||||
addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
|
||||
if (requireString(value.image.assetId, "$.image.assetId", issues)) {
|
||||
const asset = assetsById.get(value.image.assetId);
|
||||
if (!asset) addIssue(issues, "$.image.assetId", `정의되지 않은 asset: ${value.image.assetId}`);
|
||||
else if (asset.kind !== "image") addIssue(issues, "$.image.assetId", "image asset을 참조해야 합니다.");
|
||||
}
|
||||
if (requireRecord(value.image.plane, "$.image.plane", issues)) {
|
||||
const plane = value.image.plane;
|
||||
@@ -225,23 +242,25 @@ export function validateScene(value) {
|
||||
if (sceneDurationValid && startValid && durationValid && source.startTime + source.duration > value.duration + 1e-9) {
|
||||
addIssue(issues, path, "음원 구간이 장면 duration을 벗어납니다.");
|
||||
}
|
||||
validateSignal(source.signal, `${path}.signal`, issues, assetIds, sampleRateValid ? value.sampleRate : 48000);
|
||||
validateSignal(source.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
|
||||
});
|
||||
for (const duplicate of findDuplicates(sourceIds)) addIssue(issues, "$.sources", `중복 음원 id: ${duplicate}`);
|
||||
|
||||
if (Array.isArray(value.ambient)) {
|
||||
const ambientIds = [];
|
||||
value.ambient.forEach((ambient, index) => {
|
||||
const path = `$.ambient[${index}]`;
|
||||
if (!requireRecord(ambient, path, issues)) return;
|
||||
requireString(ambient.id, `${path}.id`, issues);
|
||||
if (requireString(ambient.id, `${path}.id`, issues)) ambientIds.push(ambient.id);
|
||||
requireFinite(ambient.gainDb, `${path}.gainDb`, issues, { min: -120, max: 24 });
|
||||
validateSignal(ambient.signal, `${path}.signal`, issues, assetIds, sampleRateValid ? value.sampleRate : 48000);
|
||||
validateSignal(ambient.signal, `${path}.signal`, issues, assetsById, sampleRateValid ? value.sampleRate : 48000);
|
||||
});
|
||||
for (const duplicate of findDuplicates(ambientIds)) addIssue(issues, "$.ambient", `중복 ambient id: ${duplicate}`);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function validateExperiment(value, context = {}) {
|
||||
function validateExperimentRelationships(value, context = {}) {
|
||||
/** @type {ValidationIssue[]} */
|
||||
const issues = [];
|
||||
if (!validateHeader(value, "micarrsim.experiment", issues)) return issues;
|
||||
@@ -272,10 +291,11 @@ export function validateExperiment(value, context = {}) {
|
||||
if (!Array.isArray(value.frequencyBands) || value.frequencyBands.length === 0) {
|
||||
addIssue(issues, "$.frequencyBands", "주파수 대역이 하나 이상 필요합니다.");
|
||||
} else {
|
||||
const bandIds = [];
|
||||
value.frequencyBands.forEach((band, index) => {
|
||||
const path = `$.frequencyBands[${index}]`;
|
||||
if (!requireRecord(band, path, issues)) return;
|
||||
requireString(band.id, `${path}.id`, issues);
|
||||
if (requireString(band.id, `${path}.id`, issues)) bandIds.push(band.id);
|
||||
const minValid = requireFinite(band.minHz, `${path}.minHz`, issues, { min: 0 });
|
||||
const maxValid = requireFinite(band.maxHz, `${path}.maxHz`, issues, { min: 0, exclusiveMin: true });
|
||||
if (minValid && maxValid && band.minHz >= band.maxHz) {
|
||||
@@ -288,6 +308,7 @@ export function validateExperiment(value, context = {}) {
|
||||
addIssue(issues, `${path}.color`, "#RRGGBB 색상이어야 합니다.");
|
||||
}
|
||||
});
|
||||
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.frequencyBands", `중복 band id: ${duplicate}`);
|
||||
}
|
||||
|
||||
if (requireRecord(value.stft, "$.stft", issues)) {
|
||||
@@ -310,11 +331,89 @@ export function validateExperiment(value, context = {}) {
|
||||
exclusiveMin: true,
|
||||
});
|
||||
}
|
||||
if (context.arrayDefinition && context.scene) {
|
||||
const estimatedBytes =
|
||||
context.arrayDefinition.microphones.length * Math.ceil(context.scene.duration * context.scene.sampleRate) * 4;
|
||||
const maximumBytes = 512 * 1024 * 1024;
|
||||
if (estimatedBytes > maximumBytes) {
|
||||
addIssue(
|
||||
issues,
|
||||
"$",
|
||||
`현재 비스트리밍 CPU 모델의 채널 buffer 예상 크기 ${(estimatedBytes / 1024 ** 2).toFixed(1)} MiB가 512 MiB 한도를 넘습니다.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function assertValid(value, validator, label = "데이터") {
|
||||
const issues = validator(value);
|
||||
function validateResultRelationships(value, context = {}) {
|
||||
/** @type {ValidationIssue[]} */
|
||||
const issues = [];
|
||||
if (!isRecord(value)) return issues;
|
||||
if (context.experiment) {
|
||||
if (value.experimentId !== context.experiment.id) addIssue(issues, "$.experimentId", "불러온 실험 id와 다릅니다.");
|
||||
if (value.arrayId !== context.experiment.arrayId) addIssue(issues, "$.arrayId", "실험의 배열 id와 다릅니다.");
|
||||
if (value.sceneId !== context.experiment.sceneId) addIssue(issues, "$.sceneId", "실험의 장면 id와 다릅니다.");
|
||||
}
|
||||
const sourceIds = new Set(context.scene?.sources?.map((source) => source.id) ?? []);
|
||||
const expectedBandIds = new Set(context.experiment?.frequencyBands?.map((band) => band.id) ?? []);
|
||||
const bandIds = [];
|
||||
if (Array.isArray(value.bands)) {
|
||||
value.bands.forEach((band, bandIndex) => {
|
||||
if (!isRecord(band)) return;
|
||||
const path = `$.bands[${bandIndex}]`;
|
||||
if (typeof band.id === "string") {
|
||||
bandIds.push(band.id);
|
||||
if (expectedBandIds.size > 0 && !expectedBandIds.has(band.id)) addIssue(issues, `${path}.id`, "실험에 없는 band id입니다.");
|
||||
}
|
||||
const maps = [band.rawMap, band.displayMap, ...(band.processedMaps?.map((item) => item.map) ?? [])].filter(Boolean);
|
||||
maps.forEach((map, mapIndex) => {
|
||||
if (!isRecord(map) || !Array.isArray(map.values)) return;
|
||||
if (Number.isInteger(map.rows) && Number.isInteger(map.columns) && map.values.length !== map.rows * map.columns) {
|
||||
addIssue(issues, `${path}.maps[${mapIndex}].values`, "values 길이는 rows × columns와 같아야 합니다.");
|
||||
}
|
||||
if (context.experiment && (map.rows !== context.experiment.scan.rows || map.columns !== context.experiment.scan.columns)) {
|
||||
addIssue(issues, `${path}.maps[${mapIndex}]`, "map shape이 실험 scan grid와 다릅니다.");
|
||||
}
|
||||
});
|
||||
if (isRecord(band.rawMap) && band.rawMap.scale !== "power") {
|
||||
addIssue(issues, `${path}.rawMap.scale`, "rawMap은 power scale이어야 합니다.");
|
||||
}
|
||||
if (Array.isArray(band.peaks)) {
|
||||
band.peaks.forEach((peak, peakIndex) => {
|
||||
if (peak?.sourceId && sourceIds.size > 0 && !sourceIds.has(peak.sourceId)) {
|
||||
addIssue(issues, `${path}.peaks[${peakIndex}].sourceId`, "장면에 없는 source id입니다.");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const duplicate of findDuplicates(bandIds)) addIssue(issues, "$.bands", `중복 result band id: ${duplicate}`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function combineValidation(name, value, relationshipIssues) {
|
||||
return [...(await validateDocumentStructure(name, value)), ...relationshipIssues];
|
||||
}
|
||||
|
||||
export async function validateArrayDefinition(value) {
|
||||
return combineValidation("array", value, validateArrayRelationships(value));
|
||||
}
|
||||
|
||||
export async function validateScene(value) {
|
||||
return combineValidation("scene", value, validateSceneRelationships(value));
|
||||
}
|
||||
|
||||
export async function validateExperiment(value, context = {}) {
|
||||
return combineValidation("experiment", value, validateExperimentRelationships(value, context));
|
||||
}
|
||||
|
||||
export async function validateResult(value, context = {}) {
|
||||
return combineValidation("result", value, validateResultRelationships(value, context));
|
||||
}
|
||||
|
||||
export async function assertValid(value, validator, label = "데이터") {
|
||||
const issues = await validator(value);
|
||||
if (issues.length > 0) {
|
||||
const details = issues.map(({ path, message }) => `${path}: ${message}`).join("\n");
|
||||
throw new Error(`${label} 검증 실패\n${details}`);
|
||||
|
||||
Reference in New Issue
Block a user