go up view raw 14222
package ganarchy.friendcode.sam;

import com.google.common.collect.ImmutableMap;

import java.util.Map;

/**
 * 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");
        }

        if (this.parameters.containsKey("")) {
            throw new IllegalArgumentException("parameter key 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;
    }
}