diff --git a/content-tools/build.gradle.kts b/content-tools/build.gradle.kts index 89ae5d3..1052f7a 100644 --- a/content-tools/build.gradle.kts +++ b/content-tools/build.gradle.kts @@ -42,6 +42,14 @@ tasks.register("validateFanContent") { args("validate", fanContentRoot.asFile.absolutePath) } -tasks.named("check") { - dependsOn("validateFanContent") +tasks.register("validateReadyFanAssets") { + group = "verification" + description = "Refuses READY catalog records whose Blockbench export artifacts are absent or malformed." + classpath = sourceSets.main.get().runtimeClasspath + mainClass = application.mainClass + args("ready-assets", fanContentRoot.asFile.absolutePath, rootProject.layout.projectDirectory.asFile.absolutePath) +} + +tasks.named("check") { + dependsOn("validateFanContent", "validateReadyFanAssets") } diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java index 6b06d1b..205f305 100644 --- a/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ContentTool.java @@ -15,7 +15,7 @@ public final class ContentTool { } static int run(String[] args, PrintStream out, PrintStream err) { - if (args.length != 2) { + if ((args.length != 2) && (args.length != 3 || !"ready-assets".equals(args[0]))) { printUsage(err); return 64; } @@ -24,6 +24,7 @@ public final class ContentTool { return switch (args[0]) { case "validate" -> validate(contentRoot, out, err); case "digest" -> digest(contentRoot, out, err); + case "ready-assets" -> validateReadyAssets(contentRoot, Path.of(args[2]), out, err); default -> { err.println("Unknown command: " + args[0]); printUsage(err); @@ -59,9 +60,21 @@ public final class ContentTool { } } + private static int validateReadyAssets(Path contentRoot, Path projectRoot, PrintStream out, PrintStream err) { + ValidationReport report = new ReadyAssetValidator().validate(contentRoot, projectRoot); + if (!report.isValid()) { + err.println("Ready asset validation failed with " + report.issues().size() + " issue(s):"); + report.issues().forEach(issue -> err.println("- " + issue)); + return 2; + } + out.println("Ready asset validation passed: " + contentRoot); + return 0; + } + private static void printUsage(PrintStream stream) { stream.println("Usage:"); stream.println(" content-tools validate "); stream.println(" content-tools digest "); + stream.println(" content-tools ready-assets "); } } diff --git a/content-tools/src/main/java/dev/projectcapture/contenttools/ReadyAssetValidator.java b/content-tools/src/main/java/dev/projectcapture/contenttools/ReadyAssetValidator.java new file mode 100644 index 0000000..3813005 --- /dev/null +++ b/content-tools/src/main/java/dev/projectcapture/contenttools/ReadyAssetValidator.java @@ -0,0 +1,108 @@ +package dev.projectcapture.contenttools; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +/** + * Verifies the on-disk deliverables for content which claims to be ready for + * shipping. Planned records deliberately remain data-only while artists work. + */ +public final class ReadyAssetValidator { + private static final byte[] PNG_SIGNATURE = new byte[] { + (byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a + }; + + public ValidationReport validate(Path contentRoot, Path projectRoot) { + ValidationReport report = new ValidationReport(); + Path normalizedProjectRoot = projectRoot.toAbsolutePath().normalize(); + try { + ContentBundle bundle = ContentBundle.load(contentRoot); + JsonNode assets = bundle.document("assets.json").path("items"); + for (int index = 0; index < assets.size(); index++) { + JsonNode asset = assets.path(index); + if (!"READY".equals(asset.path("status").asText())) { + continue; + } + String location = "assets.json.items[" + index + "]"; + validateJsonArtifact(asset.path("source").asText(), normalizedProjectRoot, location + ".source", report); + validateJsonArtifact(asset.path("model").asText(), normalizedProjectRoot, location + ".model", report); + validatePng(asset.path("texture").asText(), normalizedProjectRoot, location + ".texture", report); + validateJsonArtifact(asset.path("recipe").asText(), normalizedProjectRoot, location + ".recipe", report); + JsonNode animations = asset.path("animations"); + for (String name : List.of("idle", "walk", "attack")) { + validateJsonArtifact(animations.path(name).asText(), normalizedProjectRoot, + location + ".animations." + name, report); + } + } + } catch (IOException | RuntimeException exception) { + report.add("READY_ASSET_CATALOG", "assets.json", exception.getMessage()); + } + return report; + } + + private void validateJsonArtifact(String declaredPath, Path projectRoot, String location, ValidationReport report) { + Path artifact = resolve(declaredPath, projectRoot, location, report); + if (artifact == null || !requireNonEmptyFile(artifact, location, report)) { + return; + } + try { + if (ContentBundle.json().readTree(artifact.toFile()) == null) { + report.add("READY_ASSET_JSON", location, "Artifact is empty JSON: " + declaredPath); + } + } catch (IOException exception) { + report.add("READY_ASSET_JSON", location, "Artifact is not valid JSON: " + declaredPath); + } + } + + private void validatePng(String declaredPath, Path projectRoot, String location, ValidationReport report) { + Path artifact = resolve(declaredPath, projectRoot, location, report); + if (artifact == null || !requireNonEmptyFile(artifact, location, report)) { + return; + } + try { + byte[] bytes = Files.readAllBytes(artifact); + if (bytes.length < PNG_SIGNATURE.length) { + report.add("READY_ASSET_PNG", location, "Texture is too small to be a PNG: " + declaredPath); + return; + } + for (int index = 0; index < PNG_SIGNATURE.length; index++) { + if (bytes[index] != PNG_SIGNATURE[index]) { + report.add("READY_ASSET_PNG", location, "Texture has no PNG signature: " + declaredPath); + return; + } + } + } catch (IOException exception) { + report.add("READY_ASSET_PNG", location, "Could not inspect texture: " + declaredPath); + } + } + + private Path resolve(String declaredPath, Path projectRoot, String location, ValidationReport report) { + if (declaredPath.isBlank()) { + report.add("READY_ASSET_PATH", location, "Ready asset is missing a declared path"); + return null; + } + Path artifact = projectRoot.resolve(declaredPath).normalize(); + if (!artifact.startsWith(projectRoot)) { + report.add("READY_ASSET_PATH", location, "Artifact path escapes project root: " + declaredPath); + return null; + } + return artifact; + } + + private boolean requireNonEmptyFile(Path artifact, String location, ValidationReport report) { + try { + if (!Files.isRegularFile(artifact) || Files.size(artifact) == 0) { + report.add("READY_ASSET_MISSING", location, "Required generated artifact is missing: " + artifact); + return false; + } + return true; + } catch (IOException exception) { + report.add("READY_ASSET_MISSING", location, "Could not inspect generated artifact: " + artifact); + return false; + } + } +} diff --git a/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java index c2d0a4a..f80cc33 100644 --- a/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java +++ b/content-tools/src/test/java/dev/projectcapture/contenttools/ContentValidatorTest.java @@ -49,6 +49,17 @@ class ContentValidatorTest { assertEquals(ContentValidator.UNOFFICIAL_SLICE_SPECIES, ids); } + @Test + void readyAssetRecordsRequireTheirGeneratedFiles() throws IOException { + ObjectNode assets = object("assets.json"); + ((ObjectNode) assets.path("items").path(0)).put("status", "READY"); + write("assets.json", assets); + + ValidationReport report = new ReadyAssetValidator().validate(contentRoot, temporaryDirectory); + + assertTrue(report.containsCode("READY_ASSET_MISSING")); + } + @Test void allTwelveSpeciesUseEngineElementRarityAndCaptureSchema() throws IOException { JsonNode species = ContentBundle.load(contentRoot).document("species.json").path("items"); diff --git a/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch b/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch index b7eae83..87fa37d 100644 --- a/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch +++ b/devtools/blockbench-mcp/patches/0001-projectcapture-hardening.patch @@ -395,7 +395,7 @@ new file mode 100644 index 0000000..eda0255 --- /dev/null +++ b/lib/projectcaptureSecurity.ts -@@ -0,0 +1,202 @@ +@@ -0,0 +1,223 @@ +import path from "node:path"; + +export const PROJECTCAPTURE_LOOPBACK_HOST = "127.0.0.1" as const; @@ -411,15 +411,36 @@ index 0000000..eda0255 + +export interface AssetSandboxFs { + existsSync(path: string): boolean; -+ lstatSync(path: string): { ++ lstatSync?(path: string): { + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; + }; ++ statSync?(path: string): { ++ isDirectory(): boolean; ++ isFile(): boolean; ++ }; + realpathSync(path: string): string; + mkdirSync(path: string, options?: { mode?: number }): unknown; +} + ++type AssetStat = { ++ isDirectory(): boolean; ++ isFile(): boolean; ++ isSymbolicLink?(): boolean; ++}; ++ ++function inspectPath(pathname: string, fs: AssetSandboxFs): AssetStat { ++ if (typeof fs.lstatSync === "function") return fs.lstatSync(pathname); ++ if (typeof fs.statSync === "function") return fs.statSync(pathname); ++ throw new Error("Blockbench filesystem bridge does not expose a safe stat operation."); ++} ++ ++function isSymbolicLink(pathname: string, stat: AssetStat, fs: AssetSandboxFs): boolean { ++ if (typeof stat.isSymbolicLink === "function") return stat.isSymbolicLink(); ++ return fs.realpathSync(pathname) !== path.resolve(pathname); ++} ++ +function requireSecretToken(value: string | undefined): string { + const token = value?.trim(); + if (!token || token.length < PROJECTCAPTURE_MIN_TOKEN_LENGTH) { @@ -520,8 +541,8 @@ index 0000000..eda0255 + if (!fs.existsSync(root)) { + throw new Error("Configured ProjectCapture asset root does not exist."); + } -+ const rootStat = fs.lstatSync(root); -+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { ++ const rootStat = inspectPath(root, fs); ++ if (isSymbolicLink(root, rootStat, fs) || !rootStat.isDirectory()) { + throw new Error("Configured ProjectCapture asset root must be a real directory, not a symlink."); + } + return fs.realpathSync(root); @@ -541,8 +562,8 @@ index 0000000..eda0255 + cursor = path.join(cursor, segment); + assertInsideRoot(root, cursor); + if (fs.existsSync(cursor)) { -+ const stat = fs.lstatSync(cursor); -+ if (stat.isSymbolicLink() || !stat.isDirectory()) { ++ const stat = inspectPath(cursor, fs); ++ if (isSymbolicLink(cursor, stat, fs) || !stat.isDirectory()) { + throw new Error("Asset path contains a symlink or non-directory parent."); + } + } else { @@ -568,8 +589,8 @@ index 0000000..eda0255 + const candidate = path.resolve(root, relativePath); + assertInsideRoot(root, candidate); + if (fs.existsSync(candidate)) { -+ const stat = fs.lstatSync(candidate); -+ if (stat.isSymbolicLink() || !stat.isFile()) { ++ const stat = inspectPath(candidate, fs); ++ if (isSymbolicLink(candidate, stat, fs) || !stat.isFile()) { + throw new Error("Asset target must be a regular file and must not be a symlink."); + } + } @@ -590,8 +611,8 @@ index 0000000..eda0255 + if (!fs.existsSync(lexicalCandidate)) { + throw new Error("Requested asset does not exist."); + } -+ const stat = fs.lstatSync(lexicalCandidate); -+ if (stat.isSymbolicLink() || !stat.isFile()) { ++ const stat = inspectPath(lexicalCandidate, fs); ++ if (isSymbolicLink(lexicalCandidate, stat, fs) || !stat.isFile()) { + throw new Error("Requested asset must be a regular file and must not be a symlink."); + } + const realCandidate = fs.realpathSync(lexicalCandidate); diff --git a/devtools/blockbench-mcp/scripts/lib.sh b/devtools/blockbench-mcp/scripts/lib.sh index c02fb67..56d0bae 100755 --- a/devtools/blockbench-mcp/scripts/lib.sh +++ b/devtools/blockbench-mcp/scripts/lib.sh @@ -5,7 +5,7 @@ TOOL_ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" MCP_REPOSITORY="https://github.com/jasonjgardner/blockbench-mcp-plugin.git" MCP_COMMIT="6b069e308fdfc9b0a1c15bc924ca78150815f143" MCP_PATCH="${TOOL_ROOT}/patches/0001-projectcapture-hardening.patch" -MCP_PATCH_SHA256="9de0b3767e96db7a68ef4c7082d7c76bf140b4932fc9550ad6e99d9a24a048c3" +MCP_PATCH_SHA256="092d5446fcdd1f28d6abcc931be2895727fd9ab3dd56728874b6847a614ed8fa" BUN_VERSION="1.3.14" require_command() {