package ar.com.companeros.tools; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.HexFormat; import java.util.List; import java.util.Locale; import java.util.Set; import java.util.TreeSet; import java.util.UUID; /** Una herramienta describe una composición acotada, nunca un programa libre. */ public final class ToolManifest { public static final int MAX_SOURCE_BYTES = 65_536; public static final int MAX_STEPS = 128; public static final int MAX_TICKS = 12_000; private final String id; private final int version; private final UUID author; private final String description; private final Set permissions; private final List dependencies; private final List steps; private final int maxTicks; private final int maxSteps; private final JsonObject source; private final String sha256; private ToolManifest(JsonObject source, UUID trustedAuthor) { rejectUnknown(source, Set.of("schema", "id", "version", "description", "permissions", "dependencies", "limits", "steps")); if (integer(source, "schema", 1, 1) != 1) throw invalid("Versión de esquema desconocida"); id = identifier(string(source, "id", 48)); version = integer(source, "version", 1, 1_000_000); author = java.util.Objects.requireNonNull(trustedAuthor, "El autor debe provenir del servidor"); description = string(source, "description", 512); JsonObject limits = object(source, "limits"); rejectUnknown(limits, Set.of("maxTicks", "maxSteps")); maxTicks = integer(limits, "maxTicks", 1, MAX_TICKS); maxSteps = integer(limits, "maxSteps", 1, MAX_STEPS); EnumSet requested = EnumSet.noneOf(ToolPermission.class); JsonArray permissionsArray = array(source, "permissions", ToolPermission.values().length); for (JsonElement value : permissionsArray) { if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw invalid("Permiso inválido"); try { if (!requested.add(ToolPermission.valueOf(value.getAsString().toUpperCase(Locale.ROOT)))) throw invalid("Permiso repetido"); } catch (IllegalArgumentException exception) { throw invalid("Permiso desconocido"); } } permissions = Collections.unmodifiableSet(requested); List parsedDependencies = new ArrayList<>(); if (source.has("dependencies")) { for (JsonElement value : array(source, "dependencies", 32)) { if (!value.isJsonObject()) throw invalid("Dependencia inválida"); JsonObject dependency = value.getAsJsonObject(); rejectUnknown(dependency, Set.of("id", "version", "sha256")); Dependency parsed = new Dependency(identifier(string(dependency, "id", 48)), integer(dependency, "version", 1, 1_000_000), hash(string(dependency, "sha256", 64))); if (parsedDependencies.stream().anyMatch(old -> old.id.equals(parsed.id) && old.version == parsed.version)) throw invalid("Dependencia repetida"); parsedDependencies.add(parsed); } } dependencies = List.copyOf(parsedDependencies); List parsedSteps = new ArrayList<>(); JsonArray stepArray = array(source, "steps", MAX_STEPS); if (stepArray.isEmpty() || stepArray.size() > maxSteps) throw invalid("Cantidad de pasos fuera del límite"); for (JsonElement value : stepArray) { if (!value.isJsonObject()) throw invalid("Paso inválido"); JsonObject step = value.getAsJsonObject(); rejectUnknown(step, Set.of("primitive", "tool", "version", "sha256", "args", "timeoutTicks")); if (step.has("primitive") == step.has("tool")) throw invalid("Cada paso debe elegir una primitiva o herramienta"); int timeout = integer(step, "timeoutTicks", 1, maxTicks); JsonObject args = step.has("args") ? object(step, "args").deepCopy() : new JsonObject(); validateArguments(args, true); if (step.has("primitive")) { if (step.has("version") || step.has("sha256")) throw invalid("La primitiva no admite una versión de herramienta"); parsedSteps.add(new Step(identifier(string(step, "primitive", 48)), null, args, timeout)); } else { Dependency dependency = new Dependency(identifier(string(step, "tool", 48)), integer(step, "version", 1, 1_000_000), hash(string(step, "sha256", 64))); if (!dependencies.contains(dependency)) throw invalid("La herramienta utilizada debe declarar su dependencia exacta"); parsedSteps.add(new Step(null, dependency, args, timeout)); } } steps = List.copyOf(parsedSteps); this.source = source.deepCopy(); JsonObject signedContent = source.deepCopy(); signedContent.addProperty("trustedAuthor", author.toString()); sha256 = digest(canonical(signedContent)); } // Lectura estricta y metadatos: el JSON nunca decide quién lo escribió. public static ToolManifest parse(String json, UUID trustedAuthor) { if (json == null || json.getBytes(StandardCharsets.UTF_8).length > MAX_SOURCE_BYTES) throw invalid("Archivo de herramienta demasiado grande"); try { JsonElement value = JsonParser.parseString(json); if (!value.isJsonObject()) throw invalid("La herramienta debe ser un objeto JSON"); return new ToolManifest(value.getAsJsonObject(), trustedAuthor); } catch (StackOverflowError error) { throw invalid("JSON demasiado profundo"); } } public String id() { return id; } public int version() { return version; } public UUID author() { return author; } public String description() { return description; } public Set permissions() { return permissions; } public List dependencies() { return dependencies; } public List steps() { return steps; } public int maxTicks() { return maxTicks; } public int maxSteps() { return maxSteps; } public String sha256() { return sha256; } public JsonObject toJson() { return source.deepCopy(); } public record Dependency(String id, int version, String sha256) { } public static final class Step { private final String primitive; private final Dependency tool; private final JsonObject arguments; private final int timeoutTicks; private Step(String primitive, Dependency tool, JsonObject arguments, int timeoutTicks) { this.primitive = primitive; this.tool = tool; this.arguments = arguments.deepCopy(); this.timeoutTicks = timeoutTicks; } public String primitive() { return primitive; } public Dependency tool() { return tool; } public JsonObject arguments() { return arguments.deepCopy(); } public int timeoutTicks() { return timeoutTicks; } } // Valores permitidos: escalares JSON o referencia tipada a una entrada. static void validateArguments(JsonObject arguments, boolean referencesAllowed) { if (arguments.size() > 32) throw invalid("Demasiados argumentos"); for (var entry : arguments.entrySet()) { argumentName(entry.getKey()); JsonElement value = entry.getValue(); if (referencesAllowed && value.isJsonObject()) { JsonObject reference = value.getAsJsonObject(); rejectUnknown(reference, Set.of("input")); argumentName(string(reference, "input", 48)); } else if (value.isJsonPrimitive()) { if (value.getAsJsonPrimitive().isString() && value.getAsString().length() > (entry.getKey().equals("manifest") ? 4096 : 512)) throw invalid("Argumento de texto demasiado largo"); if (value.getAsJsonPrimitive().isNumber() && !Double.isFinite(value.getAsDouble())) throw invalid("Número no finito"); } else throw invalid("Solo se admiten argumentos escalares"); } } static JsonObject resolve(JsonObject template, JsonObject inputs) { JsonObject result = new JsonObject(); for (var entry : template.entrySet()) { JsonElement value = entry.getValue(); if (value.isJsonObject()) { String key = value.getAsJsonObject().get("input").getAsString(); if (!inputs.has(key)) throw invalid("Falta la entrada " + key); value = inputs.get(key); } result.add(entry.getKey(), value.deepCopy()); } validateArguments(result, false); return result; } static String identifier(String value) { if (!value.matches("[a-z][a-z0-9_]{0,47}")) throw invalid("Identificador inválido"); return value; } static String argumentName(String value) { if (!value.matches("[a-z][a-zA-Z0-9_]{0,47}")) throw invalid("Nombre de argumento inválido"); return value; } private static String hash(String value) { if (!value.matches("[0-9a-f]{64}")) throw invalid("Hash inválido"); return value; } static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } static void rejectUnknown(JsonObject object, Set allowed) { for (String key : object.keySet()) if (!allowed.contains(key)) throw invalid("Campo no permitido: " + key); } private static JsonObject object(JsonObject object, String key) { if (!object.has(key) || !object.get(key).isJsonObject()) throw invalid("Falta el objeto " + key); return object.getAsJsonObject(key); } private static JsonArray array(JsonObject object, String key, int max) { if (!object.has(key) || !object.get(key).isJsonArray()) throw invalid("Falta la lista " + key); JsonArray array = object.getAsJsonArray(key); if (array.size() > max) throw invalid("Lista demasiado grande: " + key); return array; } private static String string(JsonObject object, String key, int max) { if (!object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isString()) throw invalid("Falta el texto " + key); String value = object.get(key).getAsString(); if (value.isBlank() || value.length() > max) throw invalid("Texto fuera del límite: " + key); return value; } private static int integer(JsonObject object, String key, int min, int max) { if (!object.has(key) || !object.get(key).isJsonPrimitive() || !object.getAsJsonPrimitive(key).isNumber()) throw invalid("Falta el entero " + key); try { int value = object.get(key).getAsBigDecimal().intValueExact(); if (value < min || value > max) throw invalid("Entero fuera del límite: " + key); return value; } catch (ArithmeticException exception) { throw invalid("Entero inválido: " + key); } } static String canonical(JsonElement element) { if (element.isJsonObject()) { StringBuilder result = new StringBuilder("{"); boolean first = true; for (String key : new TreeSet<>(element.getAsJsonObject().keySet())) { if (!first) result.append(','); result.append(new com.google.gson.JsonPrimitive(key)).append(':').append(canonical(element.getAsJsonObject().get(key))); first = false; } return result.append('}').toString(); } if (element.isJsonArray()) { StringBuilder result = new StringBuilder("["); boolean first = true; for (JsonElement value : element.getAsJsonArray()) { if (!first) result.append(','); result.append(canonical(value)); first = false; } return result.append(']').toString(); } return element.toString(); } private static String digest(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } } }