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:
Tizian 「ティツィアーン」 2026-10-22 12:40:12 +02:00
commit f658cb13da
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 423 additions and 117 deletions

View file

@ -158,9 +158,80 @@ class Privacy(
/// readable one, or a studyset that isn't there. /// readable one, or a studyset that isn't there.
@override @override
Future<String?> keyOfStudyset(String studysetId) async { 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); await _idle(studysetId);
_refuseWhileDamaged(); _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 /// [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, /// In a cycle the studysets below it come first, then its members by id,
/// so every member finds the same. /// so every member finds the same.
String? _nearestPrivate(Map<String, String?> parents, String studysetId) { String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
String? keyOf(String id) => _privateKeyOf(id) ?? _itemKey('studyset:$id');
final path = <String>[]; final path = <String>[];
final seen = <String, int>{}; final seen = <String, int>{};
String? current = studysetId; String? current = studysetId;
@ -235,7 +305,7 @@ class Privacy(
final below = path.sublist(0, seen[current]); final below = path.sublist(0, seen[current]);
final cycle = path.sublist(seen[current]!)..sort(); final cycle = path.sublist(seen[current]!)..sort();
for (final id in [...below, ...cycle]) { for (final id in [...below, ...cycle]) {
if (keyOf(id) case final keyId?) { if (_decided(id) case (:final keyId)) {
return keyId; return keyId;
} }
} }
@ -243,7 +313,7 @@ class Privacy(
} }
seen[current] = path.length; seen[current] = path.length;
path.add(current); path.add(current);
if (keyOf(current) case final keyId?) { if (_decided(current) case (:final keyId)) {
return keyId; return keyId;
} }
if (!parents.containsKey(current)) { if (!parents.containsKey(current)) {
@ -258,6 +328,22 @@ class Privacy(
/// where it was made readable, or nothing says. /// where it was made readable, or nothing says.
String? _privateKeyOf(String studysetId) => ledger.factOf(studysetId)?.keyId; 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 /// The key [item] was last seen sealed with, unless a later signed record
/// made the tree of that key readable, which releases it. /// made the tree of that key readable, which releases it.
String? _itemKey(String item) { String? _itemKey(String item) {
@ -485,6 +571,7 @@ class Privacy(
/// [PrivacyRefused] on a device without the keys of an account that has /// [PrivacyRefused] on a device without the keys of an account that has
/// them. /// them.
Future<String?> keyOfNewTopLevel(String studysetId) async { Future<String?> keyOfNewTopLevel(String studysetId) async {
await keys.refresh();
if (keys.canEncrypt) { if (keys.canEncrypt) {
return await keys.newStudysetKey(studysetId); return await keys.newStudysetKey(studysetId);
} }
@ -499,6 +586,7 @@ class Privacy(
/// [PrivacyRefused] on a device without the keys of an account that has /// [PrivacyRefused] on a device without the keys of an account that has
/// them: tags and settings are read-only there. /// them: tags and settings are read-only there.
Future<String?> personalKey() async { Future<String?> personalKey() async {
await keys.refresh();
if (keys.canEncrypt) { if (keys.canEncrypt) {
return await keys.personalKey(); return await keys.personalKey();
} }
@ -513,28 +601,57 @@ class Privacy(
/// payload (the short reading of 22 Oct). /// payload (the short reading of 22 Oct).
static final _versionMark = utf8.encode('HLKV'); static final _versionMark = utf8.encode('HLKV');
/// The sealed settings row [rowId]'s [payload] (opened) without its /// The sealed settings [rows] (opened, by their ids) without their
/// version vector; null for one that isn't read (the final reading of 22 /// version vectors, leaving out those that aren't read (the final reading
/// Oct): /// of 22 Oct):
/// - a row this device folded away, whatever is handed back; /// - a row this device folded away, whatever is handed back;
/// - a rollback of a row seen before: every count at most what was seen /// - a row older than any version this device has seen of the person's
/// of it, and one lower; /// settings, or than another row read with it (its own earlier one, a
/// - a row never seen here that brings no news: every count at most what /// row written over, or one folded away elsewhere), so withholding a
/// was seen of the person's settings as a whole, so a row folded away on /// newer row and handing back an older one rolls nothing back;
/// another device can't come back;
/// - one without a vector (sealed before settings had them, or under the /// - one without a vector (sealed before settings had them, or under the
/// short reading's counter, `HLKS`): dropped, as nothing sealed so was /// short reading's counter, `HLKS`): dropped, as nothing sealed so was
/// released. /// released.
/// ///
/// A row with news from any device is taken, so two devices' changes at /// A row with news from any device is taken, so two devices' changes at
/// once both count. A blank one is as it is. /// once both count. A blank one is as it is.
ByteData? settingsWithoutCounter(String rowId, ByteData 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) { if (payload.lengthInBytes == 0) {
return payload; 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 bytes = Uint8List.sublistView(payload);
final mark = _versionMark.length; final mark = _versionMark.length;
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) { if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
@ -544,48 +661,39 @@ class Privacy(
if (bytes.length < mark + 2 + length) { if (bytes.length < mark + 2 + length) {
return null; return null;
} }
final Map<String, int> vector;
try { try {
final json = jsonDecode( final json = jsonDecode(
utf8.decode(Uint8List.sublistView(bytes, mark + 2, mark + 2 + length)), utf8.decode(Uint8List.sublistView(bytes, mark + 2, mark + 2 + length)),
); );
vector = { return (
vector: {
if (json case final Map<String, Object?> map) if (json case final Map<String, Object?> map)
for (final MapEntry(:key, :value) in map.entries) for (final MapEntry(:key, :value) in map.entries)
if (value case final int count) key: count, if (value case final int count) key: count,
}; },
payload: ByteData.sublistView(bytes, mark + 2 + length),
);
} on FormatException { } on FormatException {
return null; 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 /// 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 /// front, to be sealed: newer than every version seen, so it stands for
/// more write of this device's, so it dominates what it replaces. /// all it folds and writes over.
ByteData settingsWithCounter(String rowId, ByteData payload) { ByteData settingsWithCounter(String rowId, ByteData payload) {
if (payload.lengthInBytes == 0) { if (payload.lengthInBytes == 0) {
return payload; return payload;
} }
final device = ledger.deviceId; 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; vector[device] = (vector[device] ?? 0) + 1;
ledger.sawSettingsVersion(rowId, vector); ledger.sawSettingsVersion(rowId, vector);
final json = utf8.encode(jsonEncode(vector)); final json = utf8.encode(jsonEncode(vector));
@ -700,7 +808,9 @@ class Privacy(
_db, _db,
where: stepIds == null where: stepIds == null
? 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, step.id!.uuid: step.pathId.uuid,
}; };
@ -1017,7 +1127,8 @@ class Privacy(
} }
/// [studysetId]'s tree without the studysets inside it that a signed /// [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 { Future<Set<String>> scopeOf(String studysetId) async {
final parents = await _structure(); final parents = await _structure();
final children = <String, List<String>>{}; final children = <String, List<String>>{};
@ -1030,7 +1141,7 @@ class Privacy(
final queue = [studysetId]; final queue = [studysetId];
while (queue.isNotEmpty) { while (queue.isNotEmpty) {
final id = queue.removeLast(); final id = queue.removeLast();
if (id != studysetId && _privateKeyOf(id) != null) { if (id != studysetId && ledger.factOf(id) != null) {
continue; continue;
} }
if (scope.add(id)) { if (scope.add(id)) {
@ -2055,8 +2166,13 @@ class Privacy(
return; return;
} }
var wrote = false; var wrote = false;
for (final tag in await protocol.Tag.db.find(_db)) { // Only the readable ones are read: the upkeep after every merge costs
if (tag.keyId != null || tag.payload.lengthInBytes == 0) { // 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; continue;
} }
final opened = open( final opened = open(
@ -2082,8 +2198,11 @@ class Privacy(
); );
} }
} }
for (final settings in await protocol.PersonSettings.db.find(_db)) { for (final settings in await protocol.PersonSettings.db.find(
if (settings.keyId != null || settings.payload.lengthInBytes == 0) { _db,
where: (t) => t.keyId.equals(null),
)) {
if (settings.payload.lengthInBytes == 0) {
continue; continue;
} }
final opened = open( final opened = open(

View file

@ -93,8 +93,7 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
final _items = <String, String>{}; final _items = <String, String>{};
final _pending = <PendingRewrite>[]; final _pending = <PendingRewrite>[];
var _personalSealed = false; var _personalSealed = false;
final _settingsVersions = <String, Map<String, int>>{}; final _settingsFrontier = <String, Map<String, int>>{};
final _settingsPerson = <String, int>{};
final _foldedSettings = <String>{}; final _foldedSettings = <String>{};
String? _deviceId; String? _deviceId;
bool? _registerHasKeys; bool? _registerHasKeys;
@ -150,16 +149,13 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
for (final MapEntry(:key, :value) in vector.entries) for (final MapEntry(:key, :value) in vector.entries)
if (value case final int counter) key: counter, 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) { for (final MapEntry(key: row, :value) in rows.entries) {
if (value case final Map<String, Object?> vector) { 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) { if (kept['foldedSettings'] case final List<Object?> folded) {
_foldedSettings.addAll(folded.whereType<String>()); _foldedSettings.addAll(folded.whereType<String>());
} }
@ -243,37 +239,42 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
return _deviceId!; return _deviceId!;
} }
/// The version vector seen of the settings row [rowId] (each device's /// The newest versions of the person's settings this device has seen (the
/// count of writes to the person's settings), empty for one never seen. /// final reading of 22 Oct): each row's vector (each device's count of
Map<String, int> settingsVersionOf(String rowId) => /// writes) that no other seen vector is newer than. Two rows written at
Map.unmodifiable(_settingsVersions[rowId] ?? const {}); /// 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. /// Keeps that the settings row [rowId] was seen at [vector]: it joins the
bool sawSettingsRow(String rowId) => _settingsVersions.containsKey(rowId); /// newest, and every vector it is newer than leaves them.
/// 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.
void sawSettingsVersion(String rowId, Map<String, int> vector) { void sawSettingsVersion(String rowId, Map<String, int> vector) {
var changed = !_settingsVersions.containsKey(rowId); final own = _settingsFrontier[rowId];
final seen = _settingsVersions[rowId] ??= {}; if (own != null && _sameVector(own, vector)) {
for (final MapEntry(key: device, value: count) in vector.entries) { return;
if (count > (seen[device] ?? 0)) {
seen[device] = count;
changed = true;
} }
if (count > (_settingsPerson[device] ?? 0)) { _settingsFrontier
_settingsPerson[device] = count; ..removeWhere((_, seen) => isOlderVersion(seen, than: vector))
changed = true; ..[rowId] = {...vector};
}
}
if (changed) {
_changed(); _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 /// 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}, {'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
], ],
'personalSealed': _personalSealed, 'personalSealed': _personalSealed,
'settingsVersions': _settingsVersions, 'settingsFrontier': _settingsFrontier,
'settingsPerson': _settingsPerson,
'foldedSettings': [..._foldedSettings], 'foldedSettings': [..._foldedSettings],
'device': ?_deviceId, 'device': ?_deviceId,
'registerHasKeys': ?_registerHasKeys, 'registerHasKeys': ?_registerHasKeys,

View file

@ -189,7 +189,7 @@ class SyncLearningPathGateway(
: { : {
for (final path in await protocol.LearningPath.db.find( for (final path in await protocol.LearningPath.db.find(
_db, _db,
where: (t) => t.id.inSet({...pathOf.values}), where: (t) => t.id.inSet(<protocol.UuidValue>{...pathOf.values}),
)) ))
path.id!.uuid: path.studysetId.uuid, path.id!.uuid: path.studysetId.uuid,
}; };

View file

@ -41,11 +41,11 @@ class SyncPdfSourceGateway(
// PDF. // PDF.
return null; 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)) { return switch (await protocol.PdfSource.db.findById(_db, uuid)) {
final source? => _row( final source? => _row(source, treeKey(source.studysetId.uuid)),
source,
await _privacy.keyOfStudyset(source.studysetId.uuid),
),
null => null, null => null,
}; };
} }

View file

@ -19,8 +19,9 @@ class SyncSettingsGateway(
@override @override
Future<List<SettingsRow>> rows() async { Future<List<SettingsRow>> rows() async {
await _privacy.keys.refresh(); await _privacy.keys.refresh();
return [ final readable = <String, ByteData>{};
for (final row in await protocol.PersonSettings.db.find(_db)) final sealed = <String, ByteData>{};
for (final row in await protocol.PersonSettings.db.find(_db)) {
if (_privacy.openItem( if (_privacy.openItem(
(keyId: row.keyId, nonce: row.nonce, payload: row.payload), (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
table: 'person_settings', table: 'person_settings',
@ -30,16 +31,22 @@ class SyncSettingsGateway(
treeKey: null, treeKey: null,
protected: _privacy.personalProtected, protected: _privacy.personalProtected,
) )
case Readable(:final payload)) case Readable(:final payload)) {
if (_counted(row.id!.uuid, sealed: row.keyId != null, payload) (row.keyId != null ? sealed : readable)[row.id!.uuid] = payload;
case final payload?) }
(id: row.id!.uuid, payload: 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 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) => ByteData _withCounter(String id, ByteData payload, String? keyId) =>
keyId == null ? payload : _privacy.settingsWithCounter(id, payload); keyId == null ? payload : _privacy.settingsWithCounter(id, payload);

View file

@ -173,8 +173,11 @@ final class const PersonSettings({
restDays: {...other.restDays, ...restDays}, restDays: {...other.restDays, ...restDays},
home: home == const HomeLayout() ? other.home : home, home: home == const HomeLayout() ? other.home : home,
pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro, pomodoro: pomodoro == const PomodoroSettings() ? other.pomodoro : pomodoro,
// Left as it comes (off) counts as none. // Off where either says off (ADR 0019, the final reading of 22 Oct):
countLearning: countLearning || other.countLearning, // 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, privateOfferAnswered: privateOfferAnswered || other.privateOfferAnswered,
jellyfinTies: {...other.jellyfinTies, ...jellyfinTies}, jellyfinTies: {...other.jellyfinTies, ...jellyfinTies},
hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags, hideJellyfinTags: hideJellyfinTags || other.hideJellyfinTags,

View file

@ -17,6 +17,8 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gat
import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/data/tree_files.dart';
import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart'; import 'package:halkyon_learn_flutter/src/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_learning_path_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_path_answer_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
@ -79,6 +81,8 @@ void main() {
SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy), SyncCardGateway(db, sync, const protocol.Uuid().v7(), privacy),
SyncTagGateway(db, sync, privacy), SyncTagGateway(db, sync, privacy),
SyncSettingsGateway(db, sync, privacy), SyncSettingsGateway(db, sync, privacy),
SyncLearningPathGateway(db, sync, const protocol.Uuid().v7(), privacy),
SyncPathAnswerGateway(db, sync, privacy),
); );
} }
@ -1079,14 +1083,38 @@ void main() {
expect([for (final r in await laptop.settings.rows()) r.id], [kept]); expect([for (final r in await laptop.settings.rows()) r.id], [kept]);
}); });
test('a settings row the instance withheld and hands back after a '
'newer one is never read again', () async {
final laptop = await device(account: alice);
final older = await laptop.settings.insert(
json({'countLearning': true}),
);
final asItWas = (await protocol.PersonSettings.db.findById(
db,
protocol.UuidValue.withValidation(older),
))!;
// The instance withholds it; the person turns Count me off, in a row
// of its own as none is there.
await protocol.PersonSettings.db.deleteRow(db, asItWas);
final newer = await laptop.settings.insert(
json({'countLearning': false}),
);
await protocol.PersonSettings.db.insertRow(db, asItWas);
expect([for (final r in await laptop.settings.rows()) r.id], [newer]);
});
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);
final phone = await device(account: alice);
final kept = await laptop.settings.insert( final kept = await laptop.settings.insert(
json({'countLearning': false}), json({'countLearning': false}),
); );
expect(await phone.settings.rows(), hasLength(1)); expect(await laptop.settings.rows(), hasLength(1));
// The phone, before it heard of the laptop's, writes a row of its own;
// then folds the two, as the repository does, Count me off.
final phone = await device(account: alice);
final other = await phone.settings.insert( final other = await phone.settings.insert(
json({'countLearning': true}), json({'countLearning': true}),
); );
@ -1094,8 +1122,6 @@ void main() {
db, db,
protocol.UuidValue.withValidation(other), protocol.UuidValue.withValidation(other),
))!; ))!;
// The phone folds it away again and turns Count me off, before the
// laptop ever saw it.
expect(await phone.settings.rows(), hasLength(2)); expect(await phone.settings.rows(), hasLength(2));
await phone.settings.updatePayload(other, redactedPayload); await phone.settings.updatePayload(other, redactedPayload);
await phone.settings.delete(other); await phone.settings.delete(other);
@ -1147,6 +1173,144 @@ void main() {
}, },
); );
group('found by the hostile instance', () {
test('a write right after another device made the studyset is sealed '
'with its tree, though this device read nothing since', () async {
final laptop = await device(account: alice);
final phone = await device(account: alice);
final top = await privateStudyset(laptop, 'Japanese');
final topic = await laptop.studysets.insert(
studysetPayload('N3'),
DateTime.utc(2026, 10, 22),
parentId: top,
);
final path = await phone.paths.insertPath(
studysetId: topic,
position: 'a0',
payload: json({'schemaVersion': 1, 'title': 'SECRET'}),
);
final row = (await protocol.LearningPath.db.findById(
db,
protocol.UuidValue.withValidation(path),
))!;
expect(row.keyId, isNotNull);
});
test('a private topic the instance moves to the top level stays '
'private on a device that never read its row', () async {
final laptop = await device(account: alice);
final top = await privateStudyset(laptop, 'Japanese');
final topic = await laptop.studysets.insert(
studysetPayload('N3'),
DateTime.utc(2026, 10, 22),
parentId: top,
);
final phone = await device(account: alice);
final row = (await protocol.Studyset.db.findById(
db,
protocol.UuidValue.withValidation(topic),
))!;
await protocol.Studyset.db.updateRow(
db,
// The generated copyWith sets a null given, its default being a
// marker the lint can't see.
// ignore: avoid_redundant_argument_values
row.copyWith(parentId: null),
);
final card = await phone.cards.insertCard(
studysetId: topic,
position: 'a0',
payload: cardPayload('SECRET'),
);
expect(await sealed(card), isTrue);
});
test('a device that never saw a studyset private, its key record '
'withheld, writes nothing into it: its row is sealed', () async {
final phone = await device(account: alice);
final laptop = await device(account: alice);
final top = await privateStudyset(laptop, 'Diary');
await protocol.ContentKey.db.deleteWhere(
db,
where: (t) => t.dataKeyId.like('s1.$top.%'),
);
await expectLater(
phone.cards.insertCard(
studysetId: top,
position: 'a0',
payload: cardPayload('SECRET'),
),
throwsA(isA<PrivacyRefused>()),
);
});
test('a studyset made readable by its own record stays readable where '
"the instance moves it: a private card isn't moved in, and making "
'the other readable never opens it', () async {
final laptop = await device(account: alice);
final open = await privateStudyset(laptop, 'Kitchen German');
await laptop.studysets.setPrivate(open, private: false);
final secret = await privateStudyset(laptop, 'Diary');
final card = await laptop.cards.insertCard(
studysetId: secret,
position: 'a0',
payload: cardPayload('SECRET'),
);
final row = (await protocol.Studyset.db.findById(
db,
protocol.UuidValue.withValidation(open),
))!;
await protocol.Studyset.db.updateRow(
db,
row.copyWith(parentId: protocol.UuidValue.withValidation(secret)),
);
// As after the merge that brought it.
laptop.privacy.forgetTrees();
await expectLater(
laptop.cards.updateStudyset(card, open, 'a1'),
throwsA(isA<PrivacyRefused>()),
);
expect(await laptop.privacy.keyOfStudyset(open), isNull);
expect(await sealed(card), isTrue);
});
test("a path's steps and answers are read back", () async {
final laptop = await device(account: alice);
final id = await privateStudyset(laptop, 'Diary');
final path = await laptop.paths.insertPath(
studysetId: id,
position: 'a0',
payload: json({'schemaVersion': 1, 'title': 'Week 1'}),
);
final step = await laptop.paths.insertStep(
pathId: path,
position: 'a0',
kind: 'read',
payload: json({'schemaVersion': 1, 'title': 'Read'}),
);
await laptop.answers.insert([
(
stepId: step.stepId,
answeredAt: DateTime.utc(2026, 10, 22),
outcome: 'done',
answer: 'mine',
),
]);
expect(await laptop.paths.revisions({step.stepId}), hasLength(1));
expect(
await laptop.answers.answers(stepIds: {step.stepId}),
hasLength(1),
);
});
});
test('a tree stays partly readable while rows in it stay locked', () async { test('a tree stays partly readable while rows in it stay locked', () async {
final laptop = await device(account: alice); final laptop = await device(account: alice);
final id = await privateStudyset(laptop, 'Diary'); final id = await privateStudyset(laptop, 'Diary');
@ -1180,6 +1344,8 @@ final class const _Device(
final SyncCardGateway cards, final SyncCardGateway cards,
final SyncTagGateway tags, final SyncTagGateway tags,
final SyncSettingsGateway settings, final SyncSettingsGateway settings,
final SyncLearningPathGateway paths,
final SyncPathAnswerGateway answers,
); );
/// The person's files, one of which won't move while [failing]. /// The person's files, one of which won't move while [failing].

View file

@ -291,6 +291,17 @@ void main() {
); );
}); });
test("Count me is off as two devices' settings fold where either says "
'off: an instance handing back an older on never switches it on (ADR '
'0019, the final reading of 22 Oct)', () {
const on = PersonSettings(countLearning: true);
const off = PersonSettings();
expect(on.foldedWith(off).countLearning, isFalse);
expect(off.foldedWith(on).countLearning, isFalse);
expect(on.foldedWith(on).countLearning, isTrue);
});
test('hiding the tags from Jellyfin while studying survives a round ' test('hiding the tags from Jellyfin while studying survives a round '
'trip, in the ninth schema, left out while off; on, on either ' 'trip, in the ninth schema, left out while off; on, on either '
'device, it stays on as they fold', () { 'device, it stays on as they fold', () {