aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-27 01:08:30 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-27 01:08:30 +0100
commit86c11634379ab87238d794f8f9e8bbc991041e4d (patch)
tree4cf3943528c84205a85a33011d8361f17c2c7638
parentbc4cc19df5f53519ca1aabd3cf161b6d78ffc16c (diff)
fix(import): splitting pasted otpauth:// links runs no character loop
OtpAuth.splitUris/firstWord looped over every character in one event, so pasting about 100 links tripped the watchdog (the UI fixture imports Aegis JSON, so it never ran this path). They now use native searches only. New make watchdog case uris_100 (13,576 characters, twice per event) trips on the old code and passes now. fr70: make lint, make test, make watchdog pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md2
-rwxr-xr-xdocker/scripts/app-diag.sh2
-rw-r--r--docs/PERFORMANCE.md1
-rw-r--r--source/import/OtpAuth.mc29
-rw-r--r--source/util/StressTest.mc27
5 files changed, 49 insertions, 12 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42571b4..43fd4af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,8 @@ All notable changes to this project are documented here. The format follows
changes shown; later ones keep waiting.
- With storage full, every correct PIN stopped the app while promoting
pending entries; it now reports "Storage full" and continues.
+- Pasting about 100 `otpauth://` links tripped the watchdog: splitting the
+ text looped over every character in one event.
### Tests
diff --git a/docker/scripts/app-diag.sh b/docker/scripts/app-diag.sh
index 773fd51..f1e1fd5 100755
--- a/docker/scripts/app-diag.sh
+++ b/docker/scripts/app-diag.sh
@@ -24,7 +24,7 @@ ciq_require_key
# The stress test takes a few minutes per device (about 3,500 job steps at
# 50 ms on the 512 KB watches).
WATCHDOG_TIMEOUT_S=1200
-STRESS_ITEMS=19
+STRESS_ITEMS=20
if [[ "${kind}" == watchdog ]]; then
for device in "$@"; do
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index d261125..bf5d97b 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -57,6 +57,7 @@ What this means: unlocking stays at about 1 s because 2,000 iterations is also t
| Replacing a code with 4 KB of contents, queued / direct | not tested | passes, 735 / 727 steps |
| A 4 KB replacement queued by 0.9.0, read, kept at the PIN and confirmed | not tested | passes, 757 steps |
| A 1,024-character secret replacement / 4,096 spaces as code contents | not tested | passes, 10 / 12 steps |
+| Splitting 100 pasted `otpauth://` links (13,576 characters) | trips | passes, one event |
Not covered yet: opening a 4 KB code (`toUtf8` in one event, about 100,000 bytecodes) and the Add OTP form's secret decoding without a length limit; both are near the Forerunner 255S's limit, not the 70's.
diff --git a/source/import/OtpAuth.mc b/source/import/OtpAuth.mc
index cdf0f9c..f5c487b 100644
--- a/source/import/OtpAuth.mc
+++ b/source/import/OtpAuth.mc
@@ -21,28 +21,35 @@ module 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.
+ // one is ignored here; the importer reports it. Runs in one event, so
+ // it uses only native searches: a VM loop over every character of 100
+ // pasted links tripped the watchdog.
function splitUris(text as String) as Array<String> {
var uris = [] as Array<String>;
- 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));
+ var lower = text.toLower();
+ var at = lower.find(PREFIX);
+ while (at != null) {
+ var from = at + PREFIX.length();
+ var found = (lower.substring(from, lower.length()) as String).find(PREFIX);
+ var next = found != null ? from + found : null;
+ uris.add(firstWord(text.substring(at, next != null ? next : text.length()) as String));
at = next;
}
return uris;
}
// Text up to the first whitespace (a pasted URI never contains any).
+ // Native searches, no loop over the characters (see splitUris).
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;
+ var end = text.length();
+ var spaces = [" ", "\t", "\n", "\r"];
+ for (var i = 0; i < spaces.size(); i++) {
+ var found = text.find(spaces[i]);
+ if (found != null && found < end) {
+ end = found;
}
}
- return text;
+ return end == text.length() ? text : text.substring(0, end) as String;
}
// One URI as an import item (entry, problem or skip).
diff --git a/source/util/StressTest.mc b/source/util/StressTest.mc
index a9d9293..8956c4b 100644
--- a/source/util/StressTest.mc
+++ b/source/util/StressTest.mc
@@ -130,6 +130,10 @@ module StressTest {
// queued (ManageRun), then confirmed (ChangeQueueJob).
// * SPACES: an unlocked code entry and a REPLACE of `material` spaces
// (refused: "Contents: missing").
+ // * URIS: no vault. Setup builds 100 pasted otpauth:// links (about 150
+ // characters each) in steps of ten; the measured run is the
+ // Importer's text work in one event, done twice: OtpAuth.splitUris
+ // and the detect step's Text.trim + Text.findIgnoreCase.
class VaultCase extends Jobs.Job {
static const UNLOCK = 0;
static const COUNT = 1;
@@ -139,6 +143,8 @@ module StressTest {
static const LEGACY = 5;
static const SECRET = 6;
static const SPACES = 7;
+ static const URIS = 8;
+ static const LINKS = 100;
// Unlocked entries, one queued item each (ChangeQueue.MAX_ITEMS).
static const LOCKS = 16;
static const PIN = "1234";
@@ -167,6 +173,8 @@ module StressTest {
private var _done as Array<Boolean> = [] as Array<Boolean>;
// LEGACY: the keys each vault's menu read.
private var _keys as Array<Array<String>> = [] as Array<Array<String>>;
+ // URIS: the pasted text.
+ private var _text as String = "";
function initialize(label as String, type as Number, pendingCount as Number, materialBytes as Number) {
Job.initialize();
@@ -198,6 +206,16 @@ module StressTest {
// One bounded setup step; true when every vault is ready.
private function setup(budget as Number) as Boolean {
+ if (kind == URIS) {
+ var secret = repeat("ABCDEFGH", 4);
+ for (var i = 0; i < 10; i++) {
+ var n = (_n + 1).toString();
+ _text += "otpauth://totp/Issuer" + n + ":account" + n + "@example.com?secret=" + secret
+ + "&issuer=Issuer" + n + "&algorithm=SHA1&digits=6&period=30\n";
+ _n++;
+ }
+ return _n >= LINKS;
+ }
var v = _vaults.size() > 0 ? _vaults[_vaults.size() - 1] : null;
if (_phase == 0) {
v = new Vault(new RamBackend(), new TimeSource());
@@ -340,6 +358,14 @@ module StressTest {
// The measured run.
private function measure(budget as Number) as Boolean {
+ if (kind == URIS) {
+ for (var i = 0; i < MARGIN; i++) {
+ var trimmed = Text.trim(_text);
+ var first = Text.findIgnoreCase(trimmed, OtpAuth.MIGRATION_PREFIX, 0) + Text.findIgnoreCase(trimmed, OtpAuth.PREFIX, 0);
+ result = " uris=" + OtpAuth.splitUris(trimmed).size() + " chars=" + _text.length() + " first=" + first;
+ }
+ return true;
+ }
if (kind == COUNT) {
for (var i = 0; i < MARGIN; i++) {
ChangeQueue.changed();
@@ -508,6 +534,7 @@ module StressTest {
new VaultCase("queue_legacy_4k", VaultCase.LEGACY, 0, Entry.MAX_PAYLOAD_BYTES),
new VaultCase("replace_secret_1k", VaultCase.SECRET, 0, 0),
new VaultCase("replace_spaces_4k", VaultCase.SPACES, 0, Entry.MAX_PAYLOAD_BYTES),
+ new VaultCase("uris_100", VaultCase.URIS, 0, 0),
// Last, so it stays on the screen: app-diag.sh decodes it.
new Item("qr_fit", Codes.QR, new Qr.QrJob(SCREEN_TEXT, 1, largestQrVersion(w, h, round), -1))
] as Array<Item or VaultCase>;