aboutsummaryrefslogtreecommitdiff
path: root/source/import/ImportBuffer.mc
blob: 0aca54859592326868db7347e403199db1cdeb67 (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
// 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.<i>" 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<Application.Storage.KeyType, Application.Storage.ValueType>);
        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);
        }
    }
}