ghidra-cli/java/GhidrAdapter.java
2026-07-28 22:20:39 +00:00

954 lines
35 KiB
Java

// SPDX-License-Identifier: Apache-2.0
// Ghidra headless post-script for ghidra-cli. This file intentionally has no
// package.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.util.headless.HeadlessScript;
import ghidra.framework.Application;
import ghidra.program.model.address.Address;
import ghidra.program.model.address.AddressFactory;
import ghidra.program.model.address.AddressSpace;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Strict, bounded, read-only adapter used only by the ghidr harness. */
public final class GhidrAdapter extends HeadlessScript {
static final long PROTOCOL_VERSION = 1;
static final int MAX_REQUEST_BYTES = 1_048_576;
static final long MAX_RESPONSE_BYTES = 268_435_456L;
static final Set<String> DISPATCH = Set.of("doctor", "inspect", "functions", "decompile");
@Override
protected void run() throws Exception {
String[] arguments = getScriptArgs();
if (arguments.length != 2) {
throw new AdapterException(
"protocol_violation", "adapter requires request and response paths");
}
Path requestPath = Path.of(arguments[0]);
Path responsePath = Path.of(arguments[1]);
if (!requestPath.isAbsolute() || !responsePath.isAbsolute()) {
throw new AdapterException("protocol_violation", "adapter paths must be absolute");
}
Path temporaryPath = responsePath.resolveSibling(responsePath.getFileName() + ".tmp");
Request request = null;
try {
request = parseRequest(readBounded(requestPath));
if (!request.operation.equals("doctor") && analysisTimeoutOccurred()) {
throw new AdapterException("analysis_timeout", "Ghidra auto-analysis timed out");
}
Object data = dispatch(request);
writeResponse(temporaryPath, responsePath, response(request, "success", data, null));
} catch (ResponseTooLargeException error) {
Files.deleteIfExists(temporaryPath);
if (request == null) {
throw error;
}
Map<String, Object> details = object();
details.put("limit_bytes", MAX_RESPONSE_BYTES);
details.put("observed_at_least_bytes", error.observedAtLeast);
details.put("operation", request.operation);
writeResponse(temporaryPath, responsePath,
response(request, "error", null,
new AdapterError("result_too_large", "adapter result exceeds 256 MiB", details)));
} catch (AdapterException error) {
Files.deleteIfExists(temporaryPath);
if (request == null) {
throw error;
}
writeResponse(temporaryPath, responsePath,
response(request, "error", null,
new AdapterError(error.code, error.getMessage(), error.details)));
}
}
private Object dispatch(Request request) throws Exception {
return switch (request.operation) {
case "doctor" -> doctor(request.arguments);
case "inspect" -> inspect(request.arguments);
case "functions" -> functions(request.arguments);
case "decompile" -> decompile(request.arguments, request.decompileTimeoutSeconds);
default ->
throw new AdapterException("protocol_violation", "operation is not dispatched by Java");
};
}
private Object doctor(Map<String, Object> arguments) throws AdapterException {
expectKeys(arguments, Set.of("kind"), "doctor arguments");
requireKind(arguments, "doctor");
Map<String, Object> result = object();
result.put("ready", true);
result.put("ghidra_version", Application.getApplicationVersion());
result.put("java_version", Runtime.version().feature());
result.put("adapter_protocol_version", PROTOCOL_VERSION);
return result;
}
private Object inspect(Map<String, Object> arguments) throws AdapterException {
expectProgram();
expectKeys(arguments, Set.of("kind"), "inspect arguments");
requireKind(arguments, "inspect");
Map<String, Object> program = object();
program.put("image_base", address(currentProgram.getImageBase()));
program.put("minimum_address", address(currentProgram.getMinAddress()));
program.put("maximum_address", address(currentProgram.getMaxAddress()));
program.put("function_count", allFunctions().size());
return queryResult(program);
}
private Object functions(Map<String, Object> arguments) throws AdapterException {
expectProgram();
expectKeys(arguments, Set.of("kind", "page"), "functions arguments");
requireKind(arguments, "functions");
Map<String, Object> pageRequest = asObject(arguments.get("page"), "page");
expectKeys(pageRequest, Set.of("offset", "limit"), "page");
long offset = nonNegativeLong(pageRequest.get("offset"), "offset");
Object limitValue = pageRequest.get("limit");
Long limit = parseLimit(limitValue);
List<Function> all = allFunctions();
long total = all.size();
int start = (int) Math.min(offset, total);
long requestedEnd = limit == null ? total : Math.min(total, saturatingAdd(offset, limit));
int end = (int) Math.max(start, requestedEnd);
List<Object> items = new ArrayList<>();
for (Function function : all.subList(start, end)) {
items.add(functionItem(function));
}
Map<String, Object> page = object();
page.put("order", "location_then_entry_ascending");
page.put("offset", offset);
page.put("limit", limit);
page.put("returned", items.size());
page.put("total", total);
page.put("has_more", end < total);
Map<String, Object> result = object();
result.put("page", page);
result.put("items", items);
return queryResult(result);
}
private Object decompile(Map<String, Object> arguments, long timeoutSeconds) throws Exception {
expectProgram();
expectKeys(arguments, Set.of("kind", "selector"), "decompile arguments");
requireKind(arguments, "decompile");
Map<String, Object> selector = asObject(arguments.get("selector"), "selector");
expectKeys(selector, Set.of("kind", "value"), "selector");
String kind = string(selector.get("kind"), "selector kind");
Function function;
if (kind.equals("name")) {
String value = string(selector.get("value"), "selector value");
function = resolveName(value);
} else if (kind.equals("address")) {
function = resolveAddress(asObject(selector.get("value"), "selector address"));
} else {
throw new AdapterException("protocol_violation", "unknown Function Selector kind");
}
if (function.isExternal()) {
throw new AdapterException(
"function_not_decompilable", "external Function cannot be decompiled");
}
String text;
DecompInterface decompiler = new DecompInterface();
try {
if (!decompiler.openProgram(currentProgram)) {
throw new AdapterException(
"decompilation_failed", "Ghidra decompiler did not open Program");
}
int seconds = (int) Math.min(timeoutSeconds, Integer.MAX_VALUE);
DecompileResults results = decompiler.decompileFunction(function, seconds, monitor);
if (!results.decompileCompleted()) {
String message = results.getErrorMessage();
if (message != null && message.toLowerCase(java.util.Locale.ROOT).contains("timeout")) {
throw new AdapterException("decompilation_timeout", "Ghidra decompilation timed out");
}
throw new AdapterException("decompilation_failed",
message == null || message.isBlank() ? "Ghidra decompilation failed" : message);
}
text = normalizeLineEndings(results.getDecompiledFunction().getC());
} finally {
decompiler.dispose();
}
Map<String, Object> selected = object();
selected.put("name", function.getName(false));
selected.put("qualified_name", function.getName(true));
selected.put("entry", address(function.getEntryPoint()));
Map<String, Object> decompilation = object();
decompilation.put("syntax", "ghidra_c");
decompilation.put("text", text);
Map<String, Object> result = object();
result.put("requested_selector", selector);
result.put("function", selected);
result.put("decompilation", decompilation);
return queryResult(result);
}
private Function resolveName(String requested) throws AdapterException {
boolean qualified = requested.contains("::");
List<Function> matches = new ArrayList<>();
for (Function function : allFunctions()) {
String candidate = qualified ? function.getName(true) : function.getName(false);
if (candidate.equals(requested)) {
matches.add(function);
}
}
if (matches.isEmpty()) {
throw new AdapterException("function_not_found", "Function Selector did not resolve");
}
if (matches.size() != 1) {
Map<String, Object> details = object();
List<Object> candidates = new ArrayList<>();
for (Function function : matches.subList(0, Math.min(100, matches.size()))) {
candidates.add(address(function.getEntryPoint()));
}
details.put("candidates", candidates);
details.put("candidate_count", matches.size());
throw new AdapterException("function_selector_ambiguous",
"Function Selector resolves to more than one Function", details);
}
return matches.get(0);
}
private Function resolveAddress(Map<String, Object> selector) throws AdapterException {
expectKeys(selector, Set.of("space", "offset"), "selector address");
String spaceName = string(selector.get("space"), "address space");
String offsetText = string(selector.get("offset"), "address offset");
AddressFactory factory = currentProgram.getAddressFactory();
AddressSpace selectedSpace = null;
List<String> spaces = new ArrayList<>();
for (AddressSpace space : factory.getAllAddressSpaces()) {
spaces.add(space.getName());
if (space.getName().equals(spaceName)) {
selectedSpace = space;
}
}
if (selectedSpace == null) {
spaces.sort(GhidrAdapter::compareUtf8);
Map<String, Object> details = object();
details.put("valid_address_spaces", spaces.subList(0, Math.min(100, spaces.size())));
throw new AdapterException(
"address_space_not_found", "address space does not exist", details);
}
int width = Math.max(2, (selectedSpace.getSize() + 3) / 4);
if (!offsetText.matches("0x[0-9a-f]+") || offsetText.length() != width + 2) {
throw new AdapterException(
"protocol_violation", "address offset is not canonical for its space");
}
BigInteger offset = new BigInteger(offsetText.substring(2), 16);
if (offset.bitLength() > 64) {
throw new AdapterException(
"protocol_violation", "address offset exceeds adapter representation");
}
Address requested;
try {
requested = selectedSpace.getAddress(offset.longValue());
} catch (RuntimeException error) {
throw new AdapterException("function_not_found", "address is outside its address space");
}
Function exact = currentProgram.getFunctionManager().getFunctionAt(requested);
if (exact != null) {
return exact;
}
Function containing = currentProgram.getFunctionManager().getFunctionContaining(requested);
if (containing != null) {
Map<String, Object> details = object();
details.put("containing_function_entry", address(containing.getEntryPoint()));
throw new AdapterException(
"function_entry_required", "address is inside a Function but is not its entry", details);
}
throw new AdapterException(
"function_not_found", "no Function exists at the requested entry Address");
}
private List<Function> allFunctions() {
LinkedHashSet<Function> unique = new LinkedHashSet<>();
FunctionIterator memory = currentProgram.getFunctionManager().getFunctions(true);
while (memory.hasNext()) {
unique.add(memory.next());
}
FunctionIterator external = currentProgram.getFunctionManager().getExternalFunctions();
while (external.hasNext()) {
unique.add(external.next());
}
List<Function> functions = new ArrayList<>(unique);
functions.sort(Comparator.comparingInt((Function function) -> function.isExternal() ? 1 : 0)
.thenComparing(function
-> function.getEntryPoint().getAddressSpace().getName(),
GhidrAdapter::compareUtf8)
.thenComparing(
function -> unsigned(function.getEntryPoint().getOffset()), BigInteger::compareTo));
return functions;
}
private Map<String, Object> functionItem(Function function) {
Map<String, Object> item = object();
item.put("name", function.getName(false));
item.put("qualified_name", function.getName(true));
item.put("entry", address(function.getEntryPoint()));
item.put("body_address_count", function.getBody().getNumAddresses());
item.put("location", function.isExternal() ? "external" : "memory");
item.put("is_external", function.isExternal());
item.put("is_thunk", function.isThunk());
Function thunk = function.getThunkedFunction(false);
item.put("thunk_target_entry", thunk == null ? null : address(thunk.getEntryPoint()));
item.put("decompilable", !function.isExternal());
return item;
}
private Map<String, Object> target() {
Map<String, Object> target = object();
String executableFormat = currentProgram.getExecutableFormat();
if (executableFormat.contains("ELF")) {
target.put("loader", "ElfLoader");
target.put("format", "ELF");
} else if (executableFormat.contains("Portable Executable")) {
target.put("loader", "PeLoader");
target.put("format",
currentProgram.getLanguage().getLanguageDescription().getSize() == 64 ? "PE32+" : "PE");
} else {
target.put("loader", executableFormat);
target.put("format", executableFormat);
}
target.put("processor_language", currentProgram.getLanguageID().getIdAsString());
target.put("compiler_specification",
currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString());
return target;
}
private Map<String, Object> queryResult(Object query) {
Map<String, Object> context = object();
context.put("ghidra_version", Application.getApplicationVersion());
context.put("java_version", Runtime.version().feature());
context.put("target", target());
context.put("loader_options", List.of());
context.put("analyzer_options", analysisOptions());
Map<String, Object> result = object();
result.put("context", context);
result.put("query", query);
return result;
}
private List<Object> analysisOptions() {
Map<String, String> current = getCurrentAnalysisOptionsAndValues(currentProgram);
List<String> names = new ArrayList<>(current.keySet());
names.sort(GhidrAdapter::compareUtf8);
List<Object> result = new ArrayList<>();
for (String name : names) {
Map<String, Object> value = object();
value.put("type", "string");
value.put("value", current.get(name));
Map<String, Object> option = object();
option.put("name", name);
option.put("value", value);
result.add(option);
}
return result;
}
private static Map<String, Object> address(Address value) {
if (value == null || value == Address.NO_ADDRESS) {
return null;
}
AddressSpace space = value.getAddressSpace();
int width = Math.max(2, (space.getSize() + 3) / 4);
String digits = Long.toUnsignedString(value.getOffset(), 16);
Map<String, Object> result = object();
result.put("space", space.getName());
result.put("offset",
"0x"
+ "0".repeat(Math.max(0, width - digits.length())) + digits);
return result;
}
private static Request parseRequest(byte[] bytes) throws AdapterException {
Object parsed = new JsonParser(decodeUtf8(bytes)).parse();
Map<String, Object> root = asObject(parsed, "request");
expectKeys(root,
Set.of("protocol_version", "invocation_id", "operation", "staged_sample", "analysis_path",
"limits", "arguments"),
"request");
long version = nonNegativeLong(root.get("protocol_version"), "protocol_version");
if (version != PROTOCOL_VERSION) {
throw new AdapterException("protocol_violation", "unsupported adapter protocol version");
}
String invocation = string(root.get("invocation_id"), "invocation_id");
if (!invocation.matches("[0-9a-f]{32}")) {
throw new AdapterException("protocol_violation", "invalid invocation ID");
}
String operation = string(root.get("operation"), "operation");
if (!DISPATCH.contains(operation)) {
throw new AdapterException("protocol_violation", "operation is not in Java dispatch set");
}
Object stagedSample = root.get("staged_sample");
Object analysisPath = root.get("analysis_path");
nullableAbsolutePath(stagedSample, "staged_sample");
nullableAbsolutePath(analysisPath, "analysis_path");
if (operation.equals("doctor") && (stagedSample != null || analysisPath != null)) {
throw new AdapterException(
"protocol_violation", "doctor must not receive Sample or Analysis paths");
}
if (!operation.equals("doctor") && (stagedSample == null || analysisPath == null)) {
throw new AdapterException(
"protocol_violation", "Sample-backed operation requires Sample and Analysis paths");
}
Map<String, Object> limits = asObject(root.get("limits"), "limits");
expectKeys(limits,
Set.of("max_heap_mib", "max_cpu", "analysis_timeout_seconds", "decompile_timeout_seconds",
"child_watchdog_seconds", "max_sample_bytes", "max_inline_bytes"),
"limits");
positiveLong(limits.get("max_heap_mib"), "max_heap_mib");
positiveLong(limits.get("max_cpu"), "max_cpu");
positiveLong(limits.get("analysis_timeout_seconds"), "analysis_timeout_seconds");
Object decompileTimeout = limits.get("decompile_timeout_seconds");
long timeout =
decompileTimeout == null ? 60 : positiveLong(decompileTimeout, "decompile_timeout_seconds");
positiveLong(limits.get("child_watchdog_seconds"), "child_watchdog_seconds");
positiveLong(limits.get("max_sample_bytes"), "max_sample_bytes");
positiveLong(limits.get("max_inline_bytes"), "max_inline_bytes");
Map<String, Object> arguments = asObject(root.get("arguments"), "arguments");
if (!string(arguments.get("kind"), "argument kind").equals(operation)) {
throw new AdapterException("protocol_violation", "operation and argument kind differ");
}
return new Request(invocation, operation, arguments, timeout);
}
private static Map<String, Object> response(
Request request, String status, Object data, AdapterError error) {
Map<String, Object> root = object();
root.put("protocol_version", PROTOCOL_VERSION);
root.put("invocation_id", request.invocationId);
root.put("operation", request.operation);
Map<String, Object> result = object();
result.put("status", status);
if (error == null) {
result.put("data", data);
} else {
result.put("code", error.code);
result.put("message", error.message);
result.put("details", error.details);
}
root.put("result", result);
return root;
}
private static byte[] readBounded(Path path) throws IOException, AdapterException {
try (InputStream input = Files.newInputStream(path);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int remaining = MAX_REQUEST_BYTES + 1;
while (remaining > 0) {
int count = input.read(buffer, 0, Math.min(buffer.length, remaining));
if (count < 0) {
break;
}
output.write(buffer, 0, count);
remaining -= count;
}
if (output.size() > MAX_REQUEST_BYTES) {
throw new AdapterException("protocol_violation", "request exceeds 1 MiB");
}
return output.toByteArray();
}
}
private static String decodeUtf8(byte[] bytes) throws AdapterException {
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException error) {
throw new AdapterException("protocol_violation", "request is not valid UTF-8");
}
}
private static void writeResponse(Path temporary, Path response, Object value)
throws IOException, ResponseTooLargeException {
Files.deleteIfExists(temporary);
try (OutputStream raw = Files.newOutputStream(
temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
BoundedOutputStream bounded = new BoundedOutputStream(raw, MAX_RESPONSE_BYTES)) {
JsonWriter.write(value, bounded);
bounded.flush();
} catch (ResponseTooLargeException error) {
Files.deleteIfExists(temporary);
throw error;
}
try {
Files.move(temporary, response, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException error) {
Files.deleteIfExists(temporary);
throw new IOException("response filesystem does not support atomic rename", error);
}
}
private void expectProgram() throws AdapterException {
if (currentProgram == null) {
throw new AdapterException("analysis_failed", "operation requires an imported Program");
}
}
private static void requireKind(Map<String, Object> arguments, String expected)
throws AdapterException {
if (!string(arguments.get("kind"), "argument kind").equals(expected)) {
throw new AdapterException("protocol_violation", "unexpected argument kind");
}
}
private static Long parseLimit(Object value) throws AdapterException {
if (value instanceof String string && string.equals("all")) {
return null;
}
Map<String, Object> tagged = asObject(value, "limit");
expectKeys(tagged, Set.of("bounded"), "bounded limit");
return positiveLong(tagged.get("bounded"), "limit");
}
private static void nullableAbsolutePath(Object value, String name) throws AdapterException {
if (value == null) {
return;
}
String path = string(value, name);
try {
if (!Path.of(path).isAbsolute()) {
throw new AdapterException("protocol_violation", name + " must be absolute");
}
} catch (java.nio.file.InvalidPathException error) {
throw new AdapterException("protocol_violation", name + " is not a valid worker path");
}
}
private static long positiveLong(Object value, String name) throws AdapterException {
long result = nonNegativeLong(value, name);
if (result == 0) {
throw new AdapterException("protocol_violation", name + " must be positive");
}
return result;
}
private static long nonNegativeLong(Object value, String name) throws AdapterException {
if (!(value instanceof Long number) || number < 0) {
throw new AdapterException("protocol_violation", name + " must be a non-negative integer");
}
return number;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> asObject(Object value, String name) throws AdapterException {
if (!(value instanceof Map<?, ?>)) {
throw new AdapterException("protocol_violation", name + " must be an object");
}
return (Map<String, Object>) value;
}
private static String string(Object value, String name) throws AdapterException {
if (!(value instanceof String text)) {
throw new AdapterException("protocol_violation", name + " must be a string");
}
return text;
}
private static void expectKeys(Map<String, Object> object, Set<String> expected, String name)
throws AdapterException {
if (!object.keySet().equals(expected)) {
throw new AdapterException("protocol_violation", name + " has missing or unknown fields");
}
}
private static long saturatingAdd(long left, long right) {
return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right;
}
private static BigInteger unsigned(long value) {
return new BigInteger(Long.toUnsignedString(value));
}
private static int compareUtf8(String left, String right) {
byte[] a = left.getBytes(StandardCharsets.UTF_8);
byte[] b = right.getBytes(StandardCharsets.UTF_8);
int length = Math.min(a.length, b.length);
for (int index = 0; index < length; index++) {
int compared = Integer.compare(Byte.toUnsignedInt(a[index]), Byte.toUnsignedInt(b[index]));
if (compared != 0) {
return compared;
}
}
return Integer.compare(a.length, b.length);
}
private static String normalizeLineEndings(String input) {
return input.replace("\r\n", "\n").replace('\r', '\n');
}
private static Map<String, Object> object() {
return new LinkedHashMap<>();
}
private record Request(String invocationId, String operation, Map<String, Object> arguments,
long decompileTimeoutSeconds) {}
private record AdapterError(String code, String message, Map<String, Object> details) {}
private static final class AdapterException extends Exception {
private static final long serialVersionUID = 1L;
final String code;
final transient Map<String, Object> details;
AdapterException(String code, String message) {
this(code, message, object());
}
AdapterException(String code, String message, Map<String, Object> details) {
super(message);
this.code = code;
this.details = details;
}
}
private static final class ResponseTooLargeException extends IOException {
private static final long serialVersionUID = 1L;
final long observedAtLeast;
ResponseTooLargeException(long observedAtLeast) {
super("response exceeds fixed bound");
this.observedAtLeast = observedAtLeast;
}
}
private static final class BoundedOutputStream extends OutputStream {
private final OutputStream delegate;
private final long limit;
private long written;
BoundedOutputStream(OutputStream delegate, long limit) {
this.delegate = delegate;
this.limit = limit;
}
@Override
public void write(int value) throws IOException {
ensure(1);
delegate.write(value);
written++;
}
@Override
public void write(byte[] bytes, int offset, int length) throws IOException {
ensure(length);
delegate.write(bytes, offset, length);
written += length;
}
private void ensure(int additional) throws ResponseTooLargeException {
if (additional > limit - written) {
throw new ResponseTooLargeException(written + 1);
}
}
@Override
public void flush() throws IOException {
delegate.flush();
}
@Override
public void close() throws IOException {
delegate.close();
}
}
private static final class JsonWriter {
private JsonWriter() {}
static void write(Object value, OutputStream output) throws IOException {
if (value == null) {
bytes(output, "null");
} else if (value instanceof String string) {
string(output, string);
} else if (value instanceof Boolean || value instanceof Number) {
bytes(output, value.toString());
} else if (value instanceof Map<?, ?> map) {
output.write('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) {
output.write(',');
}
first = false;
string(output, entry.getKey().toString());
output.write(':');
write(entry.getValue(), output);
}
output.write('}');
} else if (value instanceof Iterable<?> iterable) {
output.write('[');
boolean first = true;
for (Object item : iterable) {
if (!first) {
output.write(',');
}
first = false;
write(item, output);
}
output.write(']');
} else {
throw new IOException("unsupported JSON value");
}
}
private static void string(OutputStream output, String value) throws IOException {
output.write('"');
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
switch (character) {
case '"' -> bytes(output, "\\\"");
case '\\' -> bytes(output, "\\\\");
case '\b' -> bytes(output, "\\b");
case '\f' -> bytes(output, "\\f");
case '\n' -> bytes(output, "\\n");
case '\r' -> bytes(output, "\\r");
case '\t' -> bytes(output, "\\t");
default -> {
if (character < 0x20) {
bytes(output, String.format("\\u%04x", (int) character));
} else {
int codePoint = value.codePointAt(index);
byte[] encoded =
new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
output.write(encoded);
if (Character.isSupplementaryCodePoint(codePoint)) {
index++;
}
}
}
}
}
output.write('"');
}
private static void bytes(OutputStream output, String value) throws IOException {
output.write(value.getBytes(StandardCharsets.UTF_8));
}
}
private static final class JsonParser {
private final String input;
private int offset;
JsonParser(String input) {
this.input = input;
}
Object parse() throws AdapterException {
Object value = value();
whitespace();
if (offset != input.length()) {
fail();
}
return value;
}
private Object value() throws AdapterException {
whitespace();
if (offset >= input.length()) {
return fail();
}
return switch (input.charAt(offset)) {
case '{' -> objectValue();
case '[' -> arrayValue();
case '"' -> stringValue();
case 't' -> literal("true", Boolean.TRUE);
case 'f' -> literal("false", Boolean.FALSE);
case 'n' -> literal("null", null);
default -> numberValue();
};
}
private Map<String, Object> objectValue() throws AdapterException {
offset++;
Map<String, Object> result = object();
whitespace();
if (consume('}')) {
return result;
}
while (true) {
whitespace();
if (offset >= input.length() || input.charAt(offset) != '"') {
return fail();
}
String key = stringValue();
if (result.containsKey(key)) {
throw new AdapterException("protocol_violation", "duplicate JSON object key");
}
whitespace();
if (!consume(':')) {
return fail();
}
result.put(key, value());
whitespace();
if (consume('}')) {
return result;
}
if (!consume(',')) {
return fail();
}
}
}
private List<Object> arrayValue() throws AdapterException {
offset++;
List<Object> result = new ArrayList<>();
whitespace();
if (consume(']')) {
return result;
}
while (true) {
result.add(value());
whitespace();
if (consume(']')) {
return result;
}
if (!consume(',')) {
return fail();
}
}
}
private String stringValue() throws AdapterException {
offset++;
StringBuilder result = new StringBuilder();
while (offset < input.length()) {
char character = input.charAt(offset++);
if (character == '"') {
return validString(result.toString());
}
if (character == '\\') {
if (offset >= input.length()) {
return fail();
}
char escaped = input.charAt(offset++);
switch (escaped) {
case '"', '\\', '/' -> result.append(escaped);
case 'b' -> result.append('\b');
case 'f' -> result.append('\f');
case 'n' -> result.append('\n');
case 'r' -> result.append('\r');
case 't' -> result.append('\t');
case 'u' -> result.append(unicodeEscape());
default -> throw new AdapterException("protocol_violation", "invalid JSON escape");
}
} else if (character < 0x20) {
return fail();
} else {
result.append(character);
}
}
return fail();
}
private String validString(String value) throws AdapterException {
for (int index = 0; index < value.length(); index++) {
char character = value.charAt(index);
if (Character.isHighSurrogate(character)) {
if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) {
throw new AdapterException(
"protocol_violation", "JSON string has an unpaired surrogate");
}
index++;
} else if (Character.isLowSurrogate(character)) {
throw new AdapterException("protocol_violation", "JSON string has an unpaired surrogate");
}
}
return value;
}
private char unicodeEscape() throws AdapterException {
if (offset + 4 > input.length()) {
return fail();
}
try {
char value = (char) Integer.parseInt(input.substring(offset, offset + 4), 16);
offset += 4;
return value;
} catch (NumberFormatException error) {
return fail();
}
}
private Object numberValue() throws AdapterException {
int start = offset;
if (offset < input.length() && input.charAt(offset) == '-') {
offset++;
}
if (offset >= input.length() || !Character.isDigit(input.charAt(offset))) {
return fail();
}
if (input.charAt(offset) == '0') {
offset++;
} else {
while (offset < input.length() && Character.isDigit(input.charAt(offset))) {
offset++;
}
}
if (offset < input.length() && ".eE+".indexOf(input.charAt(offset)) >= 0) {
throw new AdapterException("protocol_violation", "floating-point JSON is not accepted");
}
try {
return Long.valueOf(input.substring(start, offset));
} catch (NumberFormatException error) {
throw new AdapterException("protocol_violation", "JSON integer is out of range");
}
}
private Object literal(String text, Object value) throws AdapterException {
if (!input.startsWith(text, offset)) {
return fail();
}
offset += text.length();
return value;
}
private boolean consume(char expected) {
if (offset < input.length() && input.charAt(offset) == expected) {
offset++;
return true;
}
return false;
}
private void whitespace() {
while (offset < input.length() && " \n\r\t".indexOf(input.charAt(offset)) >= 0) {
offset++;
}
}
private <T> T fail() throws AdapterException {
throw new AdapterException("protocol_violation", "malformed JSON request");
}
}
}