88 lines
5.2 KiB
JavaScript
88 lines
5.2 KiB
JavaScript
"use strict";
|
|
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { unzipSync } = require("fflate");
|
|
|
|
const CONTRACT = "native-road-package/v1.1";
|
|
const ROOT_FILES = ["manifest.json", "compiled.json", "diagnostics.json", "comparison.json", "traffic-signal-assemblies.json", "traffic-signals.json"];
|
|
|
|
function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); }
|
|
|
|
function readCentralEntries(bytes) {
|
|
const entries = [];
|
|
for (let i = 0; i + 46 <= bytes.length; i += 1) {
|
|
if (bytes.readUInt32LE(i) !== 0x02014b50) continue;
|
|
const nameLength = bytes.readUInt16LE(i + 28);
|
|
const extraLength = bytes.readUInt16LE(i + 30);
|
|
const commentLength = bytes.readUInt16LE(i + 32);
|
|
const name = bytes.subarray(i + 46, i + 46 + nameLength).toString("utf8");
|
|
const externalAttrs = bytes.readUInt32LE(i + 38);
|
|
entries.push({ name, externalAttrs, directory: name.endsWith("/") });
|
|
i += 45 + nameLength + extraLength + commentLength;
|
|
}
|
|
if (!entries.length) throw new Error("ZIP central directory is missing");
|
|
return entries;
|
|
}
|
|
|
|
function validateName(name) {
|
|
if (!name || path.posix.isAbsolute(name) || name.split("/").includes("..") || name.includes("\\")) throw new Error(`Unsafe ZIP entry path: ${name}`);
|
|
if (name.startsWith("./") || (name.includes("/") && !name.startsWith("layers/")) || (name.startsWith("layers/") && name.split("/").length !== 2)) throw new Error(`Native road ZIP must be root-flat: ${name}`);
|
|
}
|
|
|
|
function validateManifest(rootDir, areaId) {
|
|
let manifest;
|
|
try { manifest = JSON.parse(fs.readFileSync(path.join(rootDir, "manifest.json"), "utf8")); } catch (error) { throw new Error(`Invalid native road manifest: ${error.message}`); }
|
|
if (!manifest || manifest.contract !== CONTRACT) throw new Error(`Unsupported native road contract: ${manifest?.contract || "missing"}`);
|
|
if (manifest.areaId !== areaId) throw new Error(`Native road areaId mismatch: expected ${areaId}, got ${manifest.areaId}`);
|
|
if (!Array.isArray(manifest.layers) || !manifest.layers.length) throw new Error("Native road manifest layers are missing");
|
|
const declared = new Set();
|
|
for (const layer of manifest.layers) {
|
|
if (!layer || typeof layer.source !== "string" || layer.source !== path.posix.basename(layer.source) || declared.has(layer.source)) throw new Error("Invalid native road layer declaration");
|
|
if (!["surface", "marking", "semantic"].includes(layer.role)) throw new Error(`Invalid native road layer role: ${layer.role}`);
|
|
if (layer.role !== "semantic" && typeof layer.materialLayer !== "string") throw new Error(`Renderable layer ${layer.source} has no materialLayer`);
|
|
declared.add(layer.source);
|
|
}
|
|
const published = new Set(fs.readdirSync(path.join(rootDir, "layers")).filter((name) => name.endsWith(".geojson")).map((name) => name.slice(0, -8)));
|
|
if (declared.size !== published.size || [...declared].some((name) => !published.has(name))) throw new Error("Native road manifest/source mismatch");
|
|
return manifest;
|
|
}
|
|
|
|
function importNativeRoadPackage(area) {
|
|
const zipFile = area.nativeRoadPackage;
|
|
if (!zipFile || !fs.existsSync(zipFile) || !fs.statSync(zipFile).isFile()) throw new Error(`Native road ZIP not found: ${zipFile || "missing nativeRoadPackage"}`);
|
|
const bytes = fs.readFileSync(zipFile);
|
|
const hash = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
const entries = readCentralEntries(bytes);
|
|
const names = new Set();
|
|
for (const entry of entries) {
|
|
validateName(entry.name);
|
|
if (names.has(entry.name)) throw new Error(`Duplicate ZIP entry: ${entry.name}`);
|
|
names.add(entry.name);
|
|
const unixMode = entry.externalAttrs >>> 16;
|
|
if ((unixMode & 0xf000) === 0xa000) throw new Error(`Symbolic links are not allowed: ${entry.name}`);
|
|
}
|
|
let files;
|
|
try { files = unzipSync(bytes); } catch (error) { throw new Error(`Invalid ZIP archive: ${error.message}`); }
|
|
const expected = new Set([...ROOT_FILES, ...[...names].filter((name) => name.startsWith("layers/") && name.endsWith(".geojson"))]);
|
|
for (const name of ROOT_FILES) if (!names.has(name)) throw new Error(`Missing native road ZIP entry: ${name}`);
|
|
for (const name of names) if (!expected.has(name) || (!name.startsWith("layers/") && !ROOT_FILES.includes(name))) throw new Error(`Unexpected native road ZIP entry: ${name}`);
|
|
const rootDir = path.join(area.outputs.pipelineDir, "native-road-import", hash);
|
|
if (!fs.existsSync(rootDir)) {
|
|
fs.mkdirSync(path.join(rootDir, "layers"), { recursive: true });
|
|
for (const [name, data] of Object.entries(files)) {
|
|
if (!names.has(name) || name.endsWith("/")) continue;
|
|
const destination = path.join(rootDir, name);
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
fs.writeFileSync(destination, data);
|
|
}
|
|
}
|
|
const manifest = validateManifest(rootDir, area.id);
|
|
const records = {};
|
|
for (const name of names) { const file = path.join(rootDir, name); if (fs.existsSync(file) && fs.statSync(file).isFile()) records[name] = { path: file, bytes: fs.statSync(file).size, sha256: sha256(file) }; }
|
|
return Object.freeze({ zipFile, zipRecord: { path: zipFile, bytes: bytes.length, sha256: hash }, rootDir, manifest, records });
|
|
}
|
|
|
|
module.exports = { CONTRACT, importNativeRoadPackage };
|