fix(privacy): what the hostile instance found (ADR 0019)
Each was found by the hostile-instance test (next commit) and has a test of its own: - a write decides on its key from the records and the tree read anew: a studyset missing from a tree read before, or moved since, looked readable; - each studyset on the way up decides on its own first: its own record, private or made readable (a readable studyset the instance moved under a private one was taken as private, so a private card moved in and was opened with the other tree), or the key its own row was seen sealed with, learned from that row before a write (a topic the instance moved to the top level was written readable on a device that never read it); one sealed under a key not here refuses the write (a device that never saw a studyset private, its key record withheld, wrote into it readable); - settings keep the newest versions seen: a row older than any of them, or than another read with it, isn't read, so withholding a newer row and handing back an older one rolls nothing back; Count me folds off where any row says off; - the upkeep reads only readable tags and settings, and a PDF read by its id never refuses as a write would; - two queries passed nullable ids to `id IN`, which Serverpod refuses at run time: a path's step revisions and answers couldn't be read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
bb44a7d65d
commit
f658cb13da
6 changed files with 423 additions and 117 deletions
|
|
@ -158,9 +158,80 @@ class Privacy(
|
|||
/// readable one, or a studyset that isn't there.
|
||||
@override
|
||||
Future<String?> keyOfStudyset(String studysetId) async {
|
||||
// The records and the tree a sync brought are known before any key is
|
||||
// decided (the final reading of 22 Oct): a studyset missing from a tree
|
||||
// read before, or moved since, would look readable.
|
||||
await keys.refresh();
|
||||
await _idle(studysetId);
|
||||
_refuseWhileDamaged();
|
||||
return treeKeyIn(await _structure(), studysetId);
|
||||
forgetTrees();
|
||||
final parents = await _structure();
|
||||
final locked = await _learnStudysets(parents, studysetId);
|
||||
// A studyset on the way up sealed under a key that isn't here is
|
||||
// private, whatever the records withheld say: nothing is written into
|
||||
// it readable (the final reading of 22 Oct).
|
||||
final seen = <String>{};
|
||||
for (
|
||||
String? current = studysetId;
|
||||
current != null && seen.add(current);
|
||||
current = parents[current]
|
||||
) {
|
||||
if (_decided(current) case (:final keyId)) {
|
||||
return keyId;
|
||||
}
|
||||
if (locked.contains(current)) {
|
||||
throw const PrivacyRefused(PrivacyRefusal.keysMissing);
|
||||
}
|
||||
}
|
||||
return treeKeyIn(parents, studysetId);
|
||||
}
|
||||
|
||||
/// Learns the keys of [studysetId] and the studysets above it from their
|
||||
/// own rows, where nothing is known of them yet (the final reading of 22
|
||||
/// Oct): `parentId` is the instance's to change, so a topic it moves to
|
||||
/// the top level must still be known private by its own row, sealed and
|
||||
/// bound to it, before a write decides on a key. Returns those whose row
|
||||
/// is sealed under a key not open here.
|
||||
Future<Set<String>> _learnStudysets(
|
||||
Map<String, String?> parents,
|
||||
String studysetId,
|
||||
) async {
|
||||
final unknown = <String>{};
|
||||
final seen = <String>{};
|
||||
for (
|
||||
String? current = studysetId;
|
||||
current != null && seen.add(current);
|
||||
current = parents[current]
|
||||
) {
|
||||
if (ledger.factOf(current) == null &&
|
||||
ledger.keyOfItem('studyset:$current') == null) {
|
||||
unknown.add(current);
|
||||
}
|
||||
}
|
||||
if (unknown.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
final locked = <String>{};
|
||||
for (final row in await protocol.Studyset.db.find(
|
||||
_db,
|
||||
where: (t) =>
|
||||
t.id.inSet(<protocol.UuidValue>{
|
||||
for (final id in unknown) _uuid(id),
|
||||
}) &
|
||||
t.keyId.notEquals(null),
|
||||
)) {
|
||||
if (!keys.canOpen(row.keyId!)) {
|
||||
locked.add(row.id!.uuid);
|
||||
continue;
|
||||
}
|
||||
_learnItem('studyset:${row.id!.uuid}', 'studyset', '', [
|
||||
(
|
||||
id: row.id!.uuid,
|
||||
e: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
),
|
||||
]);
|
||||
}
|
||||
return locked;
|
||||
}
|
||||
|
||||
/// [keyOfStudyset] for something about to be written into
|
||||
|
|
@ -226,7 +297,6 @@ class Privacy(
|
|||
/// In a cycle the studysets below it come first, then its members by id,
|
||||
/// so every member finds the same.
|
||||
String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
|
||||
String? keyOf(String id) => _privateKeyOf(id) ?? _itemKey('studyset:$id');
|
||||
final path = <String>[];
|
||||
final seen = <String, int>{};
|
||||
String? current = studysetId;
|
||||
|
|
@ -235,7 +305,7 @@ class Privacy(
|
|||
final below = path.sublist(0, seen[current]);
|
||||
final cycle = path.sublist(seen[current]!)..sort();
|
||||
for (final id in [...below, ...cycle]) {
|
||||
if (keyOf(id) case final keyId?) {
|
||||
if (_decided(id) case (:final keyId)) {
|
||||
return keyId;
|
||||
}
|
||||
}
|
||||
|
|
@ -243,7 +313,7 @@ class Privacy(
|
|||
}
|
||||
seen[current] = path.length;
|
||||
path.add(current);
|
||||
if (keyOf(current) case final keyId?) {
|
||||
if (_decided(current) case (:final keyId)) {
|
||||
return keyId;
|
||||
}
|
||||
if (!parents.containsKey(current)) {
|
||||
|
|
@ -258,6 +328,22 @@ class Privacy(
|
|||
/// where it was made readable, or nothing says.
|
||||
String? _privateKeyOf(String studysetId) => ledger.factOf(studysetId)?.keyId;
|
||||
|
||||
/// What decides [studysetId]'s privacy on its own, before any studyset
|
||||
/// above it: the latest signed record about it (private with its key, or
|
||||
/// made readable: `keyId` null), else the key its own row was last seen
|
||||
/// sealed with; null where neither does, and its tree's decides. A
|
||||
/// studyset made readable by its own record stays readable wherever the
|
||||
/// instance moves it (the final reading of 22 Oct).
|
||||
({String? keyId})? _decided(String studysetId) {
|
||||
if (ledger.factOf(studysetId) case final fact?) {
|
||||
return (keyId: fact.keyId);
|
||||
}
|
||||
if (_itemKey('studyset:$studysetId') case final keyId?) {
|
||||
return (keyId: keyId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The key [item] was last seen sealed with, unless a later signed record
|
||||
/// made the tree of that key readable, which releases it.
|
||||
String? _itemKey(String item) {
|
||||
|
|
@ -485,6 +571,7 @@ class Privacy(
|
|||
/// [PrivacyRefused] on a device without the keys of an account that has
|
||||
/// them.
|
||||
Future<String?> keyOfNewTopLevel(String studysetId) async {
|
||||
await keys.refresh();
|
||||
if (keys.canEncrypt) {
|
||||
return await keys.newStudysetKey(studysetId);
|
||||
}
|
||||
|
|
@ -499,6 +586,7 @@ class Privacy(
|
|||
/// [PrivacyRefused] on a device without the keys of an account that has
|
||||
/// them: tags and settings are read-only there.
|
||||
Future<String?> personalKey() async {
|
||||
await keys.refresh();
|
||||
if (keys.canEncrypt) {
|
||||
return await keys.personalKey();
|
||||
}
|
||||
|
|
@ -513,28 +601,57 @@ class Privacy(
|
|||
/// payload (the short reading of 22 Oct).
|
||||
static final _versionMark = utf8.encode('HLKV');
|
||||
|
||||
/// The sealed settings row [rowId]'s [payload] (opened) without its
|
||||
/// version vector; null for one that isn't read (the final reading of 22
|
||||
/// Oct):
|
||||
/// The sealed settings [rows] (opened, by their ids) without their
|
||||
/// version vectors, leaving out those that aren't read (the final reading
|
||||
/// of 22 Oct):
|
||||
/// - a row this device folded away, whatever is handed back;
|
||||
/// - a rollback of a row seen before: every count at most what was seen
|
||||
/// of it, and one lower;
|
||||
/// - a row never seen here that brings no news: every count at most what
|
||||
/// was seen of the person's settings as a whole, so a row folded away on
|
||||
/// another device can't come back;
|
||||
/// - a row older than any version this device has seen of the person's
|
||||
/// settings, or than another row read with it (its own earlier one, a
|
||||
/// row written over, or one folded away elsewhere), so withholding a
|
||||
/// newer row and handing back an older one rolls nothing back;
|
||||
/// - one without a vector (sealed before settings had them, or under the
|
||||
/// short reading's counter, `HLKS`): dropped, as nothing sealed so was
|
||||
/// released.
|
||||
///
|
||||
/// A row with news from any device is taken, so two devices' changes at
|
||||
/// once both count. A blank one is as it is.
|
||||
ByteData? settingsWithoutCounter(String rowId, ByteData payload) {
|
||||
if (payload.lengthInBytes == 0) {
|
||||
return payload;
|
||||
Map<String, ByteData> settingsWithoutCounters(Map<String, ByteData> rows) {
|
||||
final read = <String, ({Map<String, int> vector, ByteData payload})>{};
|
||||
final blank = <String, ByteData>{};
|
||||
for (final MapEntry(key: rowId, value: payload) in rows.entries) {
|
||||
if (payload.lengthInBytes == 0) {
|
||||
blank[rowId] = payload;
|
||||
} else if (!ledger.foldedSettings(rowId)) {
|
||||
if (_versioned(payload) case final versioned?) {
|
||||
read[rowId] = versioned;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ledger.foldedSettings(rowId)) {
|
||||
return null;
|
||||
final newest = [
|
||||
...ledger.settingsFrontier.values,
|
||||
for (final row in read.values) row.vector,
|
||||
];
|
||||
final kept = {
|
||||
for (final MapEntry(key: rowId, value: row) in read.entries)
|
||||
if (!newest.any(
|
||||
(seen) => PrivacyLedger.isOlderVersion(row.vector, than: seen),
|
||||
))
|
||||
rowId: row,
|
||||
};
|
||||
for (final MapEntry(key: rowId, value: row) in kept.entries) {
|
||||
ledger.sawSettingsVersion(rowId, row.vector);
|
||||
}
|
||||
return {
|
||||
...blank,
|
||||
for (final MapEntry(key: rowId, value: row) in kept.entries)
|
||||
rowId: row.payload,
|
||||
};
|
||||
}
|
||||
|
||||
/// [payload]'s version vector and the payload after it; null without one.
|
||||
({Map<String, int> vector, ByteData payload})? _versioned(
|
||||
ByteData payload,
|
||||
) {
|
||||
final bytes = Uint8List.sublistView(payload);
|
||||
final mark = _versionMark.length;
|
||||
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
|
||||
|
|
@ -544,48 +661,39 @@ class Privacy(
|
|||
if (bytes.length < mark + 2 + length) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, int> vector;
|
||||
try {
|
||||
final json = jsonDecode(
|
||||
utf8.decode(Uint8List.sublistView(bytes, mark + 2, mark + 2 + length)),
|
||||
);
|
||||
vector = {
|
||||
if (json case final Map<String, Object?> map)
|
||||
for (final MapEntry(:key, :value) in map.entries)
|
||||
if (value case final int count) key: count,
|
||||
};
|
||||
return (
|
||||
vector: {
|
||||
if (json case final Map<String, Object?> map)
|
||||
for (final MapEntry(:key, :value) in map.entries)
|
||||
if (value case final int count) key: count,
|
||||
},
|
||||
payload: ByteData.sublistView(bytes, mark + 2 + length),
|
||||
);
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
if (ledger.sawSettingsRow(rowId)) {
|
||||
final seen = ledger.settingsVersionOf(rowId);
|
||||
final devices = {...seen.keys, ...vector.keys};
|
||||
final older =
|
||||
devices.every((d) => (vector[d] ?? 0) <= (seen[d] ?? 0)) &&
|
||||
devices.any((d) => (vector[d] ?? 0) < (seen[d] ?? 0));
|
||||
if (older) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
final person = ledger.settingsPersonVersion;
|
||||
final news = vector.entries.any((e) => e.value > (person[e.key] ?? 0));
|
||||
if (!news) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
ledger.sawSettingsVersion(rowId, vector);
|
||||
return ByteData.sublistView(bytes, mark + 2 + length);
|
||||
}
|
||||
|
||||
/// The settings row [rowId]'s [payload] with this device's next version in
|
||||
/// front, to be sealed: everything seen of the person's settings, and one
|
||||
/// more write of this device's, so it dominates what it replaces.
|
||||
/// front, to be sealed: newer than every version seen, so it stands for
|
||||
/// all it folds and writes over.
|
||||
ByteData settingsWithCounter(String rowId, ByteData payload) {
|
||||
if (payload.lengthInBytes == 0) {
|
||||
return payload;
|
||||
}
|
||||
final device = ledger.deviceId;
|
||||
final vector = {...ledger.settingsPersonVersion};
|
||||
final vector = <String, int>{};
|
||||
for (final seen in ledger.settingsFrontier.values) {
|
||||
for (final MapEntry(key: d, value: count) in seen.entries) {
|
||||
if (count > (vector[d] ?? 0)) {
|
||||
vector[d] = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
vector[device] = (vector[device] ?? 0) + 1;
|
||||
ledger.sawSettingsVersion(rowId, vector);
|
||||
final json = utf8.encode(jsonEncode(vector));
|
||||
|
|
@ -700,7 +808,9 @@ class Privacy(
|
|||
_db,
|
||||
where: stepIds == null
|
||||
? null
|
||||
: (t) => t.id.inSet({for (final id in stepIds) _uuid(id)}),
|
||||
: (t) => t.id.inSet(<protocol.UuidValue>{
|
||||
for (final id in stepIds) _uuid(id),
|
||||
}),
|
||||
))
|
||||
step.id!.uuid: step.pathId.uuid,
|
||||
};
|
||||
|
|
@ -1017,7 +1127,8 @@ class Privacy(
|
|||
}
|
||||
|
||||
/// [studysetId]'s tree without the studysets inside it that a signed
|
||||
/// record makes private on their own: those keep their keys.
|
||||
/// record of their own makes private or readable: those keep theirs (the
|
||||
/// final reading of 22 Oct).
|
||||
Future<Set<String>> scopeOf(String studysetId) async {
|
||||
final parents = await _structure();
|
||||
final children = <String, List<String>>{};
|
||||
|
|
@ -1030,7 +1141,7 @@ class Privacy(
|
|||
final queue = [studysetId];
|
||||
while (queue.isNotEmpty) {
|
||||
final id = queue.removeLast();
|
||||
if (id != studysetId && _privateKeyOf(id) != null) {
|
||||
if (id != studysetId && ledger.factOf(id) != null) {
|
||||
continue;
|
||||
}
|
||||
if (scope.add(id)) {
|
||||
|
|
@ -2055,8 +2166,13 @@ class Privacy(
|
|||
return;
|
||||
}
|
||||
var wrote = false;
|
||||
for (final tag in await protocol.Tag.db.find(_db)) {
|
||||
if (tag.keyId != null || tag.payload.lengthInBytes == 0) {
|
||||
// Only the readable ones are read: the upkeep after every merge costs
|
||||
// what the merge brought.
|
||||
for (final tag in await protocol.Tag.db.find(
|
||||
_db,
|
||||
where: (t) => t.keyId.equals(null),
|
||||
)) {
|
||||
if (tag.payload.lengthInBytes == 0) {
|
||||
continue;
|
||||
}
|
||||
final opened = open(
|
||||
|
|
@ -2082,8 +2198,11 @@ class Privacy(
|
|||
);
|
||||
}
|
||||
}
|
||||
for (final settings in await protocol.PersonSettings.db.find(_db)) {
|
||||
if (settings.keyId != null || settings.payload.lengthInBytes == 0) {
|
||||
for (final settings in await protocol.PersonSettings.db.find(
|
||||
_db,
|
||||
where: (t) => t.keyId.equals(null),
|
||||
)) {
|
||||
if (settings.payload.lengthInBytes == 0) {
|
||||
continue;
|
||||
}
|
||||
final opened = open(
|
||||
|
|
|
|||
|
|
@ -93,8 +93,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
|||
final _items = <String, String>{};
|
||||
final _pending = <PendingRewrite>[];
|
||||
var _personalSealed = false;
|
||||
final _settingsVersions = <String, Map<String, int>>{};
|
||||
final _settingsPerson = <String, int>{};
|
||||
final _settingsFrontier = <String, Map<String, int>>{};
|
||||
final _foldedSettings = <String>{};
|
||||
String? _deviceId;
|
||||
bool? _registerHasKeys;
|
||||
|
|
@ -150,16 +149,13 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
|||
for (final MapEntry(:key, :value) in vector.entries)
|
||||
if (value case final int counter) key: counter,
|
||||
};
|
||||
if (kept['settingsVersions'] case final Map<String, Object?> rows) {
|
||||
if (kept['settingsFrontier'] case final Map<String, Object?> rows) {
|
||||
for (final MapEntry(key: row, :value) in rows.entries) {
|
||||
if (value case final Map<String, Object?> vector) {
|
||||
_settingsVersions[row] = vectorOf(vector);
|
||||
_settingsFrontier[row] = vectorOf(vector);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (kept['settingsPerson'] case final Map<String, Object?> vector) {
|
||||
_settingsPerson.addAll(vectorOf(vector));
|
||||
}
|
||||
if (kept['foldedSettings'] case final List<Object?> folded) {
|
||||
_foldedSettings.addAll(folded.whereType<String>());
|
||||
}
|
||||
|
|
@ -243,37 +239,42 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
|||
return _deviceId!;
|
||||
}
|
||||
|
||||
/// The version vector seen of the settings row [rowId] (each device's
|
||||
/// count of writes to the person's settings), empty for one never seen.
|
||||
Map<String, int> settingsVersionOf(String rowId) =>
|
||||
Map.unmodifiable(_settingsVersions[rowId] ?? const {});
|
||||
/// The newest versions of the person's settings this device has seen (the
|
||||
/// final reading of 22 Oct): each row's vector (each device's count of
|
||||
/// writes) that no other seen vector is newer than. Two rows written at
|
||||
/// once on two devices are both here; a row folded into another, or
|
||||
/// written over, is not.
|
||||
Map<String, Map<String, int>> get settingsFrontier => {
|
||||
for (final MapEntry(:key, :value) in _settingsFrontier.entries)
|
||||
key: Map.unmodifiable(value),
|
||||
};
|
||||
|
||||
/// Whether the settings row [rowId] was ever seen here.
|
||||
bool sawSettingsRow(String rowId) => _settingsVersions.containsKey(rowId);
|
||||
|
||||
/// The highest count of each device seen across all the person's settings
|
||||
/// rows (the final reading of 22 Oct): one row's news is everyone's.
|
||||
Map<String, int> get settingsPersonVersion =>
|
||||
Map.unmodifiable(_settingsPerson);
|
||||
|
||||
/// Keeps what [vector] adds to what was seen of the settings row [rowId],
|
||||
/// and of the person's settings as a whole: each device's highest count.
|
||||
/// Keeps that the settings row [rowId] was seen at [vector]: it joins the
|
||||
/// newest, and every vector it is newer than leaves them.
|
||||
void sawSettingsVersion(String rowId, Map<String, int> vector) {
|
||||
var changed = !_settingsVersions.containsKey(rowId);
|
||||
final seen = _settingsVersions[rowId] ??= {};
|
||||
for (final MapEntry(key: device, value: count) in vector.entries) {
|
||||
if (count > (seen[device] ?? 0)) {
|
||||
seen[device] = count;
|
||||
changed = true;
|
||||
}
|
||||
if (count > (_settingsPerson[device] ?? 0)) {
|
||||
_settingsPerson[device] = count;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_changed();
|
||||
final own = _settingsFrontier[rowId];
|
||||
if (own != null && _sameVector(own, vector)) {
|
||||
return;
|
||||
}
|
||||
_settingsFrontier
|
||||
..removeWhere((_, seen) => isOlderVersion(seen, than: vector))
|
||||
..[rowId] = {...vector};
|
||||
_changed();
|
||||
}
|
||||
|
||||
/// Whether [vector] is older than [than]: no count higher, and one lower.
|
||||
static bool isOlderVersion(
|
||||
Map<String, int> vector, {
|
||||
required Map<String, int> than,
|
||||
}) {
|
||||
final devices = {...vector.keys, ...than.keys};
|
||||
return devices.every((d) => (vector[d] ?? 0) <= (than[d] ?? 0)) &&
|
||||
devices.any((d) => (vector[d] ?? 0) < (than[d] ?? 0));
|
||||
}
|
||||
|
||||
static bool _sameVector(Map<String, int> a, Map<String, int> b) {
|
||||
final devices = {...a.keys, ...b.keys};
|
||||
return devices.every((d) => (a[d] ?? 0) == (b[d] ?? 0));
|
||||
}
|
||||
|
||||
/// Whether the settings row [rowId] was folded away here, so never read
|
||||
|
|
@ -360,8 +361,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
|
|||
{'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
|
||||
],
|
||||
'personalSealed': _personalSealed,
|
||||
'settingsVersions': _settingsVersions,
|
||||
'settingsPerson': _settingsPerson,
|
||||
'settingsFrontier': _settingsFrontier,
|
||||
'foldedSettings': [..._foldedSettings],
|
||||
'device': ?_deviceId,
|
||||
'registerHasKeys': ?_registerHasKeys,
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ class SyncLearningPathGateway(
|
|||
: {
|
||||
for (final path in await protocol.LearningPath.db.find(
|
||||
_db,
|
||||
where: (t) => t.id.inSet({...pathOf.values}),
|
||||
where: (t) => t.id.inSet(<protocol.UuidValue>{...pathOf.values}),
|
||||
))
|
||||
path.id!.uuid: path.studysetId.uuid,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ class SyncPdfSourceGateway(
|
|||
// PDF.
|
||||
return null;
|
||||
}
|
||||
await _privacy.keys.refresh();
|
||||
// Read as every list is: reading never refuses, whatever a write would.
|
||||
final treeKey = await _privacy.treeKeys();
|
||||
return switch (await protocol.PdfSource.db.findById(_db, uuid)) {
|
||||
final source? => _row(
|
||||
source,
|
||||
await _privacy.keyOfStudyset(source.studysetId.uuid),
|
||||
),
|
||||
final source? => _row(source, treeKey(source.studysetId.uuid)),
|
||||
null => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,27 +19,34 @@ class SyncSettingsGateway(
|
|||
@override
|
||||
Future<List<SettingsRow>> rows() async {
|
||||
await _privacy.keys.refresh();
|
||||
final readable = <String, ByteData>{};
|
||||
final sealed = <String, ByteData>{};
|
||||
for (final row in await protocol.PersonSettings.db.find(_db)) {
|
||||
if (_privacy.openItem(
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
table: 'person_settings',
|
||||
bound: '',
|
||||
id: row.id!.uuid,
|
||||
item: 'settings:${row.id!.uuid}',
|
||||
treeKey: null,
|
||||
protected: _privacy.personalProtected,
|
||||
)
|
||||
case Readable(:final payload)) {
|
||||
(row.keyId != null ? sealed : readable)[row.id!.uuid] = payload;
|
||||
}
|
||||
}
|
||||
// The sealed ones as their versions allow, all read together, so one
|
||||
// older than another read with it is left out (the final reading of 22
|
||||
// Oct).
|
||||
return [
|
||||
for (final row in await protocol.PersonSettings.db.find(_db))
|
||||
if (_privacy.openItem(
|
||||
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
|
||||
table: 'person_settings',
|
||||
bound: '',
|
||||
id: row.id!.uuid,
|
||||
item: 'settings:${row.id!.uuid}',
|
||||
treeKey: null,
|
||||
protected: _privacy.personalProtected,
|
||||
)
|
||||
case Readable(:final payload))
|
||||
if (_counted(row.id!.uuid, sealed: row.keyId != null, payload)
|
||||
case final payload?)
|
||||
(id: row.id!.uuid, payload: payload),
|
||||
for (final MapEntry(:key, :value) in {
|
||||
...readable,
|
||||
..._privacy.settingsWithoutCounters(sealed),
|
||||
}.entries)
|
||||
(id: key, payload: value),
|
||||
];
|
||||
}
|
||||
|
||||
ByteData? _counted(String id, ByteData payload, {required bool sealed}) =>
|
||||
sealed ? _privacy.settingsWithoutCounter(id, payload) : payload;
|
||||
|
||||
ByteData _withCounter(String id, ByteData payload, String? keyId) =>
|
||||
keyId == null ? payload : _privacy.settingsWithCounter(id, payload);
|
||||
|
||||
|
|
|
|||
|
|
@ -173,8 +173,11 @@ final class const PersonSettings({
|
|||
restDays: {...other.restDays, ...restDays},
|
||||
home: home == const HomeLayout() ? other.home : home,
|
||||
pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro,
|
||||
// Left as it comes (off) counts as none.
|
||||
countLearning: countLearning || other.countLearning,
|
||||
// Off where either says off (ADR 0019, the final reading of 22 Oct):
|
||||
// an instance withholding a newer off and handing back an older on, or
|
||||
// an on another device wrote before it, could otherwise switch counting
|
||||
// on; the person switches it on again at worst.
|
||||
countLearning: countLearning && other.countLearning,
|
||||
privateOfferAnswered: privateOfferAnswered || other.privateOfferAnswered,
|
||||
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
|
||||
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,
|
||||
|
|
|
|||
Loading…
Reference in a new issue