summary refs log tree commit diff stats
path: root/src/main/java/ganarchy/friendcode/sam/I2PSamCommand.java
blob: bed5bb80c1dcfa4a0536be4f8a6ca725338998e6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
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;
    }
}