diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-21 03:55:01 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-21 03:55:01 +0200 |
| commit | a69e23fb5266dd942d125239e7dcbe8a7c8ab956 (patch) | |
| tree | 164c7ed21b550ac3d5fef567060ffad4e40e4583 /source/import/ImportItem.mc | |
| parent | 6039571d70b1f6de1ae384e58e11879869be356c (diff) | |
perf(import): keep parsed items packed and the pasted text once
A parsed import item with its Entry took about 760 bytes as objects on the
Forerunner 255S; packed into one Codec ByteArray it takes about 110, and
the importer unpacks one item per store step (wiping the packed secret).
Aegis group names are resolved when unpacking, so no step repacks all
items. The JSON is parsed from the untrimmed text's bytes and the string
is dropped; an incomplete part is saved from those bytes. The form hash
is computed before the importer copies the text.
The peak of a 100-entry import on the 255S falls from 56 % to 45 %.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/import/ImportItem.mc')
| -rw-r--r-- | source/import/ImportItem.mc | 102 |
1 files changed, 101 insertions, 1 deletions
diff --git a/source/import/ImportItem.mc b/source/import/ImportItem.mc index a242aa0..cc772af 100644 --- a/source/import/ImportItem.mc +++ b/source/import/ImportItem.mc @@ -1,10 +1,17 @@ // Author: cirakg & claude ai -// Summary: One parsed import candidate (an entry, an error or a skip) and the shared field validation of both formats. +// Summary: One parsed import candidate (an entry, an error or a skip), its packed form, 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. +// +// Memory: an item with its Entry takes about 760 bytes as objects (measured +// on the Forerunner 255S in M8), so 150 parsed items would take 110 KB. The +// parsers keep each finished item packed into one ByteArray (Codec TLV, +// about 110 bytes) instead, and the importer unpacks one item per step. +// The packed form holds the secret in clear, like the Entry it replaces; +// the importer wipes it as soon as the item is unpacked. import Toybox.Lang; @@ -25,10 +32,103 @@ class ImportItem { // the reader once the whole document is read. var groupRef as String? = null; + // Packed field tags. + private static const P_INDEX = 1; + private static const P_LABEL = 2; + private static const P_PROBLEM = 3; + private static const P_SKIPPED = 4; + private static const P_NOTE = 5; + private static const P_GROUP_REF = 6; + private static const P_TYPE = 10; + private static const P_NAME = 11; + private static const P_ACCOUNT = 12; + private static const P_FAVORITE = 13; + private static const P_ALGORITHM = 14; + private static const P_DIGITS = 15; + private static const P_PERIOD = 16; + private static const P_COUNTER = 17; + private static const P_SECRET = 18; + function initialize(position as Number) { index = position; } + // This item as one ByteArray. Wipes the entry's secret: the packed copy + // is the only one left. + function pack() as ByteArray { + var w = new Codec.Writer(); + w.addInt(P_INDEX, index); + w.addText(P_LABEL, label); + addText(w, P_PROBLEM, problem); + addText(w, P_SKIPPED, skipped); + addText(w, P_NOTE, note); + addText(w, P_GROUP_REF, groupRef); + var e = entry; + if (e != null && e.secret != null) { + w.addInt(P_TYPE, e.type); + w.addText(P_NAME, e.name); + w.addText(P_ACCOUNT, e.account); + w.addBool(P_FAVORITE, e.favorite); + w.addInt(P_ALGORITHM, e.algorithm); + w.addInt(P_DIGITS, e.digits); + w.addInt(P_PERIOD, e.period); + w.addInt(P_COUNTER, e.counter); + w.addBytes(P_SECRET, e.secret as ByteArray); + e.wipeSecrets(); + } + return w.bytes; + } + + private static function addText(w as Codec.Writer, tag as Number, value as String?) as Void { + if (value != null) { + w.addText(tag, value); + } + } + + // The item packed by pack(), with its group resolved: groupRef is + // "name:<group>" or "uuid:<id>" looked up in groups (Aegis db.groups). + // The caller wipes `packed`. + static function unpack(packed as ByteArray, groups as Dictionary<String, String>) as ImportItem { + var map = Codec.decode(packed); + var item = new ImportItem(0); + if (map == null) { + item.problem = "invalid"; // only if memory was corrupted + return item; + } + item.index = (map[P_INDEX] as Long).toNumber(); + item.label = map[P_LABEL] as String; + item.problem = map[P_PROBLEM] as String?; + item.skipped = map[P_SKIPPED] as String?; + item.note = map[P_NOTE] as String?; + var secret = map[P_SECRET]; + if (secret instanceof ByteArray) { + var e = new Entry(); + e.kind = Entry.KIND_OTP; + e.source = Entry.SOURCE_IMPORT; + e.type = (map[P_TYPE] as Long).toNumber(); + e.name = map[P_NAME] as String; + e.account = map[P_ACCOUNT] as String; + e.favorite = map[P_FAVORITE] as Boolean; + e.algorithm = (map[P_ALGORITHM] as Long).toNumber() as Hashes.Algorithm; + e.digits = (map[P_DIGITS] as Long).toNumber(); + e.period = (map[P_PERIOD] as Long).toNumber(); + e.counter = map[P_COUNTER] as Long; + e.secret = secret; + e.group = groupName(map[P_GROUP_REF] as String?, groups); + item.entry = e; + } + return item; + } + + private static function groupName(ref as String?, groups as Dictionary<String, String>) as String { + if (ref == null) { + return ""; + } + var key = ref.substring(5, ref.length()) as String; + var name = ref.find("uuid:") == 0 ? groups[key] : key; + return name != null ? AegisReader.fitBytes(Text.trim(name), Entry.MAX_GROUP_BYTES) : ""; + } + // "entry 7 'GitHub'" or "URI 3", for messages. function describe(kind as String) as String { return label.length() > 0 ? kind + " " + index + " '" + label + "'" : kind + " " + index; |
