fix(privacy): a newest settings row taken away leaves out what it outweighed (ADR 0019)
Found by the hostile-instance test (seed 186): the instance withheld the laptop's personal key from the phone, so the phone wrote Count me off in a row of its own beside the laptop's on; then it deleted the phone's row and handed the key back, and the phone read the laptop's on alone, neither row's version newer than the other's. A row a device saw as newest that is gone, without this device folding it and without a row covering it (as another device's fold always leaves), now leaves out every row it doesn't cover: Count me falls back to off rather than to what the missing row outweighed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3bb90a725a
commit
988aa42617
2 changed files with 70 additions and 0 deletions
|
|
@ -638,6 +638,21 @@ class Privacy(
|
||||||
))
|
))
|
||||||
rowId: row,
|
rowId: row,
|
||||||
};
|
};
|
||||||
|
// A row seen as newest that is gone, without this device folding it
|
||||||
|
// and without a row covering it (as another device's fold leaves), was
|
||||||
|
// taken away: the rows it doesn't cover are left out too, as what it
|
||||||
|
// said might outweigh them (the final reading of 22 Oct; another
|
||||||
|
// device's older Count me on is never read alone).
|
||||||
|
for (final MapEntry(key: rowId, value: seen)
|
||||||
|
in ledger.settingsFrontier.entries) {
|
||||||
|
if (read.containsKey(rowId) || ledger.foldedSettings(rowId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (kept.values.any((row) => _covers(row.vector, seen))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
kept.removeWhere((_, row) => !_covers(row.vector, seen));
|
||||||
|
}
|
||||||
for (final MapEntry(key: rowId, value: row) in kept.entries) {
|
for (final MapEntry(key: rowId, value: row) in kept.entries) {
|
||||||
ledger.sawSettingsVersion(rowId, row.vector);
|
ledger.sawSettingsVersion(rowId, row.vector);
|
||||||
}
|
}
|
||||||
|
|
@ -648,6 +663,10 @@ class Privacy(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [vector] has every count of [seen] at least.
|
||||||
|
static bool _covers(Map<String, int> vector, Map<String, int> seen) =>
|
||||||
|
seen.entries.every((e) => (vector[e.key] ?? 0) >= e.value);
|
||||||
|
|
||||||
/// [payload]'s version vector and the payload after it; null without one.
|
/// [payload]'s version vector and the payload after it; null without one.
|
||||||
({Map<String, int> vector, ByteData payload})? _versioned(
|
({Map<String, int> vector, ByteData payload})? _versioned(
|
||||||
ByteData payload,
|
ByteData payload,
|
||||||
|
|
|
||||||
|
|
@ -1105,6 +1105,57 @@ void main() {
|
||||||
expect([for (final r in await laptop.settings.rows()) r.id], [newer]);
|
expect([for (final r in await laptop.settings.rows()) r.id], [newer]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a device's own settings row deleted by the instance leaves out the "
|
||||||
|
"rows it doesn't cover, so another device's older on isn't read "
|
||||||
|
'alone', () async {
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final mine = await phone.settings.insert(json({'countLearning': false}));
|
||||||
|
// The laptop, which never heard of it, turns Count me on in a row of
|
||||||
|
// its own.
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
await protocol.PersonSettings.db.deleteWhere(
|
||||||
|
db,
|
||||||
|
where: (t) => t.id.equals(protocol.UuidValue.withValidation(mine)),
|
||||||
|
);
|
||||||
|
final theirs = await laptop.settings.insert(
|
||||||
|
json({'countLearning': true}),
|
||||||
|
);
|
||||||
|
final row = (await protocol.PersonSettings.db.findById(
|
||||||
|
db,
|
||||||
|
protocol.UuidValue.withValidation(theirs),
|
||||||
|
))!;
|
||||||
|
// The instance hands the phone the laptop's row, withholding the
|
||||||
|
// phone's own.
|
||||||
|
expect(await protocol.PersonSettings.db.findById(db, row.id!), isNotNull);
|
||||||
|
|
||||||
|
expect([for (final r in await phone.settings.rows()) r.id], isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a settings row folded away on another device, its keeper covering '
|
||||||
|
'it, is no gap', () async {
|
||||||
|
final phone = await device(account: alice);
|
||||||
|
final mine = await phone.settings.insert(json({'countLearning': false}));
|
||||||
|
final laptop = await device(account: alice);
|
||||||
|
final theirs = await laptop.settings.insert(
|
||||||
|
json({'countLearning': true}),
|
||||||
|
);
|
||||||
|
// The laptop folds the phone's into its own, as the repository does.
|
||||||
|
final rows = [...await laptop.settings.rows()]
|
||||||
|
..sort((a, b) => a.id.compareTo(b.id));
|
||||||
|
expect(rows, hasLength(2));
|
||||||
|
final keeper = rows.first.id;
|
||||||
|
final other = rows.last.id;
|
||||||
|
await laptop.settings.updatePayload(other, redactedPayload);
|
||||||
|
await laptop.settings.delete(other);
|
||||||
|
await laptop.settings.updatePayload(
|
||||||
|
keeper,
|
||||||
|
json({'countLearning': false}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect([for (final r in await phone.settings.rows()) r.id], [keeper]);
|
||||||
|
expect({mine, theirs}, contains(keeper));
|
||||||
|
});
|
||||||
|
|
||||||
test('a settings row this device never saw is read only if it brings '
|
test('a settings row this device never saw is read only if it brings '
|
||||||
'news', () async {
|
'news', () async {
|
||||||
final laptop = await device(account: alice);
|
final laptop = await device(account: alice);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue