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 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 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 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; } }