94 lines
3.7 KiB
TypeScript
94 lines
3.7 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
|
|
import {
|
|
createPublicAssetResolver,
|
|
readConfiguredAssetRoot,
|
|
validateReadOnlyAssetRoot,
|
|
type PublicAssetEntry,
|
|
type PublicAssetResolver,
|
|
} from "./local-data-root.js";
|
|
|
|
const rootRef = "p0a_runtime_assets";
|
|
const schemaVersion = "DadaRuntimeAssets/v1";
|
|
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
|
|
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
|
|
const shaPattern = /^[a-f0-9]{64}$/i;
|
|
|
|
export interface RuntimeAssetState {
|
|
checked_at: string;
|
|
configured: boolean;
|
|
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
|
|
status: "active" | "unavailable";
|
|
}
|
|
|
|
export interface LoadedRuntimeAssets {
|
|
publicAssets?: PublicAssetResolver;
|
|
state: RuntimeAssetState;
|
|
}
|
|
|
|
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
|
|
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
|
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
|
|
throw new Error("runtime_asset_manifest_invalid");
|
|
}
|
|
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
|
|
return value.entries.map((candidate) => {
|
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
|
|
const entry = candidate as Record<string, unknown>;
|
|
if (
|
|
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|
|
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|
|
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|
|
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|
|
|| entry.rootRef !== rootRef
|
|
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
|
|
) throw new Error("runtime_asset_manifest_invalid");
|
|
return entry as unknown as PublicAssetEntry;
|
|
});
|
|
}
|
|
|
|
function unavailable(
|
|
configured: boolean,
|
|
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
|
|
checkedAt: string,
|
|
): LoadedRuntimeAssets {
|
|
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
|
|
}
|
|
|
|
export function loadConfiguredRuntimeAssets(input: {
|
|
configFile: string;
|
|
dataRoot: string;
|
|
trustedManifestPath: string;
|
|
clock?: () => number;
|
|
}): LoadedRuntimeAssets {
|
|
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
|
|
let assetRoot: string;
|
|
try {
|
|
assetRoot = readConfiguredAssetRoot(input.configFile);
|
|
} catch {
|
|
return unavailable(false, "asset_root_state_missing", checkedAt);
|
|
}
|
|
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
|
|
try {
|
|
const trustedBytes = readFileSync(input.trustedManifestPath);
|
|
const entries = parseRuntimeManifest(trustedBytes);
|
|
const validatedRoot = validateReadOnlyAssetRoot({
|
|
dataRoot: input.dataRoot,
|
|
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
|
|
manifestRelativePath: "manifest.json",
|
|
root: assetRoot,
|
|
rootRef,
|
|
});
|
|
if (!validatedRoot.ok) {
|
|
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
|
|
}
|
|
return {
|
|
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
|
|
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
|
|
};
|
|
} catch {
|
|
return unavailable(true, "asset_manifest_invalid", checkedAt);
|
|
}
|
|
}
|