Skip to content

Commit 5e92dd9

Browse files
fix(router): handle illegal percent encoding in style route (#300)
* fix(router): handle illegal percent encoding in style route Navigating to a style URL with a malformed percent-encoding (e.g. an old bookmark or shared link containing a stray `%`) caused Uri.decodeComponent to throw an ArgumentError, crashing the widget build. Decode the path segment safely, falling back to the raw value when decoding fails. https://claude.ai/code/session_01BBXUxMtenctm7JQoLpwzmY * refactor(router): move safeDecodeComponent to navigation_helpers Promotes the private _safeDecodeComponent helper to a public safeDecodeComponent function in navigation_helpers.dart so it can be directly unit-tested. Adds 8 unit tests covering valid encoding, unicode, no-op strings, and the three malformed-percent-encoding fallback cases. https://claude.ai/code/session_01BBXUxMtenctm7JQoLpwzmY * style: apply dart format to navigation_helpers_test.dart https://claude.ai/code/session_01BBXUxMtenctm7JQoLpwzmY --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ea71b5 commit 5e92dd9

4 files changed

Lines changed: 83 additions & 2 deletions

File tree

lib/router.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
44
import 'package:provider/provider.dart';
55
import 'providers/beer_provider.dart';
66
import 'screens/screens.dart';
7+
import 'utils/navigation_helpers.dart';
78
import 'main.dart';
89

910
/// Global routes that exist outside festival scope
@@ -160,10 +161,9 @@ final GoRouter appRouter = GoRouter(
160161
builder: (context, state) {
161162
final festivalId = state.pathParameters['festivalId']!;
162163
final name = state.pathParameters['name']!;
163-
final decodedName = Uri.decodeComponent(name);
164164
return StyleScreen(
165165
festivalId: festivalId,
166-
style: decodedName,
166+
style: safeDecodeComponent(name),
167167
);
168168
},
169169
),

lib/utils/navigation_helpers.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,25 @@ void navigateToRoute(BuildContext context, String path) {
235235
context.push(path);
236236
}
237237
}
238+
239+
/// Decodes a percent-encoded URI component, returning the raw value if it
240+
/// contains an illegal percent-encoding sequence.
241+
///
242+
/// [Uri.decodeComponent] throws an [ArgumentError] when a `%` is not followed
243+
/// by two hex digits (e.g. a stray `%` in an old bookmark or shared link).
244+
/// This wrapper catches that case so callers get a usable string instead of a
245+
/// crash.
246+
///
247+
/// Example:
248+
/// ```dart
249+
/// safeDecodeComponent('IPA%20American') // Returns: 'IPA American'
250+
/// safeDecodeComponent('50%') // Returns: '50%' (malformed — fallback)
251+
/// safeDecodeComponent('normal') // Returns: 'normal'
252+
/// ```
253+
String safeDecodeComponent(String value) {
254+
try {
255+
return Uri.decodeComponent(value);
256+
} on ArgumentError {
257+
return value;
258+
}
259+
}

test/router_test.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
55
import 'package:go_router/go_router.dart';
66
import 'package:cambridge_beer_festival/router.dart';
77
import 'package:cambridge_beer_festival/providers/beer_provider.dart';
8+
import 'package:cambridge_beer_festival/screens/screens.dart';
89
import 'package:cambridge_beer_festival/services/services.dart';
910
import 'package:cambridge_beer_festival/models/models.dart';
1011
import 'package:provider/provider.dart';
@@ -844,6 +845,28 @@ void main() {
844845
expect(uri.pathSegments[1], 'info');
845846
});
846847

848+
testWidgets(
849+
'style route with illegal percent encoding does not crash the build',
850+
(tester) async {
851+
await provider.initialize();
852+
853+
await tester.pumpWidget(
854+
ChangeNotifierProvider<BeerProvider>.value(
855+
value: provider,
856+
child: MaterialApp.router(routerConfig: appRouter),
857+
),
858+
);
859+
await tester.pumpAndSettle();
860+
861+
// A malformed URL (e.g. an old bookmark with a stray `%`) previously
862+
// crashed the build via Uri.decodeComponent throwing.
863+
appRouter.go('/$testFestivalId/style/50%');
864+
await tester.pumpAndSettle();
865+
866+
expect(tester.takeException(), isNull);
867+
expect(find.byType(StyleScreen), findsOneWidget);
868+
});
869+
847870
testWidgets(
848871
'navigating to drink detail updates URL with category and drink ID',
849872
(tester) async {

test/utils/navigation_helpers_test.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,5 +386,41 @@ void main() {
386386
expect(find.text('Can pop: false'), findsOneWidget);
387387
});
388388
});
389+
390+
group('safeDecodeComponent', () {
391+
test('decodes a valid percent-encoded string', () {
392+
expect(safeDecodeComponent('IPA%20American'), equals('IPA American'));
393+
});
394+
395+
test('decodes unicode percent-encoding', () {
396+
expect(safeDecodeComponent('Bi%C3%A8re%20de%20Garde'),
397+
equals('Bière de Garde'));
398+
});
399+
400+
test('returns unmodified string with no encoding', () {
401+
expect(safeDecodeComponent('IPA'), equals('IPA'));
402+
});
403+
404+
test('returns raw value for stray percent (illegal encoding)', () {
405+
expect(safeDecodeComponent('50%'), equals('50%'));
406+
});
407+
408+
test('returns raw value for truncated percent sequence', () {
409+
expect(safeDecodeComponent('foo%2'), equals('foo%2'));
410+
});
411+
412+
test('returns raw value for percent followed by non-hex', () {
413+
expect(safeDecodeComponent('foo%ZZ'), equals('foo%ZZ'));
414+
});
415+
416+
test('handles empty string', () {
417+
expect(safeDecodeComponent(''), equals(''));
418+
});
419+
420+
test('handles string with multiple valid encodings', () {
421+
expect(safeDecodeComponent('IPA%20-%20American%20Pale'),
422+
equals('IPA - American Pale'));
423+
});
424+
});
389425
});
390426
}

0 commit comments

Comments
 (0)