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); }
    }
}