package com.hubilon.proto; import com.github.os72.protocjar.Protoc; import com.google.protobuf.DescriptorProtos; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; import com.google.protobuf.util.JsonFormat; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Compiles a user-supplied .proto file with the protoc binary bundled by * protoc-jar (no system protoc install required) into a FileDescriptorSet, * then decodes raw protobuf bytes against a named message type via * DynamicMessage -- no generated Java classes needed. Compiled descriptors * are cached per .proto file and invalidated on mtime change. */ public final class ProtobufDecoder { private ProtobufDecoder() { } private static final Map CACHE = new ConcurrentHashMap<>(); private static final class CacheEntry { final long protoMtimeMs; final Map filesByName; CacheEntry(long protoMtimeMs, Map filesByName) { this.protoMtimeMs = protoMtimeMs; this.filesByName = filesByName; } } public static String decodeToJson(Path protoDir, String protoFile, String messageType, byte[] data) throws IOException { if (protoFile == null || protoFile.isBlank()) { throw new IOException("protobuf.protoFile is required"); } if (messageType == null || messageType.isBlank()) { throw new IOException("protobuf.messageType is required"); } if (protoDir == null || !Files.isDirectory(protoDir)) { throw new IOException("proto directory not found: " + protoDir); } Path protoDirFull = protoDir.toAbsolutePath().normalize(); Path protoFull = protoDirFull.resolve(protoFile).normalize(); if (!protoFull.startsWith(protoDirFull)) { throw new IOException("protoFile escapes the proto directory: " + protoFile); } if (!Files.exists(protoFull)) { throw new IOException(".proto file not found: " + protoFile); } long mtime = Files.getLastModifiedTime(protoFull).toMillis(); String cacheKey = protoFull.toString(); CacheEntry cached = CACHE.get(cacheKey); Map filesByName; if (cached != null && cached.protoMtimeMs == mtime) { filesByName = cached.filesByName; } else { filesByName = compile(protoDirFull, protoFile); CACHE.put(cacheKey, new CacheEntry(mtime, filesByName)); } Descriptors.Descriptor descriptor = findMessage(filesByName.values(), messageType); if (descriptor == null) { throw new IOException("Message type not found in " + protoFile + ": " + messageType); } DynamicMessage message = DynamicMessage.parseFrom(descriptor, data); return JsonFormat.printer().includingDefaultValueFields().print(message); } private static Map compile(Path protoDirFull, String protoFile) throws IOException { Path descFile = Files.createTempFile("apidiff-proto-", ".desc"); try { int rc; try { rc = Protoc.runProtoc(new String[]{ "--proto_path=" + protoDirFull, "--descriptor_set_out=" + descFile, "--include_imports", protoFile }); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("protoc invocation interrupted", e); } if (rc != 0) { throw new IOException("protoc failed to compile " + protoFile + " (exit code " + rc + ")"); } DescriptorProtos.FileDescriptorSet set; try (var in = Files.newInputStream(descFile)) { set = DescriptorProtos.FileDescriptorSet.parseFrom(in); } Map protoByName = new HashMap<>(); for (DescriptorProtos.FileDescriptorProto fdp : set.getFileList()) { protoByName.put(fdp.getName(), fdp); } Map built = new HashMap<>(); for (String name : protoByName.keySet()) { buildFileDescriptor(name, protoByName, built); } return built; } finally { Files.deleteIfExists(descFile); } } private static Descriptors.FileDescriptor buildFileDescriptor( String name, Map protoByName, Map built) throws IOException { Descriptors.FileDescriptor existing = built.get(name); if (existing != null) { return existing; } DescriptorProtos.FileDescriptorProto proto = protoByName.get(name); if (proto == null) { throw new IOException("Missing dependency descriptor: " + name); } List deps = new ArrayList<>(); for (String depName : proto.getDependencyList()) { deps.add(buildFileDescriptor(depName, protoByName, built)); } try { Descriptors.FileDescriptor fd = Descriptors.FileDescriptor.buildFrom(proto, deps.toArray(new Descriptors.FileDescriptor[0])); built.put(name, fd); return fd; } catch (Descriptors.DescriptorValidationException e) { throw new IOException("Invalid proto descriptor for " + name + ": " + e.getMessage(), e); } } /** Matches by fully-qualified name first (pkg.Outer.Inner), falling back to an unambiguous simple-name match. */ private static Descriptors.Descriptor findMessage(Iterable files, String messageType) { List all = new ArrayList<>(); for (Descriptors.FileDescriptor fd : files) { for (Descriptors.Descriptor top : fd.getMessageTypes()) { collect(top, all); } } String normalized = messageType.startsWith(".") ? messageType.substring(1) : messageType; for (Descriptors.Descriptor d : all) { if (d.getFullName().equals(normalized)) { return d; } } Descriptors.Descriptor onlyMatch = null; for (Descriptors.Descriptor d : all) { if (d.getName().equals(normalized)) { if (onlyMatch != null) { return null; // ambiguous simple name, require a fully-qualified messageType } onlyMatch = d; } } return onlyMatch; } private static void collect(Descriptors.Descriptor d, List out) { out.add(d); for (Descriptors.Descriptor nested : d.getNestedTypes()) { collect(nested, out); } } }