fix(app): http:// and https:// on one host are one account on the device

The reading of the full reading's fixes found that the device keeps
http://host/ and https://host/ in one place (folder, session key, keyring
prefix): someone with a stored http://host/ who added https://host/ and
then removed the first deleted the second's folder, session and keys while
it was in use, losing unsynced changes, and an anonymous account for good.

The places keep the names installs made (InstanceStorage.sharesWith says
which addresses share one): choosing an address the device keeps where a
known one is puts it in that one's place in the list, and removing an
address forgets nothing another known one uses. The build's own instance
keeps its place apart. Learn and Todo pass the storage's rule.

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-21 16:28:14 +02:00
commit 16ad925095
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 104 additions and 4 deletions

View file

@ -22,6 +22,10 @@ class InstanceChoice(
/// development builds, whose instance is the developer's own server (an
/// Android emulator reaches it as 10.0.2.2), never in releases.
final bool allowsInsecureBuiltIn = kDebugMode,
/// Whether the device keeps two instances in one place
/// (`InstanceStorage.sharesWith`): such addresses are one account here.
final bool Function(Instance a, Instance b) sharesStorage = _keptApart,
}) extends ChangeNotifier {
Instance? _current;
var _known = const <Instance>[];
@ -70,24 +74,36 @@ class InstanceChoice(
}
}
/// Uses [instance], keeping its account on the device from now on.
/// Uses [instance], keeping its account on the device from now on. An
/// address the device keeps in the place of one it knows ([sharesStorage]:
/// `https://` for `http://` on one host) takes that one's place in the
/// list, as both are the one account here (the reading of the fixes).
Future<void> use(Instance instance) async {
_current = instance;
if (!_known.contains(instance)) {
_known = [..._known, instance];
final twin = _known.indexWhere((each) => sharesStorage(each, instance));
_known = [
for (final (index, each) in _known.indexed)
if (index == twin) instance else each,
if (twin < 0) instance,
];
}
await _store.write((current: instance, known: _known));
notifyListeners();
}
/// Forgets the account on [instance], one not in use: what the device
/// holds of it goes. What it synced stays on the instance.
/// holds of it goes, unless another address it knows is kept in the same
/// place ([sharesStorage]), whose account that is too. What it synced
/// stays on the instance.
Future<void> remove(Instance instance) async {
final current = _current;
if (instance == current || current == null) {
return;
}
await forget(instance);
if (!_known.any((each) => sharesStorage(each, instance))) {
await forget(instance);
}
_known = [
for (final each in _known)
if (each != instance) each,
@ -96,3 +112,5 @@ class InstanceChoice(
notifyListeners();
}
}
bool _keptApart(Instance a, Instance b) => false;

View file

@ -52,6 +52,16 @@ class InstanceStorage(
await SecureAccountKeyStore(keysOf(instance)).forgetAllBut(null);
}
/// Whether the device keeps [a] and [b], two instances, in one place:
/// their addresses differ only in what their places' names leave out,
/// the scheme above all (`http://` and `https://` on one host; the
/// reading of the fixes). The names stay as installs made them, so such
/// addresses are one account on the device (`InstanceChoice`): choosing
/// one puts it in the other's place, and removing one forgets nothing the
/// other uses. The build's own instance keeps its place apart.
bool sharesWith(Instance a, Instance b) =>
a != b && a != builtIn && b != builtIn && _nameOf(a) == _nameOf(b);
Directory get _peopleOfBuiltIn => Directory(p.join(_support, 'people'));
Directory _folderOf(Instance instance) =>

View file

@ -14,6 +14,7 @@ void main() {
forget: (instance) async {
forgotten.add(instance);
},
sharesStorage: InstanceStorage('/support', builtIn: builtIn).sharesWith,
);
setUp(() {
@ -148,4 +149,73 @@ void main() {
expect(instances.known, [builtIn]);
expect(forgotten, [insecure]);
});
group('two addresses the device keeps in one place (http:// and https:// '
'on one host; the reading of the fixes)', () {
final insecure = Instance.parse('http://learn.example.org')!;
test('are one account: choosing one puts it in the place of the other, '
'forgetting nothing', () async {
store = MemoryInstanceStore((
current: insecure,
known: [builtIn, insecure],
));
final instances = choice();
await instances.load(usedBefore: false);
await instances.use(other);
expect(instances.current, other);
expect(instances.known, [builtIn, other]);
expect(forgotten, isEmpty);
});
test('removing one never forgets the folder, session or keys the other '
'uses', () async {
// Both known, from before.
store = MemoryInstanceStore((
current: insecure,
known: [builtIn, insecure, other],
));
final instances = choice();
await instances.load(usedBefore: false);
// Refused, and left for good, as the screen for an address over
// plain HTTP offers.
await instances.leave(insecure, forget: true);
expect(instances.known, [builtIn, other]);
expect(forgotten, isEmpty);
store = MemoryInstanceStore((
current: other,
known: [builtIn, insecure, other],
));
final again = choice();
await again.load(usedBefore: false);
await again.remove(insecure);
expect(again.known, [builtIn, other]);
expect(forgotten, isEmpty);
});
});
test('the storage tells which addresses it keeps in one place, the '
"build's own apart", () {
final storage = InstanceStorage('/support', builtIn: builtIn);
final insecure = Instance.parse('http://learn.example.org')!;
final builtInsTwin = Instance.parse('http://learn.halkyon.eu')!;
expect(storage.sharesWith(insecure, other), isTrue);
expect(storage.sharesWith(other, other), isFalse);
expect(storage.sharesWith(builtIn, builtInsTwin), isFalse);
expect(
storage.sharesWith(other, Instance.parse('learn.example.net')!),
isFalse,
);
// Their places as before: none moved.
expect(
storage.peopleOf(other).path,
'/support/instances/learn.example.org/people',
);
expect(storage.peopleOf(builtIn).path, '/support/people');
});
}