Volver al índice
src/main/java/ar/com/companeros/tools/ToolManifest.java
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<ToolPermission> permissions;
private final List<Dependency> dependencies;
private final List<Step> 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<ToolPermission> 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<Dependency> 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<Step> 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<ToolPermission> permissions() { return permissions; }
public List<Dependency> dependencies() { return dependencies; }
public List<Step> 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<String> 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); }
}
}