package p.packer.services; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import p.packer.assets.AssetFamilyCatalog; import p.packer.assets.OutputCodecCatalog; import p.packer.diagnostics.PackerDiagnostic; import p.packer.diagnostics.PackerDiagnosticCategory; import p.packer.diagnostics.PackerDiagnosticSeverity; import p.packer.models.PackerAssetDeclaration; import p.packer.models.PackerAssetDeclarationParseResult; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; public final class PackerAssetDeclarationParser { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final int SUPPORTED_SCHEMA_VERSION = 1; public PackerAssetDeclarationParseResult parse(Path assetManifestPath) { final Path manifestPath = Objects.requireNonNull(assetManifestPath, "assetManifestPath").toAbsolutePath().normalize(); final List diagnostics = new ArrayList<>(); final JsonNode root; try { root = MAPPER.readTree(manifestPath.toFile()); } catch (IOException exception) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Unable to parse asset.json: " + exception.getMessage(), manifestPath, true)); return new PackerAssetDeclarationParseResult(null, diagnostics); } final Integer schemaVersion = requiredInt(root, "schema_version", diagnostics, manifestPath); final String assetUuid = requiredText(root, "asset_uuid", diagnostics, manifestPath); final String name = requiredText(root, "name", diagnostics, manifestPath); final AssetFamilyCatalog assetFamily = requiredAssetFamily(root, diagnostics, manifestPath); final Map> inputsByRole = requiredInputs(root.path("inputs"), diagnostics, manifestPath); final String outputFormat = requiredText(root.path("output"), "format", diagnostics, manifestPath); final OutputCodecCatalog outputCodec = requiredOutputCodec(root.path("output"), diagnostics, manifestPath); final Boolean preloadEnabled = requiredBoolean(root.path("preload"), "enabled", diagnostics, manifestPath); if (schemaVersion != null && schemaVersion != SUPPORTED_SCHEMA_VERSION) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.VERSIONING, "Unsupported asset.json schema_version: " + schemaVersion, manifestPath, true)); } if (diagnostics.stream().anyMatch(PackerDiagnostic::blocking)) { return new PackerAssetDeclarationParseResult(null, diagnostics); } return new PackerAssetDeclarationParseResult( new PackerAssetDeclaration( schemaVersion, assetUuid, name, assetFamily, inputsByRole, outputFormat, outputCodec, preloadEnabled), diagnostics); } private Integer requiredInt(JsonNode node, String fieldName, List diagnostics, Path manifestPath) { final JsonNode field = node.path(fieldName); if (!field.isInt()) { diagnostics.add(missingOrInvalid(fieldName, "integer", manifestPath)); return null; } return field.intValue(); } private String requiredText(JsonNode node, String fieldName, List diagnostics, Path manifestPath) { final JsonNode field = node.path(fieldName); if (!field.isTextual() || field.asText().isBlank()) { diagnostics.add(missingOrInvalid(fieldName, "non-blank string", manifestPath)); return null; } return field.asText().trim(); } private AssetFamilyCatalog requiredAssetFamily(JsonNode node, List diagnostics, Path manifestPath) { final String manifestType = requiredText(node, "type", diagnostics, manifestPath); if (manifestType == null) { return null; } final AssetFamilyCatalog assetFamily = AssetFamilyCatalog.fromManifestType(manifestType); if (assetFamily == AssetFamilyCatalog.UNKNOWN) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'type' must be one of: image_bank, palette_bank, sound_bank.", manifestPath, true)); return null; } return assetFamily; } private Boolean requiredBoolean(JsonNode node, String fieldName, List diagnostics, Path manifestPath) { final JsonNode field = node.path(fieldName); if (!field.isBoolean()) { diagnostics.add(missingOrInvalid(fieldName, "boolean", manifestPath)); return null; } return field.booleanValue(); } private OutputCodecCatalog requiredOutputCodec(JsonNode node, List diagnostics, Path manifestPath) { final String codecValue = requiredText(node, "codec", diagnostics, manifestPath); if (codecValue == null) { return null; } final OutputCodecCatalog outputCodec = OutputCodecCatalog.fromManifestValue(codecValue); if (outputCodec == OutputCodecCatalog.UNKNOWN) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'codec' must be one of: NONE.", manifestPath, true)); return null; } return outputCodec; } private Map> requiredInputs(JsonNode inputsNode, List diagnostics, Path manifestPath) { if (!inputsNode.isObject()) { diagnostics.add(missingOrInvalid("inputs", "object of input roles", manifestPath)); return Map.of(); } final Map> result = new LinkedHashMap<>(); inputsNode.fields().forEachRemaining(entry -> { if (!entry.getValue().isArray()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Input role '" + entry.getKey() + "' must be an array of relative paths.", manifestPath, true)); return; } final List values = new ArrayList<>(); entry.getValue().forEach(value -> { if (!value.isTextual() || value.asText().isBlank()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Input role '" + entry.getKey() + "' must contain only non-blank path strings.", manifestPath, true)); return; } final String relativePath = value.asText().trim(); if (!isTrustedRelativePath(relativePath)) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Input role '" + entry.getKey() + "' contains an untrusted path outside the asset root.", manifestPath, true)); return; } values.add(relativePath); }); result.put(entry.getKey(), List.copyOf(values)); }); return Map.copyOf(result); } private boolean isTrustedRelativePath(String value) { final Path path = Path.of(value).normalize(); return !path.isAbsolute() && !path.startsWith(".."); } private PackerDiagnostic missingOrInvalid(String fieldName, String expected, Path manifestPath) { return new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field '" + fieldName + "' must be a " + expected + ".", manifestPath, true); } }