diff --git a/app/lib/extensions/uri.dart b/app/lib/extensions/uri.dart
index 62feda4d..e1c969e6 100644
--- a/app/lib/extensions/uri.dart
+++ b/app/lib/extensions/uri.dart
@@ -18,12 +18,12 @@
* along with this program. If not, see .
*/
-const _supportedSchemes = {'https', 'http', 'ftp', 'file', 'content', 'about'};
+import 'package:weblibre/utils/uri_policy.dart';
extension UriX on Uri {
Uri get base => Uri.parse('$scheme://$authority');
- bool get hasSupportedScheme => _supportedSchemes.contains(scheme);
+ bool get hasSupportedScheme => allSupportedSchemes.any((s) => s.name == scheme);
bool get isHttp => isScheme('http');
bool get isHttps => isScheme('https');
diff --git a/app/lib/features/bangs/presentation/screens/edit.dart b/app/lib/features/bangs/presentation/screens/edit.dart
index 5c8e58cd..aec6d7a6 100644
--- a/app/lib/features/bangs/presentation/screens/edit.dart
+++ b/app/lib/features/bangs/presentation/screens/edit.dart
@@ -88,7 +88,14 @@ class EditBangScreen extends HookConsumerWidget {
return;
}
- final uri = Uri.parse(urlTextController.text);
+ final uri = parseValidatedUrl(
+ urlTextController.text,
+ eagerParsing: false,
+ onlyHttpProtocol: true,
+ );
+ if (uri == null) {
+ return;
+ }
final bang = Bang(
group: BangGroup.user,
diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart
index 52242437..ea140b80 100644
--- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart
+++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart
@@ -28,7 +28,7 @@ import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositori
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
import 'package:weblibre/utils/form_validators.dart';
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
+import 'package:weblibre/utils/uri_input_parser.dart';
class BookmarkEntryEditScreen extends HookConsumerWidget {
final BookmarkInfo? initialInfo;
@@ -66,10 +66,17 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
IconButton(
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
- final newUrl = uri_parser.tryParseUrl(
+ var newUrl = parseValidatedUrl(
urlTextController.text,
eagerParsing: true,
- )!;
+ onlyHttpProtocol: true,
+ );
+
+ if (newUrl == null) {
+ return;
+ }
+
+ newUrl = redactUriCredentials(newUrl);
if (exisitingEntry != null) {
await ref
diff --git a/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart
index 6b6842db..4b534f69 100644
--- a/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart
+++ b/app/lib/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart
@@ -23,6 +23,7 @@
import 'dart:convert';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:weblibre/core/logger.dart';
+import 'package:weblibre/utils/uri_input_parser.dart';
class BookmarkJSONUtils {
final GeckoBookmarksService _service;
@@ -156,7 +157,11 @@ class BookmarkJSONUtils {
await _service.addItem(parentGuid, uri, title, i);
count++;
} else {
- logger.w('Skipping invalid URL: $url');
+ final parsed = Uri.tryParse(url);
+ final redacted = parsed != null
+ ? redactUriCredentials(parsed)
+ : url;
+ logger.w('Skipping invalid URL: $redacted');
}
} catch (e) {
logger.e('Failed to import bookmark "$title": $e');
diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart
index 73032449..29fc76a2 100644
--- a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart
+++ b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart
@@ -74,7 +74,10 @@ class OpenSharedContent extends HookConsumerWidget {
return null;
}, [currentUrl]);
- final parsedDebouncedUrl = Uri.tryParse(debouncedUrl.value);
+ final parsedDebouncedUrl = parseValidatedUrl(
+ debouncedUrl.value,
+ eagerParsing: false,
+ );
final hasExternalApp = useCachedFuture(
// ignore: discarded_futures useFuture
() => parsedDebouncedUrl != null
@@ -163,10 +166,18 @@ class OpenSharedContent extends HookConsumerWidget {
Future openTab(TabMode tabMode) async {
if (formKey.currentState?.validate() == true) {
+ final parsedUrl = parseValidatedUrl(
+ textController.text,
+ eagerParsing: false,
+ );
+ if (parsedUrl == null) {
+ return;
+ }
+
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
- url: Uri.parse(textController.text),
+ url: parsedUrl,
tabMode: tabMode,
containerSelection: selectedContainer.value == null
? const TabContainerSelection.unassigned()
@@ -183,8 +194,16 @@ class OpenSharedContent extends HookConsumerWidget {
Future openCustomTab(bool isPrivate) async {
if (formKey.currentState?.validate() == true) {
+ final parsedUrl = parseValidatedUrl(
+ textController.text,
+ eagerParsing: false,
+ );
+ if (parsedUrl == null) {
+ return;
+ }
+
await GeckoBrowserService().openInCustomTab(
- url: Uri.parse(textController.text),
+ url: parsedUrl,
private: isPrivate,
contextId: selectedContainer.value?.id,
);
@@ -197,7 +216,7 @@ class OpenSharedContent extends HookConsumerWidget {
Future openInApp() async {
if (formKey.currentState?.validate() == true) {
- final uri = Uri.tryParse(textController.text);
+ final uri = parseValidatedUrl(textController.text, eagerParsing: false);
if (uri == null) return;
final success = await _appLinksService.openAppLink(uri);
diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart
index cc329e44..46142bd8 100644
--- a/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart
+++ b/app/lib/features/geckoview/features/open_link_tools/presentation/utils/open_in_custom_tab.dart
@@ -20,14 +20,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
show GeckoBrowserService;
+
import 'package:weblibre/utils/ui_helper.dart';
+import 'package:weblibre/utils/uri_input_parser.dart';
+import 'package:weblibre/utils/uri_policy.dart';
Future openInPrivateCustomTab(BuildContext context, String url) async {
try {
- await GeckoBrowserService().openInCustomTab(
- url: Uri.parse(url),
- private: true,
+ final parsedUrl = parseUserInputUrl(
+ url,
+ policy: SchemePolicy.internalIntent,
+ allowSchemelessHosts: true,
+ enforceMaxInputLength: true,
);
+ if (parsedUrl == null) {
+ if (context.mounted) {
+ showErrorMessage(context, 'Could not open link: $url');
+ }
+ return;
+ }
+
+ await GeckoBrowserService().openInCustomTab(url: parsedUrl, private: true);
} catch (e) {
if (context.mounted) {
showErrorMessage(context, 'Could not open link: $url');
diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart
index 3a70c53b..5ded2738 100644
--- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart
+++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart
@@ -47,8 +47,9 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/c
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
+import 'package:weblibre/utils/input_classification.dart';
import 'package:weblibre/utils/text_field_line_count.dart';
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
+import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
class SearchScreen extends HookConsumerWidget {
final String? initialSearchText;
@@ -358,12 +359,26 @@ class SearchScreen extends HookConsumerWidget {
unfocusOnTapOutside: false,
onSubmitted: (value) async {
if (value.isNotEmpty) {
- var newUrl = uri_parser.tryParseUrl(
- value,
- eagerParsing: true,
- );
+ final classification = classifyAddressBarInput(value);
+ Uri? newUrl;
+ String? searchQuery;
- if (newUrl == null) {
+ switch (classification) {
+ case NavigateInputClassification(:final uri):
+ newUrl = uri;
+ case SearchInputClassification(:final query):
+ searchQuery = query;
+ case InvalidInputClassification():
+ if (context.mounted) {
+ ui_helper.showErrorMessage(
+ context,
+ 'Invalid address',
+ );
+ }
+ return;
+ }
+
+ if (newUrl == null && searchQuery != null) {
// Read from both providers - use site if set, otherwise global
final siteBang = isEditMode
? ref.read(
@@ -383,12 +398,12 @@ class SearchScreen extends HookConsumerWidget {
);
if (bang != null) {
- newUrl = bang.getTemplateUrl(value);
+ newUrl = bang.getTemplateUrl(searchQuery);
if (!privateTabMode) {
await ref
.read(bangSearchProvider.notifier)
- .triggerBangSearch(bang, value);
+ .triggerBangSearch(bang, searchQuery);
}
}
}
diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart
index e7c8e692..e56bb577 100644
--- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart
+++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_sites.dart
@@ -26,7 +26,6 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class ContainerSitesScreen extends HookConsumerWidget {
final Set initialSites;
@@ -91,11 +90,16 @@ class ContainerSitesScreen extends HookConsumerWidget {
return uriValid;
}
- final origin = Uri.parse(
- uri_parser
- .tryParseUrl(value, eagerParsing: true)!
- .origin,
+ final parsedUrl = parseValidatedUrl(
+ value,
+ eagerParsing: true,
+ onlyHttpProtocol: true,
);
+ if (parsedUrl == null) {
+ return 'Invalid URL';
+ }
+
+ final origin = Uri.parse(parsedUrl.origin);
if (sites.value.contains(origin)) {
return 'This site has been already assigned';
@@ -104,11 +108,16 @@ class ContainerSitesScreen extends HookConsumerWidget {
return null;
},
onSaved: (newValue) async {
- final origin = Uri.parse(
- uri_parser
- .tryParseUrl(newValue, eagerParsing: true)!
- .origin,
+ final parsedUrl = parseValidatedUrl(
+ newValue,
+ eagerParsing: true,
+ onlyHttpProtocol: true,
);
+ if (parsedUrl == null) {
+ return;
+ }
+
+ final origin = Uri.parse(parsedUrl.origin);
final isAssigned = await ref
.read(containerRepositoryProvider.notifier)
diff --git a/app/lib/features/share_intent/domain/entities/shared_content.dart b/app/lib/features/share_intent/domain/entities/shared_content.dart
index 371324ee..5d25d345 100644
--- a/app/lib/features/share_intent/domain/entities/shared_content.dart
+++ b/app/lib/features/share_intent/domain/entities/shared_content.dart
@@ -18,14 +18,13 @@
* along with this program. If not, see .
*/
import 'package:fast_equatable/fast_equatable.dart';
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
+import 'package:weblibre/utils/input_classification.dart';
sealed class SharedContent with FastEquatable {
SharedContent();
factory SharedContent.parse(String content) {
- if (uri_parser.tryParseUrl(content, eagerParsing: true)
- case final Uri uri) {
+ if (parseSharedIntentUrl(content) case final Uri uri) {
return SharedUrl(uri);
} else {
return SharedText(content);
diff --git a/app/lib/features/web_feed/presentation/add_feed_dialog.dart b/app/lib/features/web_feed/presentation/add_feed_dialog.dart
index b8ee3190..8311a03f 100644
--- a/app/lib/features/web_feed/presentation/add_feed_dialog.dart
+++ b/app/lib/features/web_feed/presentation/add_feed_dialog.dart
@@ -24,7 +24,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/web_feed/domain/providers/add_dialog_blocking.dart';
import 'package:weblibre/utils/form_validators.dart';
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
class AddFeedDialog extends HookConsumerWidget {
final Uri? initialUri;
@@ -83,9 +82,16 @@ class AddFeedDialog extends HookConsumerWidget {
TextButton(
onPressed: () {
if (formKey.currentState?.validate() == true) {
- FeedCreateRoute(
- feedId: uri_parser.tryParseUrl(textController.text)!,
- ).pushReplacement(context);
+ final feedId = parseValidatedUrl(
+ textController.text,
+ eagerParsing: false,
+ onlyHttpProtocol: true,
+ );
+ if (feedId == null) {
+ return;
+ }
+
+ FeedCreateRoute(feedId: feedId).pushReplacement(context);
}
},
child: const Text('Add'),
diff --git a/app/lib/features/web_feed/presentation/screens/feed_edit.dart b/app/lib/features/web_feed/presentation/screens/feed_edit.dart
index 6ad44b6b..043e8bc6 100644
--- a/app/lib/features/web_feed/presentation/screens/feed_edit.dart
+++ b/app/lib/features/web_feed/presentation/screens/feed_edit.dart
@@ -34,7 +34,6 @@ import 'package:weblibre/features/web_feed/presentation/widgets/tag_field.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/form_validators.dart';
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
enum _DialogMode { create, edit }
@@ -156,19 +155,22 @@ class _FeedEditContent extends HookConsumerWidget {
onPressed: () async {
if (formKey.currentState?.validate() ?? false) {
final feedData = FeedData(
- url: uri_parser.tryParseUrl(
+ url: parseValidatedUrl(
urlTextController.text,
- eagerParsing: true,
+ eagerParsing: false,
+ onlyHttpProtocol: true,
)!,
authors: initialFeed.authors,
description: descriptionTextController.text.whenNotEmpty,
- icon: uri_parser.tryParseUrl(
+ icon: parseValidatedUrl(
iconUrlTextController.text,
- eagerParsing: true,
+ eagerParsing: false,
+ onlyHttpProtocol: true,
),
- siteLink: uri_parser.tryParseUrl(
+ siteLink: parseValidatedUrl(
siteLinkTextController.text,
- eagerParsing: true,
+ eagerParsing: false,
+ onlyHttpProtocol: true,
),
tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(),
title: titleTextController.text.whenNotEmpty,
diff --git a/app/lib/utils/form_validators.dart b/app/lib/utils/form_validators.dart
index c9392d51..171aec34 100644
--- a/app/lib/utils/form_validators.dart
+++ b/app/lib/utils/form_validators.dart
@@ -21,8 +21,25 @@ import 'dart:io';
import 'package:nullability/nullability.dart';
import 'package:path/path.dart' as p;
-import 'package:weblibre/extensions/uri.dart';
-import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
+import 'package:weblibre/utils/uri_input_parser.dart';
+import 'package:weblibre/utils/uri_policy.dart';
+
+Uri? parseValidatedUrl(
+ String? value, {
+ required bool eagerParsing,
+ bool onlyHttpProtocol = false,
+}) {
+ final policy = onlyHttpProtocol
+ ? SchemePolicy.strictHttpOnly
+ : SchemePolicy.internalIntent;
+
+ return parseUserInputUrl(
+ value,
+ policy: policy,
+ allowSchemelessHosts: eagerParsing,
+ enforceMaxInputLength: true,
+ );
+}
String? validateUrl(
String? value, {
@@ -39,16 +56,18 @@ String? validateUrl(
}
}
- if (uri_parser.tryParseUrl(value, eagerParsing: eagerParsing)
+ if (parseValidatedUrl(
+ value,
+ eagerParsing: eagerParsing,
+ onlyHttpProtocol: onlyHttpProtocol,
+ )
case final Uri url) {
if (!requireAuthority || url.authority.isNotEmpty) {
- if (!onlyHttpProtocol || url.isHttpOrHttps) {
- return null;
- }
+ return null;
}
}
- return 'Inavlid URL';
+ return 'Invalid URL';
}
String? validateRequired(String? value, {String message = 'Value required'}) {
diff --git a/app/lib/utils/input_classification.dart b/app/lib/utils/input_classification.dart
new file mode 100644
index 00000000..ccd75a6e
--- /dev/null
+++ b/app/lib/utils/input_classification.dart
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'dart:io';
+
+import 'package:weblibre/utils/uri_input_parser.dart';
+import 'package:weblibre/utils/uri_policy.dart';
+
+enum NavigationReason {
+ explicitScheme,
+ schemelessHost,
+ localhost,
+ ipLiteral,
+ aboutLike,
+}
+
+enum SearchReason {
+ containsWhitespace,
+ notHostCandidate,
+ invalidHostChars,
+ fallback,
+}
+
+enum InvalidReason {
+ unsupportedScheme,
+ malformedUri,
+ containsControlChars,
+ emptyInput,
+ tooLong,
+}
+
+sealed class InputClassification {
+ const InputClassification();
+
+ const factory InputClassification.navigate(Uri uri, NavigationReason reason) =
+ NavigateInputClassification;
+ const factory InputClassification.search(String query, SearchReason reason) =
+ SearchInputClassification;
+ const factory InputClassification.invalid(String raw, InvalidReason reason) =
+ InvalidInputClassification;
+}
+
+final class NavigateInputClassification extends InputClassification {
+ final Uri uri;
+ final NavigationReason reason;
+
+ const NavigateInputClassification(this.uri, this.reason);
+}
+
+final class SearchInputClassification extends InputClassification {
+ final String query;
+ final SearchReason reason;
+
+ const SearchInputClassification(this.query, this.reason);
+}
+
+final class InvalidInputClassification extends InputClassification {
+ final String raw;
+ final InvalidReason reason;
+
+ const InvalidInputClassification(this.raw, this.reason);
+}
+
+InputClassification classifyAddressBarInput(
+ String input, {
+ SchemePolicy policy = SchemePolicy.addressBarTyped,
+}) {
+ final normalizedInput = normalizeInput(input);
+
+ if (normalizedInput.isEmpty) {
+ return InputClassification.invalid(
+ normalizedInput,
+ InvalidReason.emptyInput,
+ );
+ }
+
+ if (exceedsMaxInputLength(normalizedInput)) {
+ return InputClassification.invalid(normalizedInput, InvalidReason.tooLong);
+ }
+
+ if (containsControlChars(normalizedInput)) {
+ return InputClassification.invalid(
+ normalizedInput,
+ InvalidReason.containsControlChars,
+ );
+ }
+
+ if (hasExplicitScheme(normalizedInput)) {
+ final explicitUri = parseExplicitUri(normalizedInput, policy: policy);
+ if (explicitUri != null) {
+ final reason = explicitUri.isScheme('about')
+ ? NavigationReason.aboutLike
+ : NavigationReason.explicitScheme;
+
+ return InputClassification.navigate(explicitUri, reason);
+ }
+
+ if (hasAllowedScheme(normalizedInput, policy.allowedSchemes)) {
+ return InputClassification.invalid(
+ normalizedInput,
+ InvalidReason.malformedUri,
+ );
+ }
+
+ return InputClassification.invalid(
+ normalizedInput,
+ InvalidReason.unsupportedScheme,
+ );
+ }
+
+ if (normalizedInput.contains(whitespaceRegex)) {
+ return InputClassification.search(
+ normalizedInput,
+ SearchReason.containsWhitespace,
+ );
+ }
+
+ final schemelessUri = parseSchemelessWebHost(
+ normalizedInput,
+ allowedSchemes: policy.allowedSchemes,
+ );
+
+ if (schemelessUri != null) {
+ if (schemelessUri.host.toLowerCase() == 'localhost') {
+ return InputClassification.navigate(
+ schemelessUri,
+ NavigationReason.localhost,
+ );
+ }
+
+ if (InternetAddress.tryParse(schemelessUri.host) != null) {
+ return InputClassification.navigate(
+ schemelessUri,
+ NavigationReason.ipLiteral,
+ );
+ }
+
+ return InputClassification.navigate(
+ schemelessUri,
+ NavigationReason.schemelessHost,
+ );
+ }
+
+ if (looksLikeHostExpression(normalizedInput) &&
+ hasInvalidHostLikeChars(normalizedInput)) {
+ return InputClassification.search(
+ normalizedInput,
+ SearchReason.invalidHostChars,
+ );
+ }
+
+ return InputClassification.search(
+ normalizedInput,
+ SearchReason.notHostCandidate,
+ );
+}
+
+Uri? parseSharedIntentUrl(
+ String input, {
+ SchemePolicy policy = SchemePolicy.sharedIntent,
+}) {
+ final normalizedInput = normalizeInput(input);
+ if (normalizedInput.isEmpty ||
+ exceedsMaxInputLength(normalizedInput) ||
+ containsControlChars(normalizedInput)) {
+ return null;
+ }
+
+ if (hasExplicitScheme(normalizedInput)) {
+ return parseExplicitUri(normalizedInput, policy: policy);
+ }
+
+ if (normalizedInput.contains(whitespaceRegex)) {
+ return null;
+ }
+
+ return parseSchemelessWebHost(
+ normalizedInput,
+ allowedSchemes: policy.allowedSchemes,
+ );
+}
diff --git a/app/lib/utils/uri_input_parser.dart b/app/lib/utils/uri_input_parser.dart
new file mode 100644
index 00000000..7f1b90db
--- /dev/null
+++ b/app/lib/utils/uri_input_parser.dart
@@ -0,0 +1,245 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'dart:io';
+
+import 'package:weblibre/utils/uri_policy.dart';
+
+const maxUriInputLength = 4096;
+
+final _controlCharacterRegex = RegExp(r'[\x00-\x1F\u007F-\u009F]');
+final _explicitSchemeRegex = RegExp(r'^([a-zA-Z][a-zA-Z0-9+\-.]*):');
+final _hostPortSuffixRegex = RegExp(r'^\d{1,5}([/?#].*)?$');
+final _hostLabelRegex = RegExp(r'^[a-zA-Z0-9-]{1,63}$');
+final _topLevelDomainRegex = RegExp(r'^[a-zA-Z]{2,63}$');
+final _possibleHostCharsRegex = RegExp(
+ r"^[a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+$",
+);
+final whitespaceRegex = RegExp(r'\s');
+
+String normalizeInput(String input) {
+ return input.trim();
+}
+
+bool containsControlChars(String input) {
+ return _controlCharacterRegex.hasMatch(input);
+}
+
+bool hasAllowedScheme(String input, Set allowedSchemes) {
+ final scheme = _extractExplicitScheme(input);
+ if (scheme == null) {
+ return false;
+ }
+
+ return allowedSchemes.contains(scheme);
+}
+
+bool hasExplicitScheme(String input) {
+ final match = _explicitSchemeRegex.firstMatch(input);
+ if (match == null) {
+ return false;
+ }
+
+ final scheme = match.group(1)?.toLowerCase();
+ if (scheme == null) {
+ return false;
+ }
+
+ final suffix = input.substring(match.end);
+ if (suffix.startsWith('//')) {
+ return true;
+ }
+
+ // Keep host:port shorthand treated as schemeless input.
+ if ((scheme == 'localhost' || scheme.contains('.')) &&
+ _hostPortSuffixRegex.hasMatch(suffix)) {
+ return false;
+ }
+
+ return true;
+}
+
+bool exceedsMaxInputLength(String input, {int limit = maxUriInputLength}) {
+ return input.length > limit;
+}
+
+bool isValidHostCandidate(String hostCandidate) {
+ final host = hostCandidate.trim().toLowerCase();
+ if (host.isEmpty) {
+ return false;
+ }
+
+ if (host == 'localhost') {
+ return true;
+ }
+
+ if (InternetAddress.tryParse(host) != null) {
+ return true;
+ }
+
+ final labels = host.split('.');
+ if (labels.length < 2) {
+ return false;
+ }
+
+ if (!_topLevelDomainRegex.hasMatch(labels.last)) {
+ return false;
+ }
+
+ for (final label in labels) {
+ if (!_hostLabelRegex.hasMatch(label) ||
+ label.startsWith('-') ||
+ label.endsWith('-')) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+bool looksLikeHostExpression(String input) {
+ return input.contains('.') ||
+ input.contains(':') ||
+ input.startsWith('[') ||
+ input.contains('/');
+}
+
+bool hasInvalidHostLikeChars(String input) {
+ return !_possibleHostCharsRegex.hasMatch(input);
+}
+
+Uri? parseExplicitUri(
+ String input, {
+ required SchemePolicy policy,
+ bool enforceMaxInputLength = false,
+}) {
+ final normalizedInput = normalizeInput(input);
+ if (normalizedInput.isEmpty ||
+ (enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) ||
+ containsControlChars(normalizedInput) ||
+ !hasExplicitScheme(normalizedInput) ||
+ !hasAllowedScheme(normalizedInput, policy.allowedSchemes)) {
+ return null;
+ }
+
+ final uri = Uri.tryParse(normalizedInput);
+ if (uri == null) {
+ return null;
+ }
+
+ final scheme = uri.scheme.toLowerCase();
+ if (!policy.allows(scheme)) {
+ return null;
+ }
+
+ if (schemeRequiresAuthority(scheme) && uri.authority.isEmpty) {
+ return null;
+ }
+
+ return uri.replace(scheme: scheme);
+}
+
+Uri? parseSchemelessWebHost(
+ String input, {
+ Set? allowedSchemes,
+ bool enforceMaxInputLength = false,
+}) {
+ final normalizedInput = normalizeInput(input);
+ if (normalizedInput.isEmpty ||
+ (enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) ||
+ containsControlChars(normalizedInput) ||
+ hasExplicitScheme(normalizedInput) ||
+ normalizedInput.contains(whitespaceRegex)) {
+ return null;
+ }
+
+ final probeUri = Uri.tryParse('https://$normalizedInput');
+ if (probeUri == null || probeUri.host.isEmpty) {
+ return null;
+ }
+
+ if (!isValidHostCandidate(probeUri.host)) {
+ return null;
+ }
+
+ if (probeUri.hasPort && (probeUri.port < 1 || probeUri.port > 65535)) {
+ return null;
+ }
+
+ final scheme = probeUri.host.toLowerCase() == 'localhost' ? 'http' : 'https';
+ if (allowedSchemes != null && !allowedSchemes.contains(scheme)) {
+ return null;
+ }
+
+ return probeUri.replace(scheme: scheme);
+}
+
+Uri? parseUserInputUrl(
+ String? input, {
+ required SchemePolicy policy,
+ bool allowSchemelessHosts = false,
+ bool enforceMaxInputLength = false,
+}) {
+ if (input == null) {
+ return null;
+ }
+
+ final normalizedInput = normalizeInput(input);
+ if (normalizedInput.isEmpty ||
+ (enforceMaxInputLength && exceedsMaxInputLength(normalizedInput)) ||
+ containsControlChars(normalizedInput)) {
+ return null;
+ }
+
+ if (hasExplicitScheme(normalizedInput)) {
+ return parseExplicitUri(
+ normalizedInput,
+ policy: policy,
+ enforceMaxInputLength: enforceMaxInputLength,
+ );
+ }
+
+ if (!allowSchemelessHosts) {
+ return null;
+ }
+
+ return parseSchemelessWebHost(
+ normalizedInput,
+ allowedSchemes: policy.allowedSchemes,
+ enforceMaxInputLength: enforceMaxInputLength,
+ );
+}
+
+Uri redactUriCredentials(Uri uri) {
+ if (uri.userInfo.isEmpty) {
+ return uri;
+ }
+
+ return uri.replace(userInfo: '');
+}
+
+String? _extractExplicitScheme(String input) {
+ final match = _explicitSchemeRegex.firstMatch(input);
+ if (match == null) {
+ return null;
+ }
+
+ return match.group(1)?.toLowerCase();
+}
diff --git a/app/lib/utils/uri_parser.dart b/app/lib/utils/uri_parser.dart
index 2e0c0eb9..60046c1f 100644
--- a/app/lib/utils/uri_parser.dart
+++ b/app/lib/utils/uri_parser.dart
@@ -18,49 +18,13 @@
* along with this program. If not, see .
*/
-import 'dart:io';
-
-import 'package:weblibre/extensions/uri.dart';
-
-final _domainRegex = RegExp(r'\.[a-zA-Z]{2,}$');
+import 'package:weblibre/utils/uri_input_parser.dart';
+import 'package:weblibre/utils/uri_policy.dart';
Uri? tryParseUrl(String? input, {bool eagerParsing = false}) {
- if (input != null) {
- var uri = Uri.tryParse(input);
- if (uri != null) {
- if (uri.authority.isEmpty && eagerParsing) {
- if (uri.pathSegments.isNotEmpty) {
- int? port;
- var firstSegment = uri.pathSegments.first;
-
- //When there is no scheme while aprsing, the port becomse the first segment because : is treated as delimeter
- if (int.tryParse(firstSegment) case final int segmentPort) {
- port = segmentPort;
- firstSegment = uri.scheme;
- }
-
- if (_domainRegex.hasMatch(firstSegment)) {
- uri = Uri.tryParse('https://$input')?.replace(port: port);
- } else if (firstSegment == 'localhost') {
- uri = Uri.tryParse('http://$input')?.replace(port: port);
- } else if (InternetAddress.tryParse(firstSegment) != null) {
- uri = Uri.tryParse('https://$input')?.replace(port: port);
- }
- }
- }
-
- if (uri != null) {
- if (uri.isScheme('about')) {
- return uri;
- }
-
- if (uri.authority.isNotEmpty) {
- if (uri.hasSupportedScheme || !uri.hasScheme) {
- return uri;
- }
- }
- }
- }
- }
- return null;
+ return parseUserInputUrl(
+ input,
+ policy: SchemePolicy.internalIntent,
+ allowSchemelessHosts: eagerParsing,
+ );
}
diff --git a/app/lib/utils/uri_policy.dart b/app/lib/utils/uri_policy.dart
new file mode 100644
index 00000000..296bb9e6
--- /dev/null
+++ b/app/lib/utils/uri_policy.dart
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+class Scheme {
+ final String name;
+ final bool requiresAuthority;
+ const Scheme(this.name, {this.requiresAuthority = false});
+}
+
+const kSchemeHttp = Scheme('http', requiresAuthority: true);
+const kSchemeHttps = Scheme('https', requiresAuthority: true);
+const kSchemeFtp = Scheme('ftp', requiresAuthority: true);
+const kSchemeFile = Scheme('file');
+const kSchemeContent = Scheme('content', requiresAuthority: true);
+const kSchemeAbout = Scheme('about');
+const kSchemeMozExtension = Scheme('moz-extension', requiresAuthority: true);
+
+const allSupportedSchemes = [
+ kSchemeHttp,
+ kSchemeHttps,
+ kSchemeFtp,
+ kSchemeFile,
+ kSchemeContent,
+ kSchemeAbout,
+ kSchemeMozExtension,
+];
+
+const httpOnlySchemes = [kSchemeHttp, kSchemeHttps];
+
+bool schemeRequiresAuthority(String scheme) {
+ return allSupportedSchemes.any((s) => s.name == scheme && s.requiresAuthority);
+}
+
+enum SchemePolicy {
+ addressBarTyped,
+ strictHttpOnly,
+ sharedIntent,
+ internalIntent,
+}
+
+extension SchemePolicyX on SchemePolicy {
+ List get _schemes => switch (this) {
+ SchemePolicy.addressBarTyped => allSupportedSchemes,
+ SchemePolicy.strictHttpOnly => httpOnlySchemes,
+ SchemePolicy.sharedIntent => httpOnlySchemes,
+ SchemePolicy.internalIntent => allSupportedSchemes,
+ };
+
+ Set get allowedSchemes => _schemes.map((s) => s.name).toSet();
+
+ bool allows(String scheme) {
+ return allowedSchemes.contains(scheme.toLowerCase());
+ }
+}
diff --git a/app/test/features/share_intent/domain/entities/shared_content_test.dart b/app/test/features/share_intent/domain/entities/shared_content_test.dart
new file mode 100644
index 00000000..5cd48818
--- /dev/null
+++ b/app/test/features/share_intent/domain/entities/shared_content_test.dart
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/features/share_intent/domain/entities/shared_content.dart';
+
+void main() {
+ group('SharedContent.parse', () {
+ test('returns SharedUrl for explicit https uri', () {
+ final parsed = SharedContent.parse('https://weblibre.eu/path');
+
+ expect(parsed, isA());
+ expect((parsed as SharedUrl).url.host, 'weblibre.eu');
+ });
+
+ test('returns SharedText for explicit non-http scheme', () {
+ final parsed = SharedContent.parse('moz-extension://abc/index.html');
+
+ expect(parsed, isA());
+ });
+
+ test('returns SharedText for plain sentence', () {
+ final parsed = SharedContent.parse('WebLibre README.md');
+
+ expect(parsed, isA());
+ });
+ });
+}
diff --git a/app/test/utils/input_classification_test.dart b/app/test/utils/input_classification_test.dart
new file mode 100644
index 00000000..2dc6ed73
--- /dev/null
+++ b/app/test/utils/input_classification_test.dart
@@ -0,0 +1,242 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/utils/input_classification.dart';
+
+void main() {
+ group('classifyAddressBarInput', () {
+ test('searches free text containing whitespace', () {
+ final result = classifyAddressBarInput('WebLibre README.md');
+
+ expect(result, isA());
+ final search = result as SearchInputClassification;
+ expect(search.reason, SearchReason.containsWhitespace);
+ expect(search.query, 'WebLibre README.md');
+ });
+
+ test('navigates schemeless domain', () {
+ final result = classifyAddressBarInput('weblibre.eu');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.schemelessHost);
+ expect(navigation.uri.toString(), 'https://weblibre.eu');
+ });
+
+ test('navigates localhost over http', () {
+ final result = classifyAddressBarInput('localhost:8080');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.localhost);
+ expect(navigation.uri.toString(), 'http://localhost:8080');
+ });
+
+ test('navigates explicit moz-extension uri', () {
+ final result = classifyAddressBarInput('moz-extension://abc/index.html');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.explicitScheme);
+ expect(navigation.uri.scheme, 'moz-extension');
+ });
+
+ test('rejects explicit unsupported scheme as invalid', () {
+ final result = classifyAddressBarInput('myapp://callback?token=abc');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('rejects dotted explicit unsupported scheme as invalid', () {
+ final result = classifyAddressBarInput('my.app://callback');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('rejects javascript scheme as invalid', () {
+ final result = classifyAddressBarInput('JAVASCRIPT:alert(1)');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('does not decode encoded scheme before classification', () {
+ final result = classifyAddressBarInput('%6aavascript:alert(1)');
+
+ expect(result, isA());
+ });
+
+ test('rejects control chars as invalid', () {
+ final result = classifyAddressBarInput('example.com\x00path');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.containsControlChars);
+ });
+
+ test('invalid schemeless port falls back to search', () {
+ final result = classifyAddressBarInput('weblibre.eu:99999');
+
+ expect(result, isA());
+ });
+
+ test('explicit disallowed scheme with whitespace remains invalid', () {
+ final result = classifyAddressBarInput('javascript: alert(1)');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('unknown explicit scheme with whitespace remains invalid', () {
+ final result = classifyAddressBarInput('myapp: secret token');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('colon-prefixed query with whitespace is treated as invalid', () {
+ final result = classifyAddressBarInput('site:weblibre.eu privacy');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('symbolic colon query with whitespace is treated as invalid', () {
+ final result = classifyAddressBarInput('c++: tutorial');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('navigates schemeless domain with valid port', () {
+ final result = classifyAddressBarInput('weblibre.eu:8080');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.schemelessHost);
+ expect(navigation.uri.toString(), 'https://weblibre.eu:8080');
+ });
+
+ test('navigates IP literal', () {
+ final result = classifyAddressBarInput('192.168.1.1');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.ipLiteral);
+ expect(navigation.uri.toString(), 'https://192.168.1.1');
+ });
+
+ test('navigates explicit scheme with space in path', () {
+ final result = classifyAddressBarInput('https://example.com/a b');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.explicitScheme);
+ expect(navigation.uri.host, 'example.com');
+ });
+
+ test('rejects data scheme as invalid', () {
+ final result = classifyAddressBarInput(
+ 'data:text/html,',
+ );
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.unsupportedScheme);
+ });
+
+ test('treats missing scheme prefix as search', () {
+ final result = classifyAddressBarInput('://example.com');
+
+ expect(result, isA());
+ });
+
+ test('navigates URL with credentials', () {
+ final result = classifyAddressBarInput('https://user:pass@example.com');
+
+ expect(result, isA());
+ final navigation = result as NavigateInputClassification;
+ expect(navigation.reason, NavigationReason.explicitScheme);
+ expect(navigation.uri.host, 'example.com');
+ });
+
+ test('rejects very long input as tooLong', () {
+ final longInput = 'a' * 5000;
+ final result = classifyAddressBarInput(longInput);
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.tooLong);
+ });
+
+ test('rejects empty input as emptyInput', () {
+ final result = classifyAddressBarInput('');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.emptyInput);
+ });
+
+ test('rejects whitespace-only input as emptyInput', () {
+ final result = classifyAddressBarInput(' ');
+
+ expect(result, isA());
+ final invalid = result as InvalidInputClassification;
+ expect(invalid.reason, InvalidReason.emptyInput);
+ });
+ });
+
+ group('parseSharedIntentUrl', () {
+ test('parses explicit https', () {
+ final uri = parseSharedIntentUrl('https://weblibre.eu');
+
+ expect(uri, isNotNull);
+ expect(uri!.scheme, 'https');
+ });
+
+ test('treats explicit non-http scheme as non-url', () {
+ final uri = parseSharedIntentUrl('moz-extension://abc/index.html');
+
+ expect(uri, isNull);
+ });
+
+ test('treats explicit dotted non-http scheme as non-url', () {
+ final uri = parseSharedIntentUrl('my.app://callback');
+
+ expect(uri, isNull);
+ });
+
+ test('treats plain sentence as non-url', () {
+ final uri = parseSharedIntentUrl('This is WebLibre README.md');
+
+ expect(uri, isNull);
+ });
+ });
+}
diff --git a/app/test/utils/uri_input_parser_test.dart b/app/test/utils/uri_input_parser_test.dart
new file mode 100644
index 00000000..3f5dafec
--- /dev/null
+++ b/app/test/utils/uri_input_parser_test.dart
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:weblibre/utils/uri_input_parser.dart';
+import 'package:weblibre/utils/uri_policy.dart';
+
+void main() {
+ group('parseExplicitUri', () {
+ test('parses supported explicit scheme', () {
+ final uri = parseExplicitUri(
+ 'https://weblibre.eu/path',
+ policy: SchemePolicy.addressBarTyped,
+ );
+
+ expect(uri, isNotNull);
+ expect(uri!.scheme, 'https');
+ expect(uri.host, 'weblibre.eu');
+ });
+
+ test('rejects unsupported explicit scheme', () {
+ final uri = parseExplicitUri(
+ 'javascript:alert(1)',
+ policy: SchemePolicy.addressBarTyped,
+ );
+
+ expect(uri, isNull);
+ });
+
+ test('rejects malformed explicit uri with required authority', () {
+ final uri = parseExplicitUri(
+ 'https:///path',
+ policy: SchemePolicy.addressBarTyped,
+ );
+
+ expect(uri, isNull);
+ });
+ });
+
+ group('parseSchemelessWebHost', () {
+ test('upgrades domain to https', () {
+ final uri = parseSchemelessWebHost('weblibre.eu');
+
+ expect(uri, isNotNull);
+ expect(uri!.toString(), 'https://weblibre.eu');
+ });
+
+ test('upgrades localhost to http', () {
+ final uri = parseSchemelessWebHost('localhost:8080');
+
+ expect(uri, isNotNull);
+ expect(uri!.toString(), 'http://localhost:8080');
+ });
+
+ test('rejects spaces in host candidate', () {
+ expect(parseSchemelessWebHost('foo bar.com'), isNull);
+ });
+
+ test('rejects invalid port range', () {
+ expect(parseSchemelessWebHost('weblibre.eu:99999'), isNull);
+ });
+ });
+
+ group('parseUserInputUrl', () {
+ test('parses schemeless host only when enabled', () {
+ expect(
+ parseUserInputUrl('weblibre.eu', policy: SchemePolicy.addressBarTyped),
+ isNull,
+ );
+
+ expect(
+ parseUserInputUrl(
+ 'weblibre.eu',
+ policy: SchemePolicy.addressBarTyped,
+ allowSchemelessHosts: true,
+ ),
+ isNotNull,
+ );
+ });
+
+ test('rejects control chars', () {
+ expect(
+ parseUserInputUrl(
+ 'example.com\x00path',
+ policy: SchemePolicy.addressBarTyped,
+ allowSchemelessHosts: true,
+ ),
+ isNull,
+ );
+ });
+
+ test('allows long persisted urls when max length is not enforced', () {
+ final longPath = 'a' * 5000;
+ final uri = parseUserInputUrl(
+ 'https://weblibre.eu/$longPath',
+ policy: SchemePolicy.addressBarTyped,
+ );
+
+ expect(uri, isNotNull);
+ });
+
+ test('rejects long user input urls when max length is enforced', () {
+ final longPath = 'a' * 5000;
+ final uri = parseUserInputUrl(
+ 'https://weblibre.eu/$longPath',
+ policy: SchemePolicy.addressBarTyped,
+ enforceMaxInputLength: true,
+ );
+
+ expect(uri, isNull);
+ });
+ });
+
+ group('redactUriCredentials', () {
+ test('strips userInfo from URI', () {
+ final uri = Uri.parse('https://user:pass@example.com/path');
+ final redacted = redactUriCredentials(uri);
+
+ expect(redacted.userInfo, isEmpty);
+ expect(redacted.host, 'example.com');
+ expect(redacted.path, '/path');
+ expect(redacted.toString(), 'https://example.com/path');
+ });
+
+ test('is no-op for URIs without credentials', () {
+ final uri = Uri.parse('https://example.com/path');
+ final redacted = redactUriCredentials(uri);
+
+ expect(identical(redacted, uri), isTrue);
+ });
+ });
+
+ group('isValidHostCandidate', () {
+ test('rejects single-label hosts', () {
+ expect(isValidHostCandidate('example'), isFalse);
+ expect(isValidHostCandidate('intranet'), isFalse);
+ });
+ });
+
+ group('containsControlChars', () {
+ test('detects null bytes', () {
+ expect(containsControlChars('example\x00.com'), isTrue);
+ });
+
+ test('detects C1 control chars', () {
+ expect(containsControlChars('example\u0080.com'), isTrue);
+ expect(containsControlChars('example\u009F.com'), isTrue);
+ });
+
+ test('allows normal text', () {
+ expect(containsControlChars('example.com'), isFalse);
+ });
+ });
+}