From 40d9a1d5c3e85486aea964c1446402a3be6ec05a Mon Sep 17 00:00:00 2001 From: cirakg Date: Tue, 20 Oct 2026 01:51:05 +0200 Subject: 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 --- source/import/Aegis.mc | 276 ++++++++++++++++++++++++ source/import/ImportBuffer.mc | 90 ++++++++ source/import/ImportItem.mc | 148 +++++++++++++ source/import/ImportReport.mc | 71 ++++++ source/import/Importer.mc | 296 +++++++++++++++++++++++++ source/import/Json.mc | 486 ++++++++++++++++++++++++++++++++++++++++++ source/import/OtpAuth.mc | 186 ++++++++++++++++ source/util/Text.mc | 56 +++++ 8 files changed, 1609 insertions(+) create mode 100644 source/import/Aegis.mc create mode 100644 source/import/ImportBuffer.mc create mode 100644 source/import/ImportItem.mc create mode 100644 source/import/ImportReport.mc create mode 100644 source/import/Importer.mc create mode 100644 source/import/Json.mc create mode 100644 source/import/OtpAuth.mc create mode 100644 source/util/Text.mc (limited to 'source') diff --git a/source/import/Aegis.mc b/source/import/Aegis.mc new file mode 100644 index 0000000..05905e9 --- /dev/null +++ b/source/import/Aegis.mc @@ -0,0 +1,276 @@ +// Author: cirakg & claude ai +// Summary: Reads unencrypted Aegis JSON exports (vault version 1, database 1-3) into import items, a few tokens per step. +// +// README 11.3. The reader consumes the token stream of Json.Tokenizer and +// tracks the path of the current value ("/db/entries/[]/info/secret"), so it +// can stop after any token and continue in the next event. It keeps only +// the parsed items and the group names, never the document. +// +// * Encrypted exports are refused: header.slots is not null, or db is a +// string. +// * totp, hotp and steam entries are imported; motp, yandex and unknown +// types are skipped and reported. +// * Used: name (the account), issuer, favorite, info.secret/algo/digits/ +// period/counter, and the group: the older "group" string, or the first +// UUID of "groups" resolved through db.groups (database version 3, which +// may list the groups after the entries, so names are resolved at the +// end). +// * Ignored: icons (long strings are skipped unread), notes, UUIDs. +// * A field that appears twice in one entry makes that entry an error, so +// an ambiguous file can't smuggle in a second secret. + +import Toybox.Lang; + +class AegisReader { + + static const RUNNING = 0; + static const DONE = 1; + static const INCOMPLETE = 2; + static const FAILED = 3; + + // Tokens per step; with Json's per-token limit this bounds the work of + // one event. + static const TOKENS_PER_STEP = 40; + + static const ENCRYPTED_MESSAGE = "This Aegis export is encrypted. In Aegis choose Export, then turn encryption off, and paste that file."; + + var items as Array = [] as Array; + var error as String? = null; + + private var _tok as Json.Tokenizer; + private var _path as Array = [] as Array; + private var _key as String? = null; + private var _item as ImportItem? = null; + private var _fields as ImportFields? = null; + private var _seen as Dictionary = {} as Dictionary; + private var _group as Dictionary? = null; + private var _groups as Dictionary = {} as Dictionary; + private var _encrypted as Boolean = false; + private var _vaultVersion as Long = -1l; + private var _sawEntries as Boolean = false; + + function initialize(data as ByteArray) { + _tok = new Json.Tokenizer(data); + } + + // Bytes read so far (for progress). + function position() as Number { + return _tok.pos; + } + + // Process up to TOKENS_PER_STEP tokens. Returns RUNNING, DONE, + // INCOMPLETE (the text ends early: wait for more) or FAILED (error set). + function step() as Number { + for (var n = 0; n < TOKENS_PER_STEP; n++) { + var token = _tok.next(); + if (token == Json.T_END) { + return finish(); + } + if (token == Json.T_INCOMPLETE) { + return _encrypted ? fail(ENCRYPTED_MESSAGE) : INCOMPLETE; + } + if (token == Json.T_ERROR) { + return fail("not valid JSON: " + _tok.error); + } + if (token == Json.T_KEY) { + _key = _tok.text; + continue; + } + var name = nameOfValue(); + if (token == Json.T_BEGIN_OBJECT || token == Json.T_BEGIN_ARRAY) { + begin(name, token == Json.T_BEGIN_OBJECT); + _path.add(name); + } else if (token == Json.T_END_OBJECT || token == Json.T_END_ARRAY) { + var closed = _path[_path.size() - 1]; + _path = _path.slice(0, _path.size() - 1) as Array; + end(closed); + } else { + value(name, token); + } + _key = null; + } + return RUNNING; + } + + // The name of the value about to be read: its key in an object, "[]" + // in an array, "" at the top. + private function nameOfValue() as String { + var key = _key; + if (key != null) { + return key; + } + return _path.size() > 0 ? "[]" : ""; + } + + private function pathOf(name as String) as String { + var p = ""; + for (var i = 1; i < _path.size(); i++) { + p += "/" + _path[i]; + } + return _path.size() == 0 ? name : p + "/" + name; + } + + private function begin(name as String, isObject as Boolean) as Void { + var p = pathOf(name); + if (p.equals("/header/slots")) { + _encrypted = true; + } else if (p.equals("/db") && !isObject) { + _encrypted = true; + } else if (p.equals("/db/entries")) { + _sawEntries = true; + } else if (p.equals("/db/entries/[]") && isObject) { + _item = new ImportItem(items.size() + 1); + _fields = new ImportFields(); + _seen = {} as Dictionary; + } else if (p.equals("/db/groups/[]") && isObject) { + _group = {} as Dictionary; + } + } + + private function end(closed as String) as Void { + var p = pathOf(closed); + if (p.equals("/db/entries/[]")) { + var item = _item; + var fields = _fields; + if (item != null && fields != null) { + if (item.problem == null) { + fields.toEntry(item); + } else { + item.label = fields.issuer.length() > 0 ? fields.issuer : fields.account; + } + fields.secret = null; + items.add(item); + } + _item = null; + _fields = null; + } else if (p.equals("/db/groups/[]")) { + var g = _group; + if (g != null && g.hasKey("uuid") && g.hasKey("name")) { + _groups[g["uuid"] as String] = g["name"] as String; + } + _group = null; + } + } + + private function value(name as String, token as Number) as Void { + var p = pathOf(name); + if (p.equals("/version")) { + _vaultVersion = token == Json.T_NUMBER && _tok.numberIsInteger ? _tok.number : -2l; + } else if (p.equals("/header/slots")) { + if (token != Json.T_NULL) { + _encrypted = true; + } + } else if (p.equals("/db")) { + // An encrypted export stores db as a Base64 string. + _encrypted = true; + } else if (p.find("/db/entries/[]/") == 0) { + entryValue(p.substring(15, p.length()) as String, token); + } else if (p.find("/db/groups/[]/") == 0) { + var g = _group; + if (g != null && token == Json.T_STRING) { + g[p.substring(14, p.length()) as String] = _tok.text; + } + } + } + + // A value inside an entry; field is "issuer", "info/secret", "groups/[]". + private function entryValue(field as String, token as Number) as Void { + var item = _item; + var fields = _fields; + if (item == null || fields == null) { + return; + } + if (field.equals("groups/[]")) { + if (item.groupRef == null && token == Json.T_STRING) { + item.groupRef = "uuid:" + _tok.text; + } + return; + } + var used = ["type", "name", "issuer", "favorite", "group", "info/secret", "info/algo", "info/digits", "info/period", "info/counter"]; + if (used.indexOf(field) < 0) { + return; // icons, notes, uuid, anything new + } + if (_seen.hasKey(field)) { + item.problem = "field '" + field + "' appears twice"; + return; + } + _seen[field] = true; + if (field.equals("favorite")) { + fields.favorite = token == Json.T_TRUE; + return; + } + if (field.equals("info/digits") || field.equals("info/period") || field.equals("info/counter")) { + if (token != Json.T_NUMBER || !_tok.numberIsInteger) { + item.problem = field.substring(5, field.length()) + " is not a whole number"; + return; + } + if (field.equals("info/digits")) { + fields.digits = _tok.number; + } else if (field.equals("info/period")) { + fields.period = _tok.number; + } else { + fields.counter = _tok.number; + } + return; + } + if (token == Json.T_NULL && (field.equals("group") || field.equals("issuer"))) { + return; + } + if (token != Json.T_STRING) { + item.problem = "field '" + field + "' is not text" + (token == Json.T_LONG_STRING ? " (too long)" : ""); + return; + } + var text = _tok.text; + if (field.equals("type")) { + fields.type = text; + } else if (field.equals("name")) { + fields.account = Text.trim(text); + } else if (field.equals("issuer")) { + fields.issuer = Text.trim(text); + } else if (field.equals("group")) { + item.groupRef = "name:" + text; + } else if (field.equals("info/secret")) { + fields.secret = text; + } else if (field.equals("info/algo")) { + fields.algorithm = text; + } + } + + private function finish() as Number { + if (_encrypted) { + return fail(ENCRYPTED_MESSAGE); + } + if (_vaultVersion != 1 || !_sawEntries) { + return fail(_vaultVersion > 1 ? "Aegis export version " + _vaultVersion + " is not supported" + : "not an Aegis export (no version 1 vault with db.entries)"); + } + // Resolve group references now that db.groups has been read. + for (var i = 0; i < items.size(); i++) { + var item = items[i]; + var ref = item.groupRef; + var e = item.entry; + if (ref == null || e == null) { + continue; + } + var name = ref.find("uuid:") == 0 ? _groups[ref.substring(5, ref.length()) as String] : ref.substring(5, ref.length()); + if (name != null) { + e.group = fitBytes(Text.trim(name as String), Entry.MAX_GROUP_BYTES); + } + } + return DONE; + } + + // Text shortened (whole characters) to at most maxBytes UTF-8 bytes. + static function fitBytes(text as String, maxBytes as Number) as String { + var t = text; + while (Bytes.fromUtf8(t).size() > maxBytes) { + t = t.substring(0, t.length() - 1) as String; + } + return t; + } + + private function fail(message as String) as Number { + error = message; + return FAILED; + } +} diff --git a/source/import/ImportBuffer.mc b/source/import/ImportBuffer.mc new file mode 100644 index 0000000..0aca548 --- /dev/null +++ b/source/import/ImportBuffer.mc @@ -0,0 +1,90 @@ +// Author: cirakg & claude ai +// Summary: Keeps the text of an unfinished multi-part import between syncs, sealed with the device key, capped and expiring. +// +// README 11.2: when pasted JSON is still incomplete, the watch keeps what it +// got and waits for the next part. The text is sealed (SealedBox, device +// key, aad "OTPW-IMP-1"), because it holds secrets, and split into chunks +// below Application.Storage's 8 KB per value: +// "imp" {"n" => chunk count, "t" => unix time saved} +// "imp." chunk i of the sealed box +// Chunks are written before "imp", and "imp" is deleted first, so a torn +// write leaves no readable half-buffer. Buffers older than MAX_AGE_S are +// discarded. + +import Toybox.Application; +import Toybox.Lang; + +module ImportBuffer { + + const META_KEY = "imp"; + const CHUNK_BYTES = 7000; + const MAX_TEXT_BYTES = 32768; + const MAX_AGE_S = 86400; + const AAD = "OTPW-IMP-1"; + + // The waiting text, or "" when there is none (or it expired or is damaged, + // in which case it is deleted). + function load(store as Persist.Backend, keys as SealedBox.Keys, now as Long) as String { + var meta = store.getValue(META_KEY); + if (!(meta instanceof Dictionary)) { + return ""; + } + var n = meta["n"]; + var t = meta["t"]; + if (!(n instanceof Number) || !(t instanceof Long) || n < 1 || n > 16 || now - t > MAX_AGE_S || now < t) { + clear(store); + return ""; + } + var box = []b; + for (var i = 0; i < n; i++) { + var chunk = store.getValue(META_KEY + "." + i); + if (!(chunk instanceof ByteArray)) { + clear(store); + return ""; + } + box.addAll(chunk); + } + var plain = SealedBox.open(keys, Bytes.fromUtf8(AAD), box); + var text = plain != null ? Bytes.toUtf8(plain) : null; + Bytes.wipe(plain); + if (text == null) { + clear(store); + return ""; + } + return text; + } + + // Save text as the waiting import. Returns false (and saves nothing) + // when it is over MAX_TEXT_BYTES. + function save(store as Persist.Backend, keys as SealedBox.Keys, text as String, now as Long) as Boolean { + var plain = Bytes.fromUtf8(text); + if (plain.size() > MAX_TEXT_BYTES) { + Bytes.wipe(plain); + return false; + } + clear(store); + var box = SealedBox.seal(keys, Bytes.fromUtf8(AAD), plain); + Bytes.wipe(plain); + var n = 0; + for (var at = 0; at < box.size(); at += CHUNK_BYTES) { + store.setValue(META_KEY + "." + n, box.slice(at, at + CHUNK_BYTES < box.size() ? at + CHUNK_BYTES : box.size())); + n++; + } + store.setValue(META_KEY, {"n" => n, "t" => now} as Dictionary); + return true; + } + + function hasPending(store as Persist.Backend) as Boolean { + return store.getValue(META_KEY) != null; + } + + // Delete the waiting import (after it completed, failed or was cancelled). + function clear(store as Persist.Backend) as Void { + var meta = store.getValue(META_KEY); + store.deleteValue(META_KEY); + var n = meta instanceof Dictionary && meta["n"] instanceof Number ? meta["n"] as Number : 16; + for (var i = 0; i < n && i < 16; i++) { + store.deleteValue(META_KEY + "." + i); + } + } +} diff --git a/source/import/ImportItem.mc b/source/import/ImportItem.mc new file mode 100644 index 0000000..a242aa0 --- /dev/null +++ b/source/import/ImportItem.mc @@ -0,0 +1,148 @@ +// Author: cirakg & claude ai +// Summary: One parsed import candidate (an entry, an error or a skip) and the shared field validation of both formats. +// +// Both parsers (otpauth URIs and Aegis JSON) turn text fields into an Entry +// through ImportFields.toEntry, so the same rules (README 10.4) and the same +// messages apply to both. Messages name the field and the problem, never the +// secret itself. + +import Toybox.Lang; + +class ImportItem { + + // 1-based position in the import (URI number or Aegis entry number). + var index as Number; + // A short name for messages: the issuer, else the account, else "". + var label as String = ""; + var entry as Entry? = null; + // Why the item can't be imported (shown as an error), or null. + var problem as String? = null; + // Why the item was deliberately skipped (unsupported type), or null. + var skipped as String? = null; + // A remark that doesn't stop the import (issuer mismatch). + var note as String? = null; + // Aegis groups: a group UUID (database version 3) or a name, resolved by + // the reader once the whole document is read. + var groupRef as String? = null; + + function initialize(position as Number) { + index = position; + } + + // "entry 7 'GitHub'" or "URI 3", for messages. + function describe(kind as String) as String { + return label.length() > 0 ? kind + " " + index + " '" + label + "'" : kind + " " + index; + } +} + +// The raw fields one source item provides, as text, and their conversion. +class ImportFields { + + var type as String = "totp"; + var issuer as String = ""; + var account as String = ""; + var secret as String? = null; + var algorithm as String = "SHA1"; + var digits as Long = 6l; + var period as Long = 30l; + var counter as Long? = null; + var favorite as Boolean = false; + + function initialize() { + } + + // Fill item.entry, or set item.problem / item.skipped. + function toEntry(item as ImportItem) as Void { + item.label = issuer.length() > 0 ? issuer : account; + var t = type.toLower(); + var otpType = -1; + if (t.equals("totp")) { + otpType = Entry.OTP_TOTP; + } else if (t.equals("hotp")) { + otpType = Entry.OTP_HOTP; + } else if (t.equals("steam")) { + otpType = Entry.OTP_STEAM; + } else if (t.equals("motp")) { + item.skipped = "mOTP is not supported"; + return; + } else if (t.equals("yandex")) { + item.skipped = "Yandex is not supported"; + return; + } else { + item.skipped = "type '" + Text.clip(t, 12) + "' is not supported"; + return; + } + if (issuer.length() == 0 && account.length() == 0) { + item.problem = "needs an issuer or an account name"; + return; + } + if (Bytes.fromUtf8(issuer).size() > Entry.MAX_NAME_BYTES || Bytes.fromUtf8(account).size() > Entry.MAX_NAME_BYTES) { + item.problem = "issuer or account longer than " + Entry.MAX_NAME_BYTES + " bytes"; + return; + } + var e = new Entry(); + e.kind = Entry.KIND_OTP; + e.type = otpType; + e.name = issuer; + e.account = account; + e.source = Entry.SOURCE_IMPORT; + e.favorite = favorite; + if (otpType == Entry.OTP_STEAM) { + // Steam always uses SHA-1, 30 s and 5 characters. + e.algorithm = Hashes.SHA1; + e.digits = Otp.MIN_DIGITS; + e.period = Otp.STEAM_PERIOD; + } else { + var a = algorithm.toUpper(); + if (a.equals("SHA1")) { + e.algorithm = Hashes.SHA1; + } else if (a.equals("SHA256")) { + e.algorithm = Hashes.SHA256; + } else if (a.equals("SHA512")) { + e.algorithm = Hashes.SHA512; + } else { + item.problem = "algorithm '" + Text.clip(algorithm, 10) + "' is not supported (SHA1, SHA256, SHA512)"; + return; + } + if (digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) { + item.problem = digits + " digits not supported (6-8)"; + return; + } + e.digits = digits.toNumber(); + if (period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) { + item.problem = "period " + period + " s not supported (1-300)"; + return; + } + e.period = period.toNumber(); + } + if (otpType == Entry.OTP_HOTP) { + var c = counter; + if (c == null) { + item.problem = "HOTP needs a counter"; + return; + } + if (c < 0) { + item.problem = "counter can't be negative"; + return; + } + e.counter = c; + } + var s = secret; + if (s == null || s.length() == 0) { + item.problem = "secret missing"; + return; + } + var decoded = Base32.decode(s); + if (decoded instanceof Base32.DecodeError) { + item.problem = "secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : ""); + return; + } + if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) { + item.problem = "secret must be 1 to " + Entry.MAX_SECRET_BYTES + " bytes"; + Bytes.wipe(decoded); + return; + } + e.secret = decoded; + item.entry = e; + } +} diff --git a/source/import/ImportReport.mc b/source/import/ImportReport.mc new file mode 100644 index 0000000..b2a986a --- /dev/null +++ b/source/import/ImportReport.mc @@ -0,0 +1,71 @@ +// Author: cirakg & claude ai +// Summary: The outcome of one import: counts, per-item errors and skips, notes, and the texts for the status field and results screen. + +import Toybox.Lang; + +class ImportReport { + + // At most this many detail lines are kept (the results screen scrolls). + static const MAX_DETAILS = 40; + + var imported as Number = 0; + var pendingLocked as Number = 0; + var duplicates as Number = 0; + var errors as Number = 0; + var skipped as Number = 0; + var removed as Number = 0; + // Set when the whole import failed (not JSON, encrypted, storage full + // before anything was stored...). + var failure as String? = null; + // Set while a multi-part import waits for its next part. + var waitingChars as Number = -1; + var details as Array = [] as Array; + + function initialize() { + } + + function addDetail(line as String) as Void { + if (details.size() < MAX_DETAILS) { + details.add(line); + } else if (details.size() == MAX_DETAILS) { + details.add("…"); + } + } + + // One line for the phone's Import status field, for example + // "Imported 12 · 2 duplicates skipped · 1 error · 1 skipped". + function summary() as String { + var f = failure; + if (f != null) { + return "Not imported: " + f; + } + if (waitingChars >= 0) { + return "Waiting for more: received " + waitingChars + + " characters. Paste the next part into the import fields and turn 'Import now' on again."; + } + var text = "Imported " + imported; + if (pendingLocked > 0) { + text += " (" + pendingLocked + " locked, waiting for your PIN)"; + } + if (removed > 0) { + text += " · " + removed + " old imported removed"; + } + if (duplicates > 0) { + text += " · " + duplicates + (duplicates == 1 ? " duplicate skipped" : " duplicates skipped"); + } + if (errors > 0) { + text += " · " + errors + (errors == 1 ? " error" : " errors"); + } + if (skipped > 0) { + text += " · " + skipped + " skipped"; + } + return text; + } + + // The summary followed by the details, for the results screen. + function lines() as Array { + var out = [summary()] as Array; + out.addAll(details); + return out; + } +} diff --git a/source/import/Importer.mc b/source/import/Importer.mc new file mode 100644 index 0000000..9513358 --- /dev/null +++ b/source/import/Importer.mc @@ -0,0 +1,296 @@ +// Author: cirakg & claude ai +// Summary: Runs an import as a resumable job: detect the format, parse, replace old imports, find duplicates, store entry by entry. +// +// README 11. Phases, each done in small steps (one timer event each): +// PARSE otpauth URIs (one per step) or Aegis JSON (Json/Aegis token +// steps). Incomplete JSON is kept in the ImportBuffer and the +// import ends with "waiting for more". +// REPLACE in Replace mode, delete earlier imported entries (locked ones +// only while unlocked; others are kept and reported). +// INDEX read every header once (a few per step) into a fingerprint +// index (duplicates) and a name index (possible replacements). +// STORE one item per step: skip, report, or add to the vault. A crash +// leaves only whole entries. +// The report is complete when step() returns true. + +import Toybox.Lang; + +class Importer extends Jobs.Job { + + static const MODE_APPEND = 0; + static const MODE_REPLACE = 1; + static const MODE_CANCEL = 2; + + static const P_PARSE = 0; + static const P_REPLACE = 1; + static const P_INDEX = 2; + static const P_STORE = 3; + static const P_DONE = 4; + + static const HEADERS_PER_STEP = 4; + static const URIS_PER_STEP = 1; + + var report as ImportReport = new ImportReport(); + var phase as Number = P_PARSE; + + private var _vault as Vault; + private var _mode as Number; + private var _locked as Boolean; + private var _now as Long; + private var _text as String = ""; + private var _uris as Array? = null; + private var _aegis as AegisReader? = null; + private var _items as Array = [] as Array; + private var _cursor as Number = 0; + private var _ids as Array = [] as Array; + private var _fingerprints as Dictionary = {} as Dictionary; + private var _names as Dictionary = {} as Dictionary; + // How items are named in messages: "URI 3" or "entry 7 'GitHub'". + private var _kind as String = "entry"; + + // text: the joined import fields. A waiting earlier part is prepended. + function initialize(vault as Vault, text as String, mode as Number, locked as Boolean, now as Long) { + Job.initialize(); + _vault = vault; + _mode = mode; + _locked = locked; + _now = now; + var keys = vault.keyManager.deviceKeys(); + var earlier = ImportBuffer.load(vault.storage(), keys, now); + _text = earlier + text; + detect(); + } + + // Progress for the UI, 0-100. + function percent() as Number { + if (phase == P_PARSE) { + var a = _aegis; + var size = _text.length() > 0 ? _text.length() : 1; + if (a != null) { + return 40 * a.position() / (size > 0 ? size : 1); + } + var u = _uris; + return u != null && u.size() > 0 ? 40 * _cursor / u.size() : 0; + } + if (phase == P_STORE) { + return _items.size() > 0 ? 50 + 50 * _cursor / _items.size() : 100; + } + return phase == P_DONE ? 100 : 45; + } + + private function detect() as Void { + var trimmed = Text.trim(_text); + if (trimmed.length() == 0) { + finishWith("the import fields are empty"); + return; + } + if (trimmed.find("{") == 0) { + _aegis = new AegisReader(Bytes.fromUtf8(trimmed)); + return; + } + if (Text.findIgnoreCase(trimmed, OtpAuth.MIGRATION_PREFIX, 0) >= 0) { + finishWith("Google Authenticator exports are not supported yet. Export from Aegis, or paste otpauth:// links"); + return; + } + var first = Text.findIgnoreCase(trimmed, OtpAuth.PREFIX, 0); + if (first < 0) { + finishWith("not recognised. Paste otpauth:// links or an unencrypted Aegis JSON export"); + return; + } + if (first > 0) { + report.addDetail("Ignored " + first + " characters before the first otpauth:// link"); + } + _uris = OtpAuth.splitUris(trimmed); + _kind = "URI"; + _text = ""; + } + + private function finishWith(failure as String) as Void { + report.failure = failure; + ImportBuffer.clear(_vault.storage()); + phase = P_DONE; + } + + function step(budget as Number) as Boolean { + Diag.sampleMemory(); + if (phase == P_PARSE) { + parseStep(); + } else if (phase == P_REPLACE) { + replaceStep(); + } else if (phase == P_INDEX) { + indexStep(); + } else if (phase == P_STORE) { + storeStep(); + } + return phase == P_DONE; + } + + private function parseStep() as Void { + var aegis = _aegis; + if (aegis != null) { + var status = aegis.step(); + if (status == AegisReader.RUNNING) { + return; + } + if (status == AegisReader.INCOMPLETE) { + var size = Bytes.fromUtf8(_text).size(); + if (ImportBuffer.save(_vault.storage(), _vault.keyManager.deviceKeys(), _text, _now)) { + report.waitingChars = _text.length(); + phase = P_DONE; + } else { + finishWith("the JSON is incomplete and too long to wait for more (" + size + " bytes, at most " + + ImportBuffer.MAX_TEXT_BYTES + ")"); + } + _text = ""; + return; + } + if (status == AegisReader.FAILED) { + finishWith(aegis.error as String); + _text = ""; + return; + } + _items = aegis.items; + _aegis = null; + _text = ""; + startReplace(); + return; + } + var uris = _uris as Array; + for (var n = 0; n < URIS_PER_STEP && _cursor < uris.size(); n++) { + _items.add(OtpAuth.parse(uris[_cursor], _cursor + 1)); + _cursor++; + } + if (_cursor >= uris.size()) { + _uris = null; + startReplace(); + } + } + + private function startReplace() as Void { + ImportBuffer.clear(_vault.storage()); + _ids = _vault.ids(); + _ids.addAll(_vault.pendingIds()); + _cursor = 0; + phase = _mode == MODE_REPLACE ? P_REPLACE : P_INDEX; + } + + // Replace mode: delete one earlier imported entry per step. + private function replaceStep() as Void { + var kept = 0; + while (_cursor < _ids.size()) { + var id = _ids[_cursor]; + _cursor++; + var header = _vault.anyHeader(id); + if (header == null || header.source != Entry.SOURCE_IMPORT) { + continue; + } + if (_vault.pendingIds().indexOf(id) >= 0 || (header.locked && !_vault.session.isUnlocked())) { + kept++; + report.addDetail("Kept locked imported entry " + id + ": unlock first to replace it"); + continue; + } + try { + _vault.remove(id); + report.removed++; + } catch (e instanceof VaultError) { + report.addDetail("Could not remove " + id); + } + return; // one deletion per step + } + _ids = _vault.ids(); + _ids.addAll(_vault.pendingIds()); + _cursor = 0; + phase = P_INDEX; + } + + // Read a few headers into the duplicate indexes. + private function indexStep() as Void { + for (var n = 0; n < HEADERS_PER_STEP && _cursor < _ids.size(); n++) { + var id = _ids[_cursor]; + _cursor++; + var header = _vault.anyHeader(id); + if (header != null) { + remember(header, id); + } + } + if (_cursor >= _ids.size()) { + _cursor = 0; + phase = P_STORE; + } + } + + private function remember(header as Entry, id as String) as Void { + var fp = header.fingerprint; + if (fp != null) { + _fingerprints[Bytes.toHex(fp)] = id; + } + if (header.kind == Entry.KIND_OTP && (header.name.length() > 0 || header.account.length() > 0)) { + _names[nameKey(header)] = id; + } + } + + private function nameKey(e as Entry) as String { + return e.name.toLower() + "\n" + e.account.toLower(); + } + + // Store (or report) one item. + private function storeStep() as Void { + if (_cursor >= _items.size()) { + phase = P_DONE; + return; + } + var item = _items[_cursor]; + _items[_cursor] = new ImportItem(item.index); // drop the parsed secret + _cursor++; + var kind = _kind; + var note = item.note; + if (note != null) { + report.addDetail(item.describe(kind) + ": " + note); + } + var skipped = item.skipped; + if (skipped != null) { + report.skipped++; + report.addDetail(item.describe(kind) + ": skipped, " + skipped); + return; + } + var problem = item.problem; + var e = item.entry; + if (problem != null || e == null) { + report.errors++; + report.addDetail(item.describe(kind) + ": " + (problem != null ? problem : "invalid")); + return; + } + var fp = Bytes.toHex(_vault.keyManager.fingerprint(e)); + var existing = _fingerprints[fp]; + if (existing != null) { + report.duplicates++; + report.addDetail(item.describe(kind) + ": duplicate of " + existing); + e.wipeSecrets(); + return; + } + var sameName = _names[nameKey(e)]; + e.locked = _locked; + e.created = _now; + try { + var id = _vault.add(e); + report.imported++; + if (_vault.pendingIds().indexOf(id) >= 0) { + report.pendingLocked++; + } + if (sameName != null) { + report.addDetail(item.describe(kind) + ": added as " + id + ", possible replacement for " + sameName + + ". Delete the old one with Manage entry"); + } + remember(e, id); + } catch (err instanceof VaultError) { + report.errors++; + report.addDetail(item.describe(kind) + ": not stored (" + err.getErrorMessage() + ")"); + if (err.code == VaultError.FULL || err.code == VaultError.LIMIT) { + report.addDetail("Stopped: " + (_items.size() - _cursor) + " more entries not imported"); + report.errors += _items.size() - _cursor; + phase = P_DONE; + } + } + e.wipeSecrets(); + } +} 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; + } + } +} diff --git a/source/import/OtpAuth.mc b/source/import/OtpAuth.mc new file mode 100644 index 0000000..cdf0f9c --- /dev/null +++ b/source/import/OtpAuth.mc @@ -0,0 +1,186 @@ +// Author: cirakg & claude ai +// Summary: Parses otpauth:// URIs (totp, hotp, steam) from pasted text into import items (README 11.3). +// +// Rules: +// * Text may hold many URIs, separated by spaces, line breaks or nothing: +// a new URI starts at every "otpauth://" (any case). +// * The label is percent-decoded and split at the first ':' into issuer +// and account (spaces trimmed). An issuer parameter wins over the label; +// a mismatch becomes a note. +// * Parameters are separated by '&' or ';' (the Garmin Connect iPhone app +// may reject '&'), keys are case-insensitive, values are percent-decoded +// with '+' meaning a space. Unknown parameters are ignored. +// * "steam" as the type, or encoder=steam, means Steam. +// * Everything is validated by ImportFields.toEntry. + +import Toybox.Lang; + +module OtpAuth { + + const PREFIX = "otpauth://"; + const MIGRATION_PREFIX = "otpauth-migration://"; + + // The URIs in text, split at every "otpauth://". Text before the first + // one is ignored here; the importer reports it. + function splitUris(text as String) as Array { + var uris = [] as Array; + var at = Text.findIgnoreCase(text, PREFIX, 0); + while (at >= 0) { + var next = Text.findIgnoreCase(text, PREFIX, at + PREFIX.length()); + var end = next >= 0 ? next : text.length(); + uris.add(firstWord(text.substring(at, end) as String)); + at = next; + } + return uris; + } + + // Text up to the first whitespace (a pasted URI never contains any). + function firstWord(text as String) as String { + var chars = text.toCharArray(); + for (var i = 0; i < chars.size(); i++) { + if (Text.isSpace(chars[i])) { + return text.substring(0, i) as String; + } + } + return text; + } + + // One URI as an import item (entry, problem or skip). + function parse(uri as String, index as Number) as ImportItem { + var item = new ImportItem(index); + var fields = new ImportFields(); + var rest = uri.substring(PREFIX.length(), uri.length()) as String; + var slash = rest.find("/"); + if (slash == null) { + item.problem = "no '/' after the type"; + return item; + } + fields.type = (rest.substring(0, slash) as String).toLower(); + rest = rest.substring(slash + 1, rest.length()) as String; + var question = rest.find("?"); + var labelText = question == null ? rest : rest.substring(0, question) as String; + var query = question == null ? "" : rest.substring(question + 1, rest.length()) as String; + + var label = percentDecode(labelText, false); + if (label == null) { + item.problem = "the label has an invalid % escape"; + return item; + } + var colon = label.find(":"); + var labelIssuer = ""; + if (colon != null) { + labelIssuer = Text.trim(label.substring(0, colon) as String); + fields.account = Text.trim(label.substring(colon + 1, label.length()) as String); + } else { + fields.account = Text.trim(label); + } + fields.issuer = labelIssuer; + + var issuerParam = null; + var encoder = ""; + var pairs = splitParams(query); + for (var i = 0; i < pairs.size(); i++) { + var pair = pairs[i]; + if (pair.length() == 0) { + continue; + } + var eq = pair.find("="); + var key = (eq == null ? pair : pair.substring(0, eq) as String).toLower(); + var value = percentDecode(eq == null ? "" : pair.substring(eq + 1, pair.length()) as String, true); + if (value == null) { + item.problem = "parameter '" + Text.clip(key, 12) + "' has an invalid % escape"; + return item; + } + if (key.equals("secret")) { + fields.secret = value; + } else if (key.equals("issuer")) { + issuerParam = Text.trim(value); + } else if (key.equals("algorithm")) { + fields.algorithm = value; + } else if (key.equals("digits") || key.equals("period") || key.equals("counter")) { + var number = Text.parseLong(value); + if (number == null) { + item.problem = key + "=" + Text.clip(value, 10) + " is not a whole number"; + return item; + } + if (key.equals("digits")) { + fields.digits = number; + } else if (key.equals("period")) { + fields.period = number; + } else { + fields.counter = number; + } + } else if (key.equals("encoder")) { + encoder = value.toLower(); + } + } + if (issuerParam != null && issuerParam.length() > 0) { + if (labelIssuer.length() > 0 && !labelIssuer.equals(issuerParam)) { + item.note = "issuer '" + Text.clip(issuerParam, 20) + "' differs from the label's '" + Text.clip(labelIssuer, 20) + "'"; + } + fields.issuer = issuerParam; + } + if (encoder.equals("steam")) { + fields.type = "steam"; + } + fields.toEntry(item); + return item; + } + + // Parameters separated by '&' or ';'. + function splitParams(query as String) as Array { + var parts = [] as Array; + var chars = query.toCharArray(); + var start = 0; + for (var i = 0; i <= chars.size(); i++) { + if (i == chars.size() || chars[i] == '&' || chars[i] == ';') { + parts.add(query.substring(start, i) as String); + start = i + 1; + } + } + return parts; + } + + // Percent-decoding to UTF-8 text; '+' becomes a space when plusIsSpace. + // Null for a bad escape or invalid UTF-8. + function percentDecode(text as String, plusIsSpace as Boolean) as String? { + if (text.find("%") == null && (!plusIsSpace || text.find("+") == null)) { + return text; + } + var bytes = Bytes.fromUtf8(text); + var out = []b; + var i = 0; + while (i < bytes.size()) { + var b = bytes[i]; + if (b == 0x25) { + if (i + 2 >= bytes.size()) { + return null; + } + var hi = hexValue(bytes[i + 1]); + var lo = hexValue(bytes[i + 2]); + if (hi < 0 || lo < 0) { + return null; + } + out.add(hi * 16 + lo); + i += 3; + } else { + out.add(plusIsSpace && b == 0x2B ? 0x20 : b); + i++; + } + } + return Bytes.toUtf8(out); + } + + function hexValue(c as Number) as Number { + if (c >= 0x30 && c <= 0x39) { + return c - 0x30; + } + if (c >= 0x41 && c <= 0x46) { + return c - 0x37; + } + if (c >= 0x61 && c <= 0x66) { + return c - 0x57; + } + return -1; + } +} diff --git a/source/util/Text.mc b/source/util/Text.mc new file mode 100644 index 0000000..9af7b32 --- /dev/null +++ b/source/util/Text.mc @@ -0,0 +1,56 @@ +// Author: cirakg & claude ai +// Summary: Small string helpers for parsing: trimming, clipping for messages, integer parsing, case-insensitive search. + +import Toybox.Lang; + +module Text { + + // Text without leading and trailing spaces, tabs and line breaks. + function trim(text as String) as String { + var chars = text.toCharArray(); + var start = 0; + var end = chars.size(); + while (start < end && isSpace(chars[start])) { + start++; + } + while (end > start && isSpace(chars[end - 1])) { + end--; + } + return start == 0 && end == chars.size() ? text : text.substring(start, end) as String; + } + + function isSpace(c as Char) as Boolean { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; + } + + // At most maxChars characters, with "…" when shortened (for messages that + // quote user input). + function clip(text as String, maxChars as Number) as String { + return text.length() <= maxChars ? text : (text.substring(0, maxChars) as String) + "…"; + } + + // A non-negative decimal integer (at most 18 digits), or null. + function parseLong(text as String) as Long? { + var chars = text.toCharArray(); + if (chars.size() == 0 || chars.size() > 18) { + return null; + } + var value = 0l; + for (var i = 0; i < chars.size(); i++) { + var c = chars[i]; + if (c < '0' || c > '9') { + return null; + } + value = value * 10 + (c.toNumber() - 48); + } + return value; + } + + // Index of needle in haystack ignoring ASCII case, from `from`, or -1. + function findIgnoreCase(haystack as String, needle as String, from as Number) as Number { + var lower = haystack.toLower(); + var rest = from == 0 ? lower : lower.substring(from, lower.length()) as String; + var at = rest.find(needle.toLower()); + return at == null ? -1 : at + from; + } +} -- cgit v1.2.3