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