2026-03-24 13:42:44 +00:00

198 lines
8.7 KiB
Java

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<PackerDiagnostic> 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<String, List<String>> 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<PackerDiagnostic> 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<PackerDiagnostic> 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<PackerDiagnostic> 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<PackerDiagnostic> 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<PackerDiagnostic> 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<String, List<String>> requiredInputs(JsonNode inputsNode, List<PackerDiagnostic> diagnostics, Path manifestPath) {
if (!inputsNode.isObject()) {
diagnostics.add(missingOrInvalid("inputs", "object of input roles", manifestPath));
return Map.of();
}
final Map<String, List<String>> 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<String> 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);
}
}