aboutsummaryrefslogtreecommitdiff
path: root/source/import/Json.mc
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 01:51:05 +0200
commit40d9a1d5c3e85486aea964c1446402a3be6ec05a (patch)
tree7af82c46a459afc99156e5a6022946b43e35a3de /source/import/Json.mc
parentbc228753bfb4a2faea540cddb20fc39ebd265db5 (diff)
feat(import): parse otpauth URIs and unencrypted Aegis JSON in resumable steps
Streaming JSON tokenizer with depth and length limits and native skipping of long strings; Aegis reader for database versions 1-3 with groups and encrypted-export detection; otpauth parser with ';' separators and Steam; import job with Replace mode, duplicate and replacement checks, a sealed multi-part buffer and a report. Google Authenticator exports deferred. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/import/Json.mc')
-rw-r--r--source/import/Json.mc486
1 files changed, 486 insertions, 0 deletions
diff --git a/source/import/Json.mc b/source/import/Json.mc
new file mode 100644
index 0000000..c19150e
--- /dev/null
+++ b/source/import/Json.mc
@@ -0,0 +1,486 @@
+// Author: cirakg & claude ai
+// Summary: A streaming JSON tokenizer over UTF-8 bytes: one token per call, strict grammar, depth and length limits.
+//
+// README 11.3: the Aegis reader must stream, never build large objects, and
+// pause between steps (watchdog). This tokenizer keeps only its position, a
+// small container stack and the current token, so a reader can stop after any
+// token and continue in the next event.
+//
+// Limits and guarantees:
+// * Strict RFC 8259 grammar; anything else is T_ERROR with a message.
+// * Nesting deeper than MAX_DEPTH is an error.
+// * Strings longer than MAX_STRING_BYTES, or with more than STRING_WINDOW
+// input bytes (icons in Aegis exports), are not decoded: they come back as T_LONG_STRING and are skipped with native
+// searches, so a 50 KB icon costs a few calls, not 50,000 loop rounds.
+// * Escapes, including \u with surrogate pairs, are decoded; the result
+// must be valid UTF-8 without NUL (Bytes.toUtf8).
+// * Input that ends inside the document gives T_INCOMPLETE (not an error),
+// so a multi-part import can wait for the rest.
+// * Numbers are returned as Long when they are integers that fit;
+// numberIsInteger is false otherwise (Aegis only needs integers).
+// * Work per call is bounded: at most STRING_WINDOW bytes are looked at
+// in Monkey C per token; searches over longer stretches are native.
+
+import Toybox.Lang;
+
+module Json {
+
+ const T_END = 0; // the document is complete
+ const T_BEGIN_OBJECT = 1;
+ const T_END_OBJECT = 2;
+ const T_BEGIN_ARRAY = 3;
+ const T_END_ARRAY = 4;
+ const T_KEY = 5; // text holds the key
+ const T_STRING = 6; // text holds the value
+ const T_NUMBER = 7; // number / numberIsInteger
+ const T_TRUE = 8;
+ const T_FALSE = 9;
+ const T_NULL = 10;
+ const T_LONG_STRING = 11; // a string value over MAX_STRING_BYTES, skipped
+ const T_INCOMPLETE = 12; // the input ended inside the document
+ const T_ERROR = 13; // error holds the reason
+
+ const MAX_DEPTH = 16;
+ // Longest decoded string; values the importer needs (names, secrets,
+ // group IDs) are far shorter.
+ const MAX_STRING_BYTES = 512;
+ // Input bytes examined for one string: escapes may double its size.
+ const STRING_WINDOW = 2 * MAX_STRING_BYTES + 2;
+ // Chunk size for native searches when skipping long strings.
+ const SKIP_CHUNK = 4096;
+
+ // Parser states.
+ const S_VALUE = 0;
+ const S_VALUE_OR_END = 1; // after '['
+ const S_KEY_OR_END = 2; // after '{'
+ const S_KEY = 3; // after ',' in an object
+ const S_COLON = 4;
+ const S_COMMA_OR_END = 5;
+ const S_DONE = 6;
+ const S_FAILED = 7;
+
+ const QUOTE = 0x22;
+ const BACKSLASH = 0x5C;
+
+ class Tokenizer {
+
+ var pos as Number = 0;
+ var depth as Number = 0;
+ var text as String = "";
+ var number as Long = 0l;
+ var numberIsInteger as Boolean = false;
+ var error as String = "";
+
+ private var _data as ByteArray;
+ private var _stack as ByteArray = []b; // 1 = object, 2 = array
+ private var _state as Number = S_VALUE;
+
+ function initialize(data as ByteArray) {
+ _data = data;
+ }
+
+ // Bytes of input.
+ function size() as Number {
+ return _data.size();
+ }
+
+ // The next token (T_*). After T_END, T_INCOMPLETE or T_ERROR every
+ // further call returns the same token.
+ function next() as Number {
+ while (true) {
+ if (_state == S_FAILED) {
+ return T_ERROR;
+ }
+ skipWhitespace();
+ if (pos >= _data.size()) {
+ return _state == S_DONE ? T_END : T_INCOMPLETE;
+ }
+ var b = _data[pos];
+ if (_state == S_DONE) {
+ return fail("unexpected data after the end of the JSON document");
+ }
+ if (_state == S_COLON) {
+ if (b != 0x3A) {
+ return fail("':' expected");
+ }
+ pos++;
+ _state = S_VALUE;
+ continue;
+ }
+ if (_state == S_COMMA_OR_END) {
+ var top = _stack[_stack.size() - 1];
+ if (b == 0x2C) {
+ pos++;
+ _state = top == 1 ? S_KEY : S_VALUE;
+ continue;
+ }
+ if ((top == 1 && b == 0x7D) || (top == 2 && b == 0x5D)) {
+ return close(top);
+ }
+ return fail(top == 1 ? "',' or '}' expected" : "',' or ']' expected");
+ }
+ if (_state == S_KEY_OR_END && b == 0x7D) {
+ return close(1);
+ }
+ if (_state == S_KEY_OR_END || _state == S_KEY) {
+ if (b != QUOTE) {
+ return fail("a quoted key expected");
+ }
+ var key = readString(false);
+ if (key != T_STRING) {
+ return key;
+ }
+ _state = S_COLON;
+ return T_KEY;
+ }
+ if (_state == S_VALUE_OR_END && b == 0x5D) {
+ return close(2);
+ }
+ return value(b);
+ }
+ return T_ERROR; // not reached
+ }
+
+ private function value(b as Number) as Number {
+ if (b == 0x7B || b == 0x5B) {
+ if (depth >= MAX_DEPTH) {
+ return fail("nested deeper than " + MAX_DEPTH + " levels");
+ }
+ pos++;
+ depth++;
+ _stack.add(b == 0x7B ? 1 : 2);
+ _state = b == 0x7B ? S_KEY_OR_END : S_VALUE_OR_END;
+ return b == 0x7B ? T_BEGIN_OBJECT : T_BEGIN_ARRAY;
+ }
+ var token = T_ERROR;
+ if (b == QUOTE) {
+ token = readString(true);
+ } else if (b == 0x74) {
+ token = literal("true", T_TRUE);
+ } else if (b == 0x66) {
+ token = literal("false", T_FALSE);
+ } else if (b == 0x6E) {
+ token = literal("null", T_NULL);
+ } else if (b == 0x2D || (b >= 0x30 && b <= 0x39)) {
+ token = readNumber();
+ } else {
+ return fail("a value expected");
+ }
+ if (token != T_INCOMPLETE && token != T_ERROR) {
+ _state = depth == 0 ? S_DONE : S_COMMA_OR_END;
+ }
+ return token;
+ }
+
+ private function close(kind as Number) as Number {
+ pos++;
+ depth--;
+ _stack = _stack.slice(0, _stack.size() - 1);
+ _state = depth == 0 ? S_DONE : S_COMMA_OR_END;
+ return kind == 1 ? T_END_OBJECT : T_END_ARRAY;
+ }
+
+ private function fail(message as String) as Number {
+ error = message + " at byte " + pos;
+ _state = S_FAILED;
+ return T_ERROR;
+ }
+
+ private function skipWhitespace() as Void {
+ var data = _data;
+ var n = data.size();
+ while (pos < n) {
+ var b = data[pos];
+ if (b != 0x20 && b != 0x0A && b != 0x0D && b != 0x09) {
+ return;
+ }
+ pos++;
+ }
+ }
+
+ private function literal(word as String, token as Number) as Number {
+ var bytes = Bytes.fromUtf8(word);
+ for (var i = 0; i < bytes.size(); i++) {
+ if (pos + i >= _data.size()) {
+ return T_INCOMPLETE;
+ }
+ if (_data[pos + i] != bytes[i]) {
+ return fail("invalid literal");
+ }
+ }
+ pos += bytes.size();
+ return token;
+ }
+
+ // -?(0|[1-9][0-9]*)(.[0-9]+)?([eE][+-]?[0-9]+)?
+ private function readNumber() as Number {
+ var data = _data;
+ var n = data.size();
+ var start = pos;
+ var i = pos;
+ var negative = false;
+ if (data[i] == 0x2D) {
+ negative = true;
+ i++;
+ }
+ var intStart = i;
+ var value = 0l;
+ var overflow = false;
+ while (i < n && data[i] >= 0x30 && data[i] <= 0x39) {
+ if (i - intStart >= 18) {
+ overflow = true;
+ } else {
+ value = value * 10 + (data[i] - 0x30);
+ }
+ i++;
+ }
+ if (i == intStart) {
+ return i >= n ? T_INCOMPLETE : fail("invalid number");
+ }
+ if (i - intStart > 1 && data[intStart] == 0x30) {
+ return fail("leading zero in a number");
+ }
+ var integer = !overflow;
+ if (i < n && data[i] == 0x2E) {
+ integer = false;
+ i++;
+ var fracStart = i;
+ while (i < n && data[i] >= 0x30 && data[i] <= 0x39) {
+ i++;
+ }
+ if (i == fracStart) {
+ return i >= n ? T_INCOMPLETE : fail("invalid number");
+ }
+ }
+ if (i < n && (data[i] == 0x65 || data[i] == 0x45)) {
+ integer = false;
+ i++;
+ if (i < n && (data[i] == 0x2B || data[i] == 0x2D)) {
+ i++;
+ }
+ var expStart = i;
+ while (i < n && data[i] >= 0x30 && data[i] <= 0x39) {
+ i++;
+ }
+ if (i == expStart) {
+ return i >= n ? T_INCOMPLETE : fail("invalid number");
+ }
+ }
+ if (i >= n && depth > 0) {
+ // The number may continue in the next part.
+ pos = start;
+ return T_INCOMPLETE;
+ }
+ pos = i;
+ numberIsInteger = integer;
+ number = negative ? -value : value;
+ return T_NUMBER;
+ }
+
+ // Reads a string starting at the opening quote. Values over
+ // MAX_STRING_BYTES are skipped (T_LONG_STRING); keys that long are an
+ // error. Returns T_STRING with `text` set, or another token.
+ private function readString(isValue as Boolean) as Number {
+ var data = _data;
+ var n = data.size();
+ var start = pos + 1;
+ // Find the closing quote within the window, with native searches.
+ var window = data.slice(start, start + STRING_WINDOW < n ? start + STRING_WINDOW : n);
+ var end = closingQuote(window);
+ if (end < 0) {
+ if (window.size() < STRING_WINDOW) {
+ return T_INCOMPLETE; // the input ends inside the string
+ }
+ return isValue ? skipLongString(start) : fail("key too long");
+ }
+ if (end > MAX_STRING_BYTES && window.slice(0, end).indexOf(BACKSLASH) < 0) {
+ // Without escapes the decoded text is as long as the input.
+ pos = start + end + 1;
+ return isValue ? T_LONG_STRING : fail("key too long");
+ }
+ var out = []b;
+ var i = 0;
+ while (i < end) {
+ var b = window[i];
+ if (b < 0x20) {
+ pos = start + i;
+ return fail("control character in a string");
+ }
+ if (b != BACKSLASH) {
+ out.add(b);
+ i++;
+ continue;
+ }
+ var e = window[i + 1];
+ if (e == 0x75) {
+ var consumed = unicodeEscape(window, i, end, out);
+ if (consumed < 0) {
+ pos = start + i;
+ return fail("invalid \\u escape");
+ }
+ i += consumed;
+ continue;
+ }
+ var mapped = escapeByte(e);
+ if (mapped < 0) {
+ pos = start + i;
+ return fail("invalid escape");
+ }
+ out.add(mapped);
+ i += 2;
+ }
+ if (out.size() > MAX_STRING_BYTES) {
+ pos = start + end + 1;
+ if (!isValue) {
+ return fail("key too long");
+ }
+ return T_LONG_STRING;
+ }
+ var decoded = Bytes.toUtf8(out);
+ if (decoded == null) {
+ pos = start;
+ return fail("invalid UTF-8 or NUL in a string");
+ }
+ text = decoded;
+ pos = start + end + 1;
+ return T_STRING;
+ }
+
+ // Index of the first unescaped quote in bytes, or -1. Quotes are
+ // found natively; one preceded by an odd number of backslashes is
+ // escaped.
+ private function closingQuote(bytes as ByteArray) as Number {
+ var offset = 0;
+ while (offset < bytes.size()) {
+ var rel = (offset == 0 ? bytes : bytes.slice(offset, null)).indexOf(QUOTE);
+ if (rel < 0) {
+ return -1;
+ }
+ var q = offset + rel;
+ var backslashes = 0;
+ while (q - 1 - backslashes >= 0 && bytes[q - 1 - backslashes] == BACKSLASH) {
+ backslashes++;
+ }
+ if (backslashes % 2 == 0) {
+ return q;
+ }
+ offset = q + 1;
+ }
+ return -1;
+ }
+
+ // Skips a long string value from `start` (just after its opening
+ // quote) using native searches; escaped quotes are recognised by the
+ // number of backslashes before them.
+ private function skipLongString(start as Number) as Number {
+ var data = _data;
+ var n = data.size();
+ var p = start;
+ while (p < n) {
+ var chunkEnd = p + SKIP_CHUNK < n ? p + SKIP_CHUNK : n;
+ var found = data.slice(p, chunkEnd).indexOf(QUOTE);
+ if (found < 0) {
+ p = chunkEnd;
+ continue;
+ }
+ var q = p + found;
+ var backslashes = 0;
+ while (q - 1 - backslashes >= start && data[q - 1 - backslashes] == BACKSLASH) {
+ backslashes++;
+ }
+ if (backslashes % 2 == 0) {
+ pos = q + 1;
+ return T_LONG_STRING;
+ }
+ p = q + 1;
+ }
+ return T_INCOMPLETE;
+ }
+
+ // The byte for a one-character escape, or -1.
+ private function escapeByte(e as Number) as Number {
+ if (e == QUOTE || e == BACKSLASH || e == 0x2F) {
+ return e;
+ }
+ if (e == 0x62) {
+ return 0x08;
+ }
+ if (e == 0x66) {
+ return 0x0C;
+ }
+ if (e == 0x6E) {
+ return 0x0A;
+ }
+ if (e == 0x72) {
+ return 0x0D;
+ }
+ if (e == 0x74) {
+ return 0x09;
+ }
+ return -1;
+ }
+
+ // Decodes \uXXXX (and a following low surrogate) at bytes[i] into
+ // UTF-8. Returns the number of input bytes used, or -1.
+ private function unicodeEscape(bytes as ByteArray, i as Number, end as Number, out as ByteArray) as Number {
+ var cp = hex4(bytes, i + 2, end);
+ if (cp < 0) {
+ return -1;
+ }
+ var used = 6;
+ if (cp >= 0xD800 && cp <= 0xDBFF) {
+ if (i + 11 >= end + 1 || bytes[i + 6] != BACKSLASH || bytes[i + 7] != 0x75) {
+ return -1;
+ }
+ var low = hex4(bytes, i + 8, end);
+ if (low < 0xDC00 || low > 0xDFFF) {
+ return -1;
+ }
+ cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
+ used = 12;
+ } else if (cp >= 0xDC00 && cp <= 0xDFFF) {
+ return -1;
+ }
+ if (cp < 0x80) {
+ out.add(cp);
+ } else if (cp < 0x800) {
+ out.add(0xC0 | (cp >> 6));
+ out.add(0x80 | (cp & 0x3F));
+ } else if (cp < 0x10000) {
+ out.add(0xE0 | (cp >> 12));
+ out.add(0x80 | ((cp >> 6) & 0x3F));
+ out.add(0x80 | (cp & 0x3F));
+ } else {
+ out.add(0xF0 | (cp >> 18));
+ out.add(0x80 | ((cp >> 12) & 0x3F));
+ out.add(0x80 | ((cp >> 6) & 0x3F));
+ out.add(0x80 | (cp & 0x3F));
+ }
+ return used;
+ }
+
+ // Four hex digits at bytes[at..at+3] (all before `end`), or -1.
+ private function hex4(bytes as ByteArray, at as Number, end as Number) as Number {
+ if (at + 4 > end) {
+ return -1;
+ }
+ var v = 0;
+ for (var k = 0; k < 4; k++) {
+ var c = bytes[at + k];
+ var d = -1;
+ if (c >= 0x30 && c <= 0x39) {
+ d = c - 0x30;
+ } else if (c >= 0x41 && c <= 0x46) {
+ d = c - 0x37;
+ } else if (c >= 0x61 && c <= 0x66) {
+ d = c - 0x57;
+ }
+ if (d < 0) {
+ return -1;
+ }
+ v = (v << 4) | d;
+ }
+ return v;
+ }
+ }
+}