From abbf0e22bf2354a32fa49e5f7ca203fce5fd3062 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Sat, 25 Jul 2026 11:20:07 -0600 Subject: [PATCH] models: parse both shapes of user_failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lantern-box v0.0.104 changed each `user_failures` entry from a bare RFC3339 timestamp to a `{"at": ..., "kind": ...}` object. Go got back-compat for its own reads (`UserFailure.UnmarshalJSON`); Dart still cast every entry to String, so the first entry radiance recorded threw out of `SelectionHistory.fromJson`. `AvailableServers.fromJson` maps over the whole list with no per-entry isolation, so one poisoned server cost the client every server it had — Freshdesk #180591 shows a China Android user on 9.1.17 hitting this 339 times over five hours, 92 seconds after upgrading. Accept both shapes and drop entries we can't read instead of throwing: `user_failures` has no readers on this side, so an unparseable timestamp should never be able to take down the server list. Also adds the first `fromJson` coverage this model has had. Five of the seven new tests fail without the fix, one of them reproducing the verbatim runtime error from the ticket. Co-Authored-By: Claude --- lib/core/models/available_servers.dart | 19 ++- test/core/models/available_servers_test.dart | 138 +++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/lib/core/models/available_servers.dart b/lib/core/models/available_servers.dart index 197e07e59f..d6e4e92eab 100644 --- a/lib/core/models/available_servers.dart +++ b/lib/core/models/available_servers.dart @@ -258,7 +258,8 @@ class SelectionHistory { consecutiveFailures: json["consecutive_failures"] ?? 0, userFailures: (json["user_failures"] as List?) - ?.map((e) => DateTime.parse(e as String)) + ?.map(_userFailureAt) + .whereType() .toList() ?? const [], updatedAt: json["updated_at"] == null @@ -266,11 +267,27 @@ class SelectionHistory { : DateTime.parse(json["updated_at"]), ); + /// Timestamp of one `user_failures` entry, or null if it has none we can + /// read. lantern-box v0.0.104 changed each entry from a bare RFC3339 string + /// to a `{"at": ..., "kind": ...}` object; both are accepted so this parses + /// against either version, matching Go's `UserFailure.UnmarshalJSON`. + /// Unreadable entries are dropped rather than thrown on: one entry in the + /// shape this side didn't expect used to fail `Server.fromJson`, and since + /// `AvailableServers.fromJson` maps over the whole list, that cost the client + /// every server it had. `kind` is dropped too — nothing here reads it. + static DateTime? _userFailureAt(dynamic entry) { + final at = entry is Map ? entry["at"] : entry; + return at is String ? DateTime.tryParse(at) : null; + } + Map toJson() => { "last_success_delay_ms": lastSuccessDelayMs, if (lastOutcomeAt != null) "last_outcome_at": lastOutcomeAt!.toIso8601String(), "consecutive_failures": consecutiveFailures, + // Bare timestamps, i.e. the pre-v0.0.104 shape, which Go still accepts. + // Nothing sends this back to Go today; emitting the object form would mean + // inventing a `kind` we never parsed. if (userFailures.isNotEmpty) "user_failures": userFailures.map((e) => e.toIso8601String()).toList(), if (updatedAt != null) "updated_at": updatedAt!.toIso8601String(), diff --git a/test/core/models/available_servers_test.dart b/test/core/models/available_servers_test.dart index ddba8233e7..2316e9b16d 100644 --- a/test/core/models/available_servers_test.dart +++ b/test/core/models/available_servers_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:lantern/core/models/available_servers.dart'; @@ -259,8 +261,144 @@ void main() { expect(s.shouldWarnBeforeManualSelection, isFalse); }); }); + + group('user_failures deserialization', () { + // lantern-box v0.0.104 changed each entry from a bare RFC3339 timestamp to + // a {"at": ..., "kind": ...} object. Both have to parse: the object form is + // what current lantern-box sends, the string form is what a client paired + // with a pre-v0.0.104 one would see. + test('parses the object form', () { + final history = SelectionHistory.fromJson({ + 'last_success_delay_ms': 2556, + 'consecutive_failures': 2, + 'user_failures': [ + {'at': '2026-07-23T19:23:18.956396324+08:00', 'kind': 'unknown'}, + {'at': '2026-07-23T19:24:11.696763492+08:00', 'kind': 'stall'}, + ], + 'updated_at': '2026-07-23T19:26:52.297493639+08:00', + }); + + expect(history.userFailures, [ + DateTime.parse('2026-07-23T19:23:18.956396324+08:00'), + DateTime.parse('2026-07-23T19:24:11.696763492+08:00'), + ]); + }); + + test('parses an object form with no kind', () { + final history = SelectionHistory.fromJson({ + 'user_failures': [ + {'at': '2026-07-06T12:00:00Z'}, + ], + }); + + expect(history.userFailures, [DateTime.parse('2026-07-06T12:00:00Z')]); + }); + + test('parses the legacy bare-timestamp form', () { + final history = SelectionHistory.fromJson({ + 'user_failures': ['2026-07-06T12:00:00Z', '2026-07-06T12:01:00Z'], + }); + + expect(history.userFailures, [ + DateTime.parse('2026-07-06T12:00:00Z'), + DateTime.parse('2026-07-06T12:01:00Z'), + ]); + }); + + test('parses a window holding both forms at once', () { + final history = SelectionHistory.fromJson({ + 'user_failures': [ + '2026-07-06T12:00:00Z', + {'at': '2026-07-06T12:01:00Z', 'kind': 'reset'}, + ], + }); + + expect(history.userFailures, [ + DateTime.parse('2026-07-06T12:00:00Z'), + DateTime.parse('2026-07-06T12:01:00Z'), + ]); + }); + + test('an absent window parses as empty', () { + expect(SelectionHistory.fromJson({}).userFailures, isEmpty); + }); + + // A window this side can't read must cost us the entries, not the server. + test('drops entries with no readable timestamp', () { + final history = SelectionHistory.fromJson({ + 'user_failures': [ + {'kind': 'stall'}, + {'at': null}, + {'at': 1752580800}, + 'the beginning of time', + 42, + null, + {'at': '2026-07-06T12:00:00Z', 'kind': 'stall'}, + ], + }); + + expect(history.userFailures, [DateTime.parse('2026-07-06T12:00:00Z')]); + }); + + // Freshdesk #180591: a single server carrying the object form threw during + // Server.fromJson, and because AvailableServers.fromJson maps over the + // whole list, that one entry took down all 55 servers — leaving the client + // with no server list at all. + test('parses a list where only one server carries user_failures', () { + final servers = AvailableServers.fromJson( + _payload([ + _serverJson(tag: 'no-failures'), + _serverJson( + tag: 'has-failures', + userFailures: [ + {'at': '2026-07-23T19:23:18.956396324+08:00', 'kind': 'unknown'}, + ], + ), + ]), + ); + + expect(servers.servers.map((s) => s.tag), [ + 'no-failures', + 'has-failures', + ]); + expect( + servers.serverByTag('has-failures')!.selectionHistory!.userFailures, + hasLength(1), + ); + }); + }); } +/// Round-trips through JSON so element types match what the platform channel +/// hands us — `List` of `Map`, nested maps likewise — +/// rather than the sharper literal types the analyzer infers here. +List _payload(List> servers) => + jsonDecode(jsonEncode(servers)) as List; + +/// One server as it looks after `jsonDecode` of the payload radiance sends. +Map _serverJson({ + required String tag, + List>? userFailures, +}) => { + 'tag': tag, + 'type': 'samizdat', + 'isLantern': true, + 'outbound': {'type': 'samizdat', 'tag': tag, 'server': '203.0.113.7'}, + 'location': { + 'country': 'U.S.A.', + 'city': 'Chicago', + 'country_code': 'US', + 'latitude': 41.8781, + 'longitude': -87.6298, + }, + 'selection_history': { + 'last_success_delay_ms': 1264, + 'last_outcome_at': '2026-07-23T19:26:51.648896087+08:00', + 'user_failures': ?userFailures, + 'updated_at': '2026-07-23T19:26:51.648896087+08:00', + }, +}; + Server _server({ required String tag, bool isLantern = true,