summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java')
-rw-r--r--src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java333
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;
+    }
+}