diff options
Diffstat (limited to 'src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java')
-rw-r--r-- | src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java | 333 |
1 files changed, 333 insertions, 0 deletions
diff --git a/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java new file mode 100644 index 0000000..bed5bb8 --- /dev/null +++ b/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java @@ -0,0 +1,333 @@ +package ganarchy.friendcode.sam; + +import com.google.common.collect.ImmutableMap; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * An I2P SAM command. + * + * @param name The command name. + * @param parameters The parameters. + */ +record I2PSamCommand(String name, String opcode, Map<String, String> parameters) { + /** + * Creates a new I2P SAM command. + * + * @param name The command name. + * @param opcode The subcommand name, or null. + * @param parameters The parameters. + * @throws IllegalArgumentException If the command or parameters don't follow the expected format. + * @throws NullPointerException If anything (except for opcode) is null. + */ + public I2PSamCommand(final String name, final String opcode, final Map<String, String> parameters) { + this.name = name; + this.opcode = opcode; + this.parameters = ImmutableMap.copyOf(parameters); + + // command formats: + // COMMAND SUBCOMMAND KEY KEY= KEY="" KEY=VALUE KEY=" " KEY="\"" KEY="\\" + // COMMAND + // PING[ arbitrary text] + // PONG[ arbitrary text] + // none of these may contain newlines (\n) + + // edge-case: I2PRouter treats empty values as "true", while we distinguish empty values. + // it's not strictly specified how they are to be handled, but anyway. + + // check newlines + if (this.name.contains("\n") || (this.opcode != null && this.opcode.contains("\n")) || this.parameters.entrySet().stream().anyMatch(arg -> arg.getKey().contains("\n") || arg.getValue().contains("\n"))) { + throw new IllegalArgumentException("commands may not contain embedded newlines"); + } + + if (this.name.isEmpty()) { + throw new IllegalArgumentException("name must not be empty"); + } + + // check for PING/PONG + if (this.name.equals("PING") || this.name.equals("PONG")) { + // reject parameters + if (!this.parameters.isEmpty()) { + throw new IllegalArgumentException("PING/PONG does not accept parameters"); + } + // skip any other validation + return; + } + + // everything else is fine + + // NOTE: we don't special-case I2PRouter's weirdness with \"\"COMMAND\"\" and \"\"OPCODE\"\" + // for one, we require name != null and !name.isEmpty(), so \"\"COMMAND\"\" would error on I2PRouter anyway, + // for two, single-word commands don't take a subcommand, so it doesn't even matter if you set \"\"OPCODE\"\". + } + + /** + * Creates a new I2P SAM command, with no parameters. + * + * @param name The command name. + * @param opcode The subcommand name, or null. + */ + public I2PSamCommand(final String name, final String opcode) { + this(name, opcode, ImmutableMap.of()); + } + + /** + * Builds the complete command line. + * + * @return The complete command line. + */ + public String buildCommandLine() { + final StringBuilder sb = new StringBuilder(); + sb.append(escape(this.name, false)); + if (this.opcode != null) { + sb.append(' '); + if (this.name.equals("PING") || this.name.equals("PONG")) { + sb.append(this.opcode); + } else { + sb.append(escape(this.opcode, false)); + } + } + for (final var argument : parameters.entrySet()) { + sb.append(' '); + sb.append(escape(argument.getKey(), false)); + sb.append('='); + sb.append(escape(argument.getValue(), true)); + } + return sb.toString(); + } + + /** + * Parses the given command line and returns an I2PSamCommand. + * + * @param commandLine The command line, without terminating newline. + * @return The parsed command, or null if there was an error. + */ + public static I2PSamCommand parse(final String commandLine) { + // we're a bit more strict than I2PRouter in how we parse these, but that should be okay. + if (commandLine.isEmpty()) { + return null; + } + if (commandLine.contains("\n")) { + throw new IllegalArgumentException("commandLine may not contain embedded newlines"); + } + final var rawParams = new StringBuilder(commandLine); + int index; + index = nextToken(rawParams); + final String name = rawParams.substring(0, index); + if (index == rawParams.length()) { // invariant: length() != 0 + // COMMAND + // (includes PING/PONG with no data) + return new I2PSamCommand(name, null); + } else if (name.isEmpty() || rawParams.charAt(index) != ' ') { + throw new IllegalArgumentException("commandLine must start with a command"); + } + rawParams.delete(0, index + 1); + if (name.equals("PING") || name.equals("PONG")) { + // PING arbitrary text + // PONG arbitrary text + return new I2PSamCommand(name, rawParams.toString()); + } + // COMMAND SUBCOMMAND[ ...] + index = nextToken(rawParams); + final String opcode = rawParams.substring(0, index); + if (opcode.isEmpty()) { // also if length() == 0 + // COMMAND =... or some other BS + // we do not currently accept trailing spaces or parameters for single-word commands coming from the bridge. + throw new IllegalArgumentException("expected subcommand"); + } else if (index == rawParams.length()) { // invariant: length() != 0 (checked above) + // COMMAND SUBCOMMAND + return new I2PSamCommand(name, opcode); + } else if (rawParams.charAt(index) != ' ') { // index != length() + throw new IllegalArgumentException("expected subcommand"); + } + rawParams.delete(0, index + 1); + // COMMAND SUBCOMMAND ... + final ImmutableMap.Builder<String, String> params = ImmutableMap.builder(); + // params + while (!rawParams.isEmpty()) { + index = nextToken(rawParams); + String key = rawParams.substring(0, index); + if (key.isEmpty()) { + throw new IllegalArgumentException("expected parameter key"); + } else if (index == rawParams.length()) { + // KEY + params.put(key, ""); + rawParams.setLength(0); + } else if (rawParams.charAt(index) == ' ') { + // KEY ... + params.put(key, ""); + rawParams.delete(0, index + 1); + } else if (rawParams.charAt(index) == '=') { + rawParams.delete(0, index + 1); + if (rawParams.length() == 0) { + // KEY= + params.put(key, ""); + } else if (rawParams.charAt(0) == '"') { + // KEY="... + // just special-case it + rawParams.deleteCharAt(0); + for (; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == '\r' || c == '"') { + // characters that must be escaped in quotes + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + if (index == rawParams.length()) { + // KEY=" + throw new IllegalArgumentException("unterminated quote"); + } else if (rawParams.charAt(index) == '"') { + // KEY=""... + final String value = rawParams.substring(0, index); + params.put(key, value); + if (index + 1 == rawParams.length()) { + // KEY="" + rawParams.delete(0, index + 1); + } else if (rawParams.charAt(index + 1) == ' ') { + // KEY="" ... + rawParams.delete(0, index + 2); + } else { + throw new IllegalArgumentException("malformed quote"); + } + } else { + throw new IllegalArgumentException("malformed quote"); + } + } else { + // KEY=... + index = nextToken(rawParams); + if (index == rawParams.length()) { + // KEY=VALUE + final String value = rawParams.substring(0, index); + params.put(key, value); + rawParams.delete(0, index); + } else if (rawParams.charAt(index) == '=') { + // KEY=VALUE=... + // just special-case it + for (; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '\r' || c == '"') { + // characters that must (generally) be escaped + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + final String value = rawParams.substring(0, index); + if (index == rawParams.length()) { + // KEY=VALUE= + params.put(key, value); + rawParams.delete(0, index); + } else if (rawParams.charAt(index) == ' ') { + // KEY=VALUE= ... + params.put(key, value); + rawParams.delete(0, index + 1); + } else { + throw new IllegalArgumentException("malformed parameter value"); + } + } else if (rawParams.charAt(index) == ' ') { + // KEY=VALUE ... + final String value = rawParams.substring(0, index); + params.put(key, value); + rawParams.delete(0, index + 1); + } else { + throw new IllegalArgumentException("malformed parameter value"); + } + } + } else { + throw new IllegalArgumentException("expected parameter key"); + } + } + return new I2PSamCommand(name, opcode, params.build()); + } + + @Override + public String toString() { + // The built command line is likely nicer to use when debugging. + return this.buildCommandLine(); + } + + /** + * Escapes and optionally quotes a string. + * + * @param input The input string. + * @param valueSection Whether this is a value section. + * @return The escaped and optionally quoted string. + */ + private static String escape(final String input, final boolean valueSection) { + final StringBuilder sb = new StringBuilder(0); + int lastIndex = 0; + for (int index = 0; index < input.length(); index++) { + final int c = input.charAt(index); + final boolean escape; + if (c == '=') { + // only escaped outside of value section, but does not require quoting. + escape = !valueSection; + } else if (c == ' ' || c == '\t' || c == '\f' || c == '\b') { + // only escaped outside of value section. requires quoting. + escape = !valueSection; + if (valueSection) { + // make sure the sb isn't empty so it actually adds quotes + sb.append(input, lastIndex, index + 1); + lastIndex = index + 1; + } + } else if (c == '\r' || c == '"' || c == '\\') { + // always escaped + escape = true; + } else { + // never escaped + escape = false; + } + if (escape) { + sb.append(input, lastIndex, index); + sb.append('\\'); + sb.append(c); + lastIndex = index + 1; + } + } + if (!sb.isEmpty()) { + sb.append(input, lastIndex, input.length()); + if (valueSection) { + sb.insert(0, '"'); + sb.append('"'); + } + return sb.toString(); + } else { + return input; + } + } + + /** + * Parses the next token. + * + * @param rawParams The input being processed. + * @return The index of the end of the next token. + */ + private static int nextToken(final StringBuilder rawParams) { + int index; + for (index = 0; index < rawParams.length(); index++) { + final int c = rawParams.charAt(index); + if (c == ' ' || c == '\t' || c == '\f' || c == '\b' || c == '=' || c == '\r' || c == '"') { + // characters that must (generally) be escaped + break; + } else if (c == '\\') { + // skip next character + rawParams.deleteCharAt(index); + if (index == rawParams.length()) { + throw new IllegalArgumentException("unterminated escape"); + } + } + } + return index; + } +} |