Skip to content

Commit ca34f6d

Browse files
committed
fix(profile): harden subscription-userinfo parsing + add regression tests
Per review on #2243: - Skip malformed key=value segments (a segment without '=' threw via dartx .second and dropped the whole profile through parse()'s tryCatch). - Guard toInt() against non-finite values (1e999/Infinity/NaN) so they fall back to null / the unlimited sentinel instead of throwing. - Keep total/expire optional via null-safe lookups; only upload+download are required. - Add regression tests: missing total/expire (subInfo + support/web-page tiles still applied), only-total / only-expire omitted, malformed segment, and non-finite values.
1 parent 659234d commit ca34f6d

2 files changed

Lines changed: 249 additions & 18 deletions

File tree

lib/features/profile/data/profile_parser.dart

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -287,25 +287,39 @@ class ProfileParser {
287287
}
288288

289289
static SubscriptionInfo? _parseSubscriptionInfo(String subInfoStr) {
290-
final values = subInfoStr.split(';');
291-
final map = {for (final v in values) v.split('=').first.trim(): num.tryParse(v.split('=').second.trim())?.toInt()};
292-
// Only upload+download are required. total/expire are optional - many panels omit them (e.g.
293-
// `expire` for no-expiry plans) - and are already defaulted to the "unlimited" sentinels below.
294-
// Requiring their keys here discarded the WHOLE subscription-userinfo when either was absent,
295-
// which silently drops the usage bar AND the support-url / profile-web-page-url tiles.
296-
if (map case {"upload": final upload?, "download": final download?}) {
297-
final total = map["total"];
298-
var expire = map["expire"];
299-
final total1 = (total == null || total == 0) ? infiniteTrafficThreshold + 1 : total;
300-
expire = (expire == null || expire == 0) ? infiniteTimeThreshold : expire;
301-
return SubscriptionInfo(
302-
upload: upload,
303-
download: download,
304-
total: total1,
305-
expire: DateTime.fromMillisecondsSinceEpoch(expire * 1000),
306-
);
290+
// Parse "key=value;key=value" defensively: skip any segment that isn't a well-formed key=value
291+
// pair instead of throwing. A malformed segment (no '=') would otherwise bubble up through
292+
// parse()'s tryCatch and drop the WHOLE profile.
293+
final map = <String, int?>{};
294+
for (final segment in subInfoStr.split(';')) {
295+
final parts = segment.split('=');
296+
if (parts.length < 2) continue;
297+
// Guard toInt() against non-finite values: num.tryParse of "1e999"/"Infinity"/"NaN" yields a
298+
// non-finite double and double.toInt() throws on those, which would again take down the whole
299+
// profile parse. Treat them as absent so the field falls back gracefully (null / unlimited sentinel).
300+
final value = num.tryParse(parts[1].trim());
301+
map[parts.first.trim()] = (value != null && value.isFinite) ? value.toInt() : null;
307302
}
308-
return null;
303+
304+
// Only upload+download are required. total/expire are optional - panels omit total for unlimited
305+
// plans and expire for no-expiry plans - and default to the "unlimited" sentinels below. Requiring
306+
// their keys discarded the WHOLE subscription-userinfo when either was absent, silently dropping the
307+
// usage bar AND the support-url / profile-web-page-url tiles parsed alongside it.
308+
final upload = map['upload'];
309+
final download = map['download'];
310+
if (upload == null || download == null) return null;
311+
312+
final total = map['total'];
313+
final expire = map['expire'];
314+
final resolvedTotal = (total == null || total == 0) ? infiniteTrafficThreshold + 1 : total;
315+
final resolvedExpire = (expire == null || expire == 0) ? infiniteTimeThreshold : expire;
316+
317+
return SubscriptionInfo(
318+
upload: upload,
319+
download: download,
320+
total: resolvedTotal,
321+
expire: DateTime.fromMillisecondsSinceEpoch(resolvedExpire * 1000),
322+
);
309323
}
310324

311325
@visibleForTesting

test/features/profile/data/profile_parser_test.dart

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,5 +162,222 @@ void main() {
162162
});
163163
});
164164
});
165+
166+
test("Should keep subscription info when total and expire are omitted", () {
167+
// Panels routinely omit total (unlimited plans) and expire (no-expiry plans). The header must
168+
// still produce a SubscriptionInfo, and the support-url / profile-web-page-url tiles parsed
169+
// alongside it must still be applied. Regression test for #2242.
170+
final headers = <String, List<String>>{
171+
"profile-title": ["title"],
172+
"subscription-userinfo": ["upload=100;download=200"],
173+
"profile-web-page-url": [validBaseUrl],
174+
"support-url": [validSupportUrl],
175+
};
176+
final fixedHeaders = headers.map((key, value) {
177+
if (value.length == 1) return MapEntry(key, value.first);
178+
return MapEntry(key, value);
179+
});
180+
final allHeaders = ProfileParser.populateHeaders(content: '', remoteHeaders: fixedHeaders);
181+
expect(allHeaders.isRight(), true);
182+
allHeaders.match((l) {}, (r) {
183+
final profile = ProfileParser.parse(
184+
tempFilePath: '',
185+
profile: RemoteProfileEntity(
186+
id: const Uuid().v4(),
187+
active: true,
188+
name: '',
189+
url: validBaseUrl,
190+
lastUpdate: DateTime.now(),
191+
populatedHeaders: r,
192+
),
193+
);
194+
expect(profile.isRight(), true);
195+
profile.match((l) {}, (r) {
196+
expect(r is RemoteProfileEntity, true);
197+
r.map(
198+
remote: (rp) {
199+
expect(rp.subInfo, isNotNull);
200+
expect(rp.subInfo!.upload, equals(100));
201+
expect(rp.subInfo!.download, equals(200));
202+
expect(rp.subInfo!.total, equals(ProfileParser.infiniteTrafficThreshold + 1));
203+
expect(
204+
rp.subInfo!.expire,
205+
equals(DateTime.fromMillisecondsSinceEpoch(ProfileParser.infiniteTimeThreshold * 1000)),
206+
);
207+
expect(rp.subInfo!.webPageUrl, equals(validBaseUrl));
208+
expect(rp.subInfo!.supportUrl, equals(validSupportUrl));
209+
},
210+
local: (lp) {},
211+
);
212+
});
213+
});
214+
});
215+
216+
test("Should not drop the profile when a subscription-userinfo segment is malformed", () {
217+
// A segment without '=' (here "garbage") previously threw and failed the entire profile parse.
218+
// It must now be skipped while the valid fields are still parsed.
219+
final headers = <String, List<String>>{
220+
"profile-title": ["title"],
221+
"subscription-userinfo": ["upload=100;download=200;garbage;total=5000;expire=1704054600"],
222+
};
223+
final fixedHeaders = headers.map((key, value) {
224+
if (value.length == 1) return MapEntry(key, value.first);
225+
return MapEntry(key, value);
226+
});
227+
final allHeaders = ProfileParser.populateHeaders(content: '', remoteHeaders: fixedHeaders);
228+
expect(allHeaders.isRight(), true);
229+
allHeaders.match((l) {}, (r) {
230+
final profile = ProfileParser.parse(
231+
tempFilePath: '',
232+
profile: RemoteProfileEntity(
233+
id: const Uuid().v4(),
234+
active: true,
235+
name: '',
236+
url: validBaseUrl,
237+
lastUpdate: DateTime.now(),
238+
populatedHeaders: r,
239+
),
240+
);
241+
expect(profile.isRight(), true);
242+
profile.match((l) {}, (r) {
243+
expect(r is RemoteProfileEntity, true);
244+
r.map(
245+
remote: (rp) {
246+
expect(rp.subInfo, isNotNull);
247+
expect(rp.subInfo!.upload, equals(100));
248+
expect(rp.subInfo!.download, equals(200));
249+
expect(rp.subInfo!.total, equals(5000));
250+
expect(
251+
rp.subInfo!.expire,
252+
equals(DateTime.fromMillisecondsSinceEpoch(1704054600 * 1000)),
253+
);
254+
},
255+
local: (lp) {},
256+
);
257+
});
258+
});
259+
});
260+
261+
test("Should fall back to unlimited traffic when only total is omitted", () {
262+
final headers = <String, List<String>>{
263+
"profile-title": ["title"],
264+
"subscription-userinfo": ["upload=100;download=200;expire=1704054600"],
265+
};
266+
final fixedHeaders = headers.map((key, value) {
267+
if (value.length == 1) return MapEntry(key, value.first);
268+
return MapEntry(key, value);
269+
});
270+
final allHeaders = ProfileParser.populateHeaders(content: '', remoteHeaders: fixedHeaders);
271+
expect(allHeaders.isRight(), true);
272+
allHeaders.match((l) {}, (r) {
273+
final profile = ProfileParser.parse(
274+
tempFilePath: '',
275+
profile: RemoteProfileEntity(
276+
id: const Uuid().v4(),
277+
active: true,
278+
name: '',
279+
url: validBaseUrl,
280+
lastUpdate: DateTime.now(),
281+
populatedHeaders: r,
282+
),
283+
);
284+
expect(profile.isRight(), true);
285+
profile.match((l) {}, (r) {
286+
expect(r is RemoteProfileEntity, true);
287+
r.map(
288+
remote: (rp) {
289+
expect(rp.subInfo, isNotNull);
290+
expect(rp.subInfo!.total, equals(ProfileParser.infiniteTrafficThreshold + 1));
291+
expect(rp.subInfo!.expire, equals(DateTime.fromMillisecondsSinceEpoch(1704054600 * 1000)));
292+
},
293+
local: (lp) {},
294+
);
295+
});
296+
});
297+
});
298+
299+
test("Should fall back to no-expiry when only expire is omitted", () {
300+
final headers = <String, List<String>>{
301+
"profile-title": ["title"],
302+
"subscription-userinfo": ["upload=100;download=200;total=5000"],
303+
};
304+
final fixedHeaders = headers.map((key, value) {
305+
if (value.length == 1) return MapEntry(key, value.first);
306+
return MapEntry(key, value);
307+
});
308+
final allHeaders = ProfileParser.populateHeaders(content: '', remoteHeaders: fixedHeaders);
309+
expect(allHeaders.isRight(), true);
310+
allHeaders.match((l) {}, (r) {
311+
final profile = ProfileParser.parse(
312+
tempFilePath: '',
313+
profile: RemoteProfileEntity(
314+
id: const Uuid().v4(),
315+
active: true,
316+
name: '',
317+
url: validBaseUrl,
318+
lastUpdate: DateTime.now(),
319+
populatedHeaders: r,
320+
),
321+
);
322+
expect(profile.isRight(), true);
323+
profile.match((l) {}, (r) {
324+
expect(r is RemoteProfileEntity, true);
325+
r.map(
326+
remote: (rp) {
327+
expect(rp.subInfo, isNotNull);
328+
expect(rp.subInfo!.total, equals(5000));
329+
expect(
330+
rp.subInfo!.expire,
331+
equals(DateTime.fromMillisecondsSinceEpoch(ProfileParser.infiniteTimeThreshold * 1000)),
332+
);
333+
},
334+
local: (lp) {},
335+
);
336+
});
337+
});
338+
});
339+
340+
test("Should not throw the whole profile away on non-finite numeric values", () {
341+
// num.tryParse of "1e999"/"Infinity"/"NaN" yields a non-finite double; double.toInt() throws on
342+
// those. The parser must degrade them gracefully (unlimited sentinels) instead of failing the parse.
343+
final headers = <String, List<String>>{
344+
"profile-title": ["title"],
345+
"subscription-userinfo": ["upload=100;download=200;total=1e999;expire=NaN"],
346+
};
347+
final fixedHeaders = headers.map((key, value) {
348+
if (value.length == 1) return MapEntry(key, value.first);
349+
return MapEntry(key, value);
350+
});
351+
final allHeaders = ProfileParser.populateHeaders(content: '', remoteHeaders: fixedHeaders);
352+
expect(allHeaders.isRight(), true);
353+
allHeaders.match((l) {}, (r) {
354+
final profile = ProfileParser.parse(
355+
tempFilePath: '',
356+
profile: RemoteProfileEntity(
357+
id: const Uuid().v4(),
358+
active: true,
359+
name: '',
360+
url: validBaseUrl,
361+
lastUpdate: DateTime.now(),
362+
populatedHeaders: r,
363+
),
364+
);
365+
expect(profile.isRight(), true);
366+
profile.match((l) {}, (r) {
367+
expect(r is RemoteProfileEntity, true);
368+
r.map(
369+
remote: (rp) {
370+
expect(rp.subInfo, isNotNull);
371+
expect(rp.subInfo!.total, equals(ProfileParser.infiniteTrafficThreshold + 1));
372+
expect(
373+
rp.subInfo!.expire,
374+
equals(DateTime.fromMillisecondsSinceEpoch(ProfileParser.infiniteTimeThreshold * 1000)),
375+
);
376+
},
377+
local: (lp) {},
378+
);
379+
});
380+
});
381+
});
165382
});
166383
}

0 commit comments

Comments
 (0)