import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join, resolve, sep } from "node:path"; export const ZONE_SOURCE_GENERATION_SCHEMA_VERSION = "nodedc.zone-source-generation/v2"; export const ZONE_SOURCE_MANIFEST_SCHEMA_VERSION = "nodedc.zone-source-manifest/v1"; const PROFILE_ID = /^[a-z][a-z0-9._-]{2,95}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const SHA256 = /^[a-f0-9]{64}$/; const TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/; const MAX_SOURCE_BYTES = 16 * 1024 * 1024; const MAX_ZONES = 5000; const MAX_SCHEDULE_ROWS = 50_000; const MAX_POSITIONS = 250_000; const PROFILE_POLICIES = Object.freeze({ "moscow-pmd-slow-zones": Object.freeze({ authorityId: "moscow-department-of-transport", datasetId: "pmd-slow-zones", adapterIds: Object.freeze(["depttrans-mmap-snapshot-v1", "depttrans-api-snapshot-v1"]), timezone: "Europe/Moscow", }), }); export async function loadZoneSourceProfile(root, profileId) { if (!PROFILE_ID.test(String(profileId || ""))) throw snapshotError("zone_source_profile_id_invalid"); const profileRoot = withinRoot(root, profileId); const manifest = parseJson( await readBounded(withinRoot(profileRoot, "manifest.json"), 256 * 1024), "zone_source_manifest_json_invalid", ); validateManifest(manifest, profileId); const geometryPath = withinRoot(profileRoot, manifest.geometry.file); const schedulePath = withinRoot(profileRoot, manifest.schedule.file); const [geometryBytes, scheduleBytes] = await Promise.all([ readBounded(geometryPath, MAX_SOURCE_BYTES), readBounded(schedulePath, MAX_SOURCE_BYTES), ]); if (geometryBytes.byteLength + scheduleBytes.byteLength > MAX_SOURCE_BYTES) { throw snapshotError("zone_source_snapshot_bytes_exceeded"); } const geometryDigest = sha256(geometryBytes); const scheduleDigest = sha256(scheduleBytes); if (geometryDigest !== manifest.geometry.sha256) throw snapshotError("zone_source_geometry_digest_mismatch"); if (scheduleDigest !== manifest.schedule.sha256) throw snapshotError("zone_source_schedule_digest_mismatch"); const geojson = parseJson(geometryBytes, "zone_source_geojson_invalid"); const rawSchedule = parseJson(scheduleBytes, "zone_source_schedule_json_invalid"); const generation = normalizeGeneration(manifest, geojson, rawSchedule, { geometryDigest, scheduleDigest, sourceBytes: geometryBytes.byteLength + scheduleBytes.byteLength, }); const body = Buffer.from(`${JSON.stringify(generation)}\n`); if (body.byteLength > MAX_SOURCE_BYTES) throw snapshotError("zone_source_generation_bytes_exceeded"); return Object.freeze({ generation: deepFreeze(generation), body, etag: `"sha256:${generation.contentDigest}"` }); } function normalizeGeneration(manifest, geojson, rawSchedule, digests) { if (!geojson || geojson.type !== "FeatureCollection" || !Array.isArray(geojson.features)) { throw snapshotError("zone_source_feature_collection_required"); } if (!geojson.features.length || geojson.features.length > MAX_ZONES) { throw snapshotError("zone_source_zone_limit_exceeded"); } if (!Array.isArray(rawSchedule) || !rawSchedule.length || rawSchedule.length > MAX_SCHEDULE_ROWS) { throw snapshotError("zone_source_schedule_limit_exceeded"); } const features = new Map(); let positionCount = 0; for (const [index, feature] of geojson.features.entries()) { if (!feature || feature.type !== "Feature" || !isPlainObject(feature.properties)) { throw snapshotError(`zone_source_feature_${index}_invalid`); } const nativeId = nativeZoneId(feature.properties.pmd_slow_zone_id); if (features.has(nativeId)) throw snapshotError("zone_source_identity_duplicate"); positionCount += validateGeometry(feature.geometry, index); if (positionCount > MAX_POSITIONS) throw snapshotError("zone_source_position_limit_exceeded"); features.set(nativeId, feature); } const scheduleByZone = new Map(); const scheduleIds = new Set(); const scheduleKeys = new Set(); for (const [index, row] of rawSchedule.entries()) { if (!isPlainObject(row)) throw snapshotError(`zone_source_schedule_${index}_invalid`); const rowId = nativeScheduleId(row.id); if (scheduleIds.has(rowId)) throw snapshotError("zone_source_schedule_identity_duplicate"); scheduleIds.add(rowId); const zoneId = nativeZoneId(row.pmd_slow_zone_id); if (!features.has(zoneId)) throw snapshotError("zone_source_schedule_zone_missing"); const dayOfWeek = integer(row.day_of_week_num, 1, 7, "zone_source_schedule_day_invalid"); const speedLimitKph = finite(row.speed_limit, 0, 200, "zone_source_schedule_speed_invalid"); const startTime = clock(row.start_time, "zone_source_schedule_start_invalid"); const endTime = clock(row.end_time, "zone_source_schedule_end_invalid"); const key = `${zoneId}\u0000${dayOfWeek}\u0000${startTime}\u0000${endTime}`; if (scheduleKeys.has(key)) throw snapshotError("zone_source_schedule_interval_duplicate"); scheduleKeys.add(key); const normalized = Object.freeze({ dayOfWeek, startTime, endTime, speedLimitKph }); const entries = scheduleByZone.get(zoneId) || []; entries.push(normalized); scheduleByZone.set(zoneId, entries); } const zones = []; let excludedZoneCount = 0; for (const [nativeId, feature] of features) { const properties = feature.properties; if (properties.archive_flg === true || properties.delete_flg === true) { excludedZoneCount += 1; continue; } const schedule = [...(scheduleByZone.get(nativeId) || [])].sort(compareSchedule); if (!schedule.length || new Set(schedule.map((entry) => entry.dayOfWeek)).size !== 7) { throw snapshotError("zone_source_schedule_week_incomplete"); } const speeds = [...new Set(schedule.map((entry) => entry.speedLimitKph))]; zones.push(compact({ nativeId, displayName: requiredString(properties.pmd_slow_zone_nm, "zone_source_name_required"), geometry: structuredClone(feature.geometry), areaSquareMeters: optionalFinite(properties.area, 0, Number.MAX_SAFE_INTEGER), description: optionalString(properties.comment), appliesToKicksharing: boolean(properties.kick_sharing, "zone_source_kicksharing_flag_invalid"), appliesToCouriers: boolean(properties.courier, "zone_source_courier_flag_invalid"), sourceCreatedAt: optionalString(properties.create_dttm), sourceUpdatedAt: optionalString(properties.update_dttm), maxSpeedKph: speeds.length === 1 ? speeds[0] : undefined, weeklySpeedSchedule: schedule, })); } zones.sort((left, right) => compareNativeIds(left.nativeId, right.nativeId)); const contentDigest = sha256(Buffer.from(`${digests.geometryDigest}\n${digests.scheduleDigest}\n`)); return { schemaVersion: ZONE_SOURCE_GENERATION_SCHEMA_VERSION, profileId: manifest.profileId, authorityId: manifest.authorityId, datasetId: manifest.datasetId, adapterId: manifest.adapterId, sourceRevision: manifest.sourceRevision, generatedAt: iso(manifest.generatedAt, "zone_source_generated_at_invalid"), timezone: manifest.timezone, complete: true, contentDigest, zones, metadata: { sourceZoneCount: features.size, publishedZoneCount: zones.length, excludedZoneCount, scheduleRowCount: rawSchedule.length, positionCount, sourceBytes: digests.sourceBytes, geometrySha256: digests.geometryDigest, scheduleSha256: digests.scheduleDigest, }, }; } function validateManifest(value, requestedProfileId) { if (!isPlainObject(value) || value.schemaVersion !== ZONE_SOURCE_MANIFEST_SCHEMA_VERSION) { throw snapshotError("zone_source_manifest_schema_invalid"); } if (value.profileId !== requestedProfileId || !PROFILE_ID.test(value.profileId)) { throw snapshotError("zone_source_manifest_profile_mismatch"); } const policy = PROFILE_POLICIES[requestedProfileId]; if (!policy) throw snapshotError("zone_source_profile_unsupported"); for (const key of ["authorityId", "datasetId", "adapterId"]) { if (!IDENTIFIER.test(String(value[key] || ""))) throw snapshotError(`zone_source_manifest_${key}_invalid`); } if (value.authorityId !== policy.authorityId) throw snapshotError("zone_source_manifest_authority_mismatch"); if (value.datasetId !== policy.datasetId) throw snapshotError("zone_source_manifest_dataset_mismatch"); if (!policy.adapterIds.includes(value.adapterId)) throw snapshotError("zone_source_manifest_adapter_unsupported"); requiredString(value.sourceRevision, "zone_source_manifest_revision_invalid"); iso(value.generatedAt, "zone_source_manifest_generated_at_invalid"); if (value.timezone !== policy.timezone) throw snapshotError("zone_source_manifest_timezone_invalid"); for (const [name, artifact] of [["geometry", value.geometry], ["schedule", value.schedule]]) { if (!isPlainObject(artifact) || !safeFileName(artifact.file) || !SHA256.test(String(artifact.sha256 || ""))) { throw snapshotError(`zone_source_manifest_${name}_invalid`); } } } function validateGeometry(value, index) { if (!isPlainObject(value) || !new Set(["Polygon", "MultiPolygon"]).has(value.type)) { throw snapshotError(`zone_source_feature_${index}_geometry_invalid`); } const polygons = value.type === "Polygon" ? [value.coordinates] : value.coordinates; if (!Array.isArray(polygons) || !polygons.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`); let positions = 0; for (const polygon of polygons) { if (!Array.isArray(polygon) || !polygon.length) throw snapshotError(`zone_source_feature_${index}_geometry_invalid`); for (const ring of polygon) { if (!Array.isArray(ring) || ring.length < 4) throw snapshotError(`zone_source_feature_${index}_ring_invalid`); for (const point of ring) { if (!Array.isArray(point) || point.length < 2) throw snapshotError(`zone_source_feature_${index}_position_invalid`); const longitude = Number(point[0]); const latitude = Number(point[1]); if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) { throw snapshotError(`zone_source_feature_${index}_position_invalid`); } positions += 1; } const first = ring[0]; const last = ring.at(-1); if (first[0] !== last[0] || first[1] !== last[1]) throw snapshotError(`zone_source_feature_${index}_ring_unclosed`); } } return positions; } function compareSchedule(left, right) { return left.dayOfWeek - right.dayOfWeek || left.startTime.localeCompare(right.startTime) || left.endTime.localeCompare(right.endTime) || left.speedLimitKph - right.speedLimitKph; } function compareNativeIds(left, right) { const a = Number(left); const b = Number(right); return Number.isSafeInteger(a) && Number.isSafeInteger(b) ? a - b : left.localeCompare(right); } function nativeZoneId(value) { const normalized = String(value ?? "").trim(); if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_native_identity_invalid"); return normalized; } function nativeScheduleId(value) { const normalized = String(value ?? "").trim(); if (!/^[1-9]\d{0,18}$/.test(normalized)) throw snapshotError("zone_source_schedule_identity_invalid"); return normalized; } async function readBounded(path, maxBytes) { const value = await readFile(path); if (value.byteLength > maxBytes) throw snapshotError("zone_source_file_bytes_exceeded"); return value; } function withinRoot(root, child) { const base = resolve(root); const target = resolve(base, child); if (target !== base && !target.startsWith(`${base}${sep}`)) throw snapshotError("zone_source_path_escape_rejected"); return target; } function safeFileName(value) { return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value); } function parseJson(value, code) { try { return JSON.parse(value.toString("utf8")); } catch { throw snapshotError(code); } } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } function integer(value, min, max, code) { const number = Number(value); if (!Number.isInteger(number) || number < min || number > max) throw snapshotError(code); return number; } function finite(value, min, max, code) { const number = Number(value); if (!Number.isFinite(number) || number < min || number > max) throw snapshotError(code); return number; } function optionalFinite(value, min, max) { if (value === undefined || value === null || value === "") return undefined; const number = Number(value); return Number.isFinite(number) && number >= min && number <= max ? number : undefined; } function boolean(value, code) { if (typeof value !== "boolean") throw snapshotError(code); return value; } function clock(value, code) { const normalized = String(value ?? "").trim(); if (!TIME.test(normalized)) throw snapshotError(code); return normalized; } function iso(value, code) { const date = new Date(String(value ?? "")); if (Number.isNaN(date.getTime())) throw snapshotError(code); return date.toISOString(); } function requiredString(value, code) { const normalized = optionalString(value); if (!normalized) throw snapshotError(code); return normalized; } function optionalString(value) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function compact(value) { return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); } function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function deepFreeze(value) { if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; Object.freeze(value); Object.values(value).forEach(deepFreeze); return value; } function snapshotError(code) { return Object.assign(new Error(code), { code }); }