summaryrefslogtreecommitdiff
path: root/source/settings/Forms.mc
blob: d26cb7abd2e0c31c5184800a4e03bc8c50f50407 (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
// Author:  cirakg & claude ai
// Summary: Shared pieces of the settings forms: where field values come from, typed reads, text checks, the applied-forms log.
//
// The processor never calls Application.Properties directly: it goes through
// a Forms.Store, so the unit tests can run every form against a dictionary
// (MemoryStore) while the app uses the real properties (PropertyStore).
//
// Values from the phone are untrusted: a field can be missing (an app update
// added it but the phone hasn't synced), of an unexpected type, or out of
// range. The typed reads below never throw; they return a default or null
// and the form validation turns that into a message.

import Toybox.Application;
import Toybox.Lang;

module Forms {

    // Form hashes remembered, most recent last (README 10.3, rule 3).
    const LOG_KEY = "fh";
    const LOG_SIZE = 32;
    // Status fields hold at most this many characters.
    const STATUS_MAX_CHARS = 200;
    // Field separator inside the hashed form content (not typeable on the phone).
    const SEPARATOR = 0x1F;

    // Where field values come from and go to.
    class Store {
        function initialize() {
        }

        // The value, or null when it doesn't exist.
        function get(key as String) as Application.Properties.ValueType or Null {
            return null;
        }

        function set(key as String, value as Application.Properties.ValueType) as Void {
        }
    }

    // The app's properties (resources/settings/properties.xml). Reading a
    // property that isn't defined there stops the app with an error that
    // can't be caught, so only defined keys may be passed in.
    class PropertyStore extends Store {
        function initialize() {
            Store.initialize();
        }

        function get(key as String) as Application.Properties.ValueType or Null {
            try {
                return Application.Properties.getValue(key);
            } catch (e instanceof Lang.Exception) {
                return null;
            }
        }

        function set(key as String, value as Application.Properties.ValueType) as Void {
            try {
                Application.Properties.setValue(key, value);
            } catch (e instanceof Lang.Exception) {
                // Nothing to clear or report into.
            }
        }
    }

    // A dictionary of fields, for tests (and nothing else).
    class MemoryStore extends Store {
        var values as Dictionary<String, Application.Properties.ValueType> = {} as Dictionary<String, Application.Properties.ValueType>;

        function initialize() {
            Store.initialize();
        }

        function get(key as String) as Application.Properties.ValueType or Null {
            return values[key];
        }

        function set(key as String, value as Application.Properties.ValueType) as Void {
            values[key] = value;
        }
    }

    // ---- Typed reads ----------------------------------------------------

    // A text field, trimmed; "" when missing or not text.
    function text(store as Store, key as String) as String {
        var v = store.get(key);
        return v instanceof String ? Text.trim(v) : "";
    }

    // A text field exactly as sent (secrets, barcode contents).
    function rawText(store as Store, key as String) as String {
        var v = store.get(key);
        return v instanceof String ? v : "";
    }

    function flag(store as Store, key as String) as Boolean {
        var v = store.get(key);
        return v instanceof Boolean && v;
    }

    // A whole number (list index, numeric field), or null. Longs that fit,
    // whole floats and decimal text are accepted too: the phone's numeric
    // fields don't promise a type.
    function number(store as Store, key as String) as Number? {
        var v = long(store, key);
        return v != null && v >= -2147483648l && v <= 2147483647l ? v.toNumber() : null;
    }

    // A whole number as a Long, or null (also null for negative text).
    function long(store as Store, key as String) as Long? {
        var v = store.get(key);
        if (v instanceof Number || v instanceof Long) {
            return v.toLong();
        }
        if (v instanceof Float || v instanceof Double) {
            var whole = v.toLong();
            return whole.toDouble() == v.toDouble() ? whole : null;
        }
        if (v instanceof String) {
            var t = Text.trim(v);
            if (t.find("-") == 0) {
                var magnitude = Text.parseLong(t.substring(1, t.length()) as String);
                return magnitude != null ? -magnitude : null;
            }
            return Text.parseLong(t);
        }
        return null;
    }

    // ---- Text checks ----------------------------------------------------

    // The 1-based position of the first control character, or 0.
    function controlCharAt(value as String) as Number {
        var chars = value.toCharArray();
        for (var i = 0; i < chars.size(); i++) {
            var c = chars[i].toNumber();
            if (c < 32 || c == 127) {
                return i + 1;
            }
        }
        return 0;
    }

    // null when a name-like field is acceptable, else the message.
    function checkName(label as String, value as String, maxBytes as Number) as String? {
        var at = controlCharAt(value);
        if (at > 0) {
            return label + ": control character at position " + at;
        }
        var size = Bytes.fromUtf8(value).size();
        if (size > maxBytes) {
            return label + ": too long (" + size + " bytes, at most " + maxBytes + ")";
        }
        return null;
    }

    // "GitHub (alice)", "GitHub", "alice" or "" for messages.
    function label(name as String, account as String) as String {
        if (name.length() > 0 && account.length() > 0) {
            return name + " (" + account + ")";
        }
        return name.length() > 0 ? name : account;
    }

    // What reading a form gave: an entry to add, or the first problem, plus
    // remarks that don't stop it (a short secret, a check digit added).
    class Parsed {
        var entry as Entry? = null;
        var error as String? = null;
        var notes as Array<String> = [] as Array<String>;
        var content as Content;

        function initialize(formName as String) {
            content = new Content(formName);
        }

        function fail(message as String) as Parsed {
            error = message;
            var e = entry;
            if (e != null) {
                e.wipeSecrets();
                entry = null;
            }
            return self;
        }
    }

    // ---- Content hash and the applied-forms log --------------------------

    // Collects the content of a form for its hash: every field in a fixed
    // order, each as its own UTF-8 bytes (converted natively, never copied
    // into one buffer: an import form holds kilobytes). The parts hold
    // secrets: hash() wipes them, or call wipe().
    class Content {
        var parts as Array<ByteArray> = [] as Array<ByteArray>;

        function initialize(formName as String) {
            parts.add(Bytes.fromUtf8(formName));
        }

        function add(value as Application.Properties.ValueType or Null) as Void {
            if (value instanceof String) {
                parts.add(Bytes.fromUtf8(value));
            } else {
                parts.add(value != null ? Bytes.fromUtf8(value.toString()) : []b);
            }
        }

        // Zero the short parts (fields, secrets) and drop all of them.
        // Long parts (import text) are dropped unwiped: zeroing kilobytes
        // byte by byte would trip the watchdog, and the same text stays in
        // memory as the property's String anyway (README 7.8 limits).
        function wipe() as Void {
            for (var i = 0; i < parts.size(); i++) {
                if (parts[i].size() <= WIPE_MAX_BYTES) {
                    Bytes.wipe(parts[i]);
                }
            }
            parts = [] as Array<ByteArray>;
        }
    }

    const WIPE_MAX_BYTES = 512;

    // Keyed hash of a form's content (KeyManager.formHash), wiping the content.
    function hash(vault as Vault, content as Content) as String {
        var h = vault.keyManager.formHash(content.parts, SEPARATOR);
        content.wipe();
        return h;
    }

    function alreadyApplied(vault as Vault, hash as String) as Boolean {
        var log = vault.storage().getValue(LOG_KEY);
        return log instanceof Array && log.indexOf(hash) >= 0;
    }

    function rememberApplied(vault as Vault, hash as String) as Void {
        var stored = vault.storage().getValue(LOG_KEY);
        var log = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
        log.add(hash);
        if (log.size() > LOG_SIZE) {
            log = log.slice(log.size() - LOG_SIZE, null) as Array<Application.Storage.ValueType>;
        }
        try {
            vault.storage().setValue(LOG_KEY, log);
        } catch (e instanceof Lang.Exception) {
            // A full store: the form may be applied again if re-sent.
        }
    }

    // Write a form's status field (shortened to fit).
    function status(store as Store, key as String, message as String) as Void {
        store.set(key, Text.clip(message, STATUS_MAX_CHARS));
    }
}