improved uri parsing
This commit is contained in:
@@ -18,12 +18,12 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const _supportedSchemes = {'https', 'http', 'ftp', 'file', 'content', 'about'};
|
import 'package:weblibre/utils/uri_policy.dart';
|
||||||
|
|
||||||
extension UriX on Uri {
|
extension UriX on Uri {
|
||||||
Uri get base => Uri.parse('$scheme://$authority');
|
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 isHttp => isScheme('http');
|
||||||
bool get isHttps => isScheme('https');
|
bool get isHttps => isScheme('https');
|
||||||
|
|||||||
@@ -88,7 +88,14 @@ class EditBangScreen extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final uri = Uri.parse(urlTextController.text);
|
final uri = parseValidatedUrl(
|
||||||
|
urlTextController.text,
|
||||||
|
eagerParsing: false,
|
||||||
|
onlyHttpProtocol: true,
|
||||||
|
);
|
||||||
|
if (uri == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final bang = Bang(
|
final bang = Bang(
|
||||||
group: BangGroup.user,
|
group: BangGroup.user,
|
||||||
|
|||||||
+10
-3
@@ -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/dialogs/delete_bookmark_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.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/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 {
|
class BookmarkEntryEditScreen extends HookConsumerWidget {
|
||||||
final BookmarkInfo? initialInfo;
|
final BookmarkInfo? initialInfo;
|
||||||
@@ -66,10 +66,17 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (formKey.currentState?.validate() ?? false) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
final newUrl = uri_parser.tryParseUrl(
|
var newUrl = parseValidatedUrl(
|
||||||
urlTextController.text,
|
urlTextController.text,
|
||||||
eagerParsing: true,
|
eagerParsing: true,
|
||||||
)!;
|
onlyHttpProtocol: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newUrl = redactUriCredentials(newUrl);
|
||||||
|
|
||||||
if (exisitingEntry != null) {
|
if (exisitingEntry != null) {
|
||||||
await ref
|
await ref
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/utils/uri_input_parser.dart';
|
||||||
|
|
||||||
class BookmarkJSONUtils {
|
class BookmarkJSONUtils {
|
||||||
final GeckoBookmarksService _service;
|
final GeckoBookmarksService _service;
|
||||||
@@ -156,7 +157,11 @@ class BookmarkJSONUtils {
|
|||||||
await _service.addItem(parentGuid, uri, title, i);
|
await _service.addItem(parentGuid, uri, title, i);
|
||||||
count++;
|
count++;
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
logger.e('Failed to import bookmark "$title": $e');
|
logger.e('Failed to import bookmark "$title": $e');
|
||||||
|
|||||||
+23
-4
@@ -74,7 +74,10 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, [currentUrl]);
|
}, [currentUrl]);
|
||||||
|
|
||||||
final parsedDebouncedUrl = Uri.tryParse(debouncedUrl.value);
|
final parsedDebouncedUrl = parseValidatedUrl(
|
||||||
|
debouncedUrl.value,
|
||||||
|
eagerParsing: false,
|
||||||
|
);
|
||||||
final hasExternalApp = useCachedFuture(
|
final hasExternalApp = useCachedFuture(
|
||||||
// ignore: discarded_futures useFuture
|
// ignore: discarded_futures useFuture
|
||||||
() => parsedDebouncedUrl != null
|
() => parsedDebouncedUrl != null
|
||||||
@@ -163,10 +166,18 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
|
|
||||||
Future<void> openTab(TabMode tabMode) async {
|
Future<void> openTab(TabMode tabMode) async {
|
||||||
if (formKey.currentState?.validate() == true) {
|
if (formKey.currentState?.validate() == true) {
|
||||||
|
final parsedUrl = parseValidatedUrl(
|
||||||
|
textController.text,
|
||||||
|
eagerParsing: false,
|
||||||
|
);
|
||||||
|
if (parsedUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(tabRepositoryProvider.notifier)
|
.read(tabRepositoryProvider.notifier)
|
||||||
.addTab(
|
.addTab(
|
||||||
url: Uri.parse(textController.text),
|
url: parsedUrl,
|
||||||
tabMode: tabMode,
|
tabMode: tabMode,
|
||||||
containerSelection: selectedContainer.value == null
|
containerSelection: selectedContainer.value == null
|
||||||
? const TabContainerSelection.unassigned()
|
? const TabContainerSelection.unassigned()
|
||||||
@@ -183,8 +194,16 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
|
|
||||||
Future<void> openCustomTab(bool isPrivate) async {
|
Future<void> openCustomTab(bool isPrivate) async {
|
||||||
if (formKey.currentState?.validate() == true) {
|
if (formKey.currentState?.validate() == true) {
|
||||||
|
final parsedUrl = parseValidatedUrl(
|
||||||
|
textController.text,
|
||||||
|
eagerParsing: false,
|
||||||
|
);
|
||||||
|
if (parsedUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await GeckoBrowserService().openInCustomTab(
|
await GeckoBrowserService().openInCustomTab(
|
||||||
url: Uri.parse(textController.text),
|
url: parsedUrl,
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
contextId: selectedContainer.value?.id,
|
contextId: selectedContainer.value?.id,
|
||||||
);
|
);
|
||||||
@@ -197,7 +216,7 @@ class OpenSharedContent extends HookConsumerWidget {
|
|||||||
|
|
||||||
Future<void> openInApp() async {
|
Future<void> openInApp() async {
|
||||||
if (formKey.currentState?.validate() == true) {
|
if (formKey.currentState?.validate() == true) {
|
||||||
final uri = Uri.tryParse(textController.text);
|
final uri = parseValidatedUrl(textController.text, eagerParsing: false);
|
||||||
if (uri == null) return;
|
if (uri == null) return;
|
||||||
|
|
||||||
final success = await _appLinksService.openAppLink(uri);
|
final success = await _appLinksService.openAppLink(uri);
|
||||||
|
|||||||
+16
-3
@@ -20,14 +20,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'
|
||||||
show GeckoBrowserService;
|
show GeckoBrowserService;
|
||||||
|
|
||||||
import 'package:weblibre/utils/ui_helper.dart';
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
import 'package:weblibre/utils/uri_input_parser.dart';
|
||||||
|
import 'package:weblibre/utils/uri_policy.dart';
|
||||||
|
|
||||||
Future<void> openInPrivateCustomTab(BuildContext context, String url) async {
|
Future<void> openInPrivateCustomTab(BuildContext context, String url) async {
|
||||||
try {
|
try {
|
||||||
await GeckoBrowserService().openInCustomTab(
|
final parsedUrl = parseUserInputUrl(
|
||||||
url: Uri.parse(url),
|
url,
|
||||||
private: true,
|
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) {
|
} catch (e) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
showErrorMessage(context, 'Could not open link: $url');
|
showErrorMessage(context, 'Could not open link: $url');
|
||||||
|
|||||||
@@ -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/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
|
||||||
import 'package:weblibre/presentation/hooks/sampled_value_notifier.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/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 {
|
class SearchScreen extends HookConsumerWidget {
|
||||||
final String? initialSearchText;
|
final String? initialSearchText;
|
||||||
@@ -358,12 +359,26 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
unfocusOnTapOutside: false,
|
unfocusOnTapOutside: false,
|
||||||
onSubmitted: (value) async {
|
onSubmitted: (value) async {
|
||||||
if (value.isNotEmpty) {
|
if (value.isNotEmpty) {
|
||||||
var newUrl = uri_parser.tryParseUrl(
|
final classification = classifyAddressBarInput(value);
|
||||||
value,
|
Uri? newUrl;
|
||||||
eagerParsing: true,
|
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
|
// Read from both providers - use site if set, otherwise global
|
||||||
final siteBang = isEditMode
|
final siteBang = isEditMode
|
||||||
? ref.read(
|
? ref.read(
|
||||||
@@ -383,12 +398,12 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (bang != null) {
|
if (bang != null) {
|
||||||
newUrl = bang.getTemplateUrl(value);
|
newUrl = bang.getTemplateUrl(searchQuery);
|
||||||
|
|
||||||
if (!privateTabMode) {
|
if (!privateTabMode) {
|
||||||
await ref
|
await ref
|
||||||
.read(bangSearchProvider.notifier)
|
.read(bangSearchProvider.notifier)
|
||||||
.triggerBangSearch(bang, value);
|
.triggerBangSearch(bang, searchQuery);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/presentation/widgets/url_icon.dart';
|
||||||
import 'package:weblibre/utils/form_validators.dart';
|
import 'package:weblibre/utils/form_validators.dart';
|
||||||
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
|
||||||
|
|
||||||
class ContainerSitesScreen extends HookConsumerWidget {
|
class ContainerSitesScreen extends HookConsumerWidget {
|
||||||
final Set<Uri> initialSites;
|
final Set<Uri> initialSites;
|
||||||
@@ -91,11 +90,16 @@ class ContainerSitesScreen extends HookConsumerWidget {
|
|||||||
return uriValid;
|
return uriValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
final origin = Uri.parse(
|
final parsedUrl = parseValidatedUrl(
|
||||||
uri_parser
|
value,
|
||||||
.tryParseUrl(value, eagerParsing: true)!
|
eagerParsing: true,
|
||||||
.origin,
|
onlyHttpProtocol: true,
|
||||||
);
|
);
|
||||||
|
if (parsedUrl == null) {
|
||||||
|
return 'Invalid URL';
|
||||||
|
}
|
||||||
|
|
||||||
|
final origin = Uri.parse(parsedUrl.origin);
|
||||||
|
|
||||||
if (sites.value.contains(origin)) {
|
if (sites.value.contains(origin)) {
|
||||||
return 'This site has been already assigned';
|
return 'This site has been already assigned';
|
||||||
@@ -104,11 +108,16 @@ class ContainerSitesScreen extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
onSaved: (newValue) async {
|
onSaved: (newValue) async {
|
||||||
final origin = Uri.parse(
|
final parsedUrl = parseValidatedUrl(
|
||||||
uri_parser
|
newValue,
|
||||||
.tryParseUrl(newValue, eagerParsing: true)!
|
eagerParsing: true,
|
||||||
.origin,
|
onlyHttpProtocol: true,
|
||||||
);
|
);
|
||||||
|
if (parsedUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final origin = Uri.parse(parsedUrl.origin);
|
||||||
|
|
||||||
final isAssigned = await ref
|
final isAssigned = await ref
|
||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
|
|||||||
@@ -18,14 +18,13 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
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 {
|
sealed class SharedContent with FastEquatable {
|
||||||
SharedContent();
|
SharedContent();
|
||||||
|
|
||||||
factory SharedContent.parse(String content) {
|
factory SharedContent.parse(String content) {
|
||||||
if (uri_parser.tryParseUrl(content, eagerParsing: true)
|
if (parseSharedIntentUrl(content) case final Uri uri) {
|
||||||
case final Uri uri) {
|
|
||||||
return SharedUrl(uri);
|
return SharedUrl(uri);
|
||||||
} else {
|
} else {
|
||||||
return SharedText(content);
|
return SharedText(content);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/web_feed/domain/providers/add_dialog_blocking.dart';
|
import 'package:weblibre/features/web_feed/domain/providers/add_dialog_blocking.dart';
|
||||||
import 'package:weblibre/utils/form_validators.dart';
|
import 'package:weblibre/utils/form_validators.dart';
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
|
||||||
|
|
||||||
class AddFeedDialog extends HookConsumerWidget {
|
class AddFeedDialog extends HookConsumerWidget {
|
||||||
final Uri? initialUri;
|
final Uri? initialUri;
|
||||||
@@ -83,9 +82,16 @@ class AddFeedDialog extends HookConsumerWidget {
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (formKey.currentState?.validate() == true) {
|
if (formKey.currentState?.validate() == true) {
|
||||||
FeedCreateRoute(
|
final feedId = parseValidatedUrl(
|
||||||
feedId: uri_parser.tryParseUrl(textController.text)!,
|
textController.text,
|
||||||
).pushReplacement(context);
|
eagerParsing: false,
|
||||||
|
onlyHttpProtocol: true,
|
||||||
|
);
|
||||||
|
if (feedId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FeedCreateRoute(feedId: feedId).pushReplacement(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Add'),
|
child: const Text('Add'),
|
||||||
|
|||||||
@@ -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/failure_widget.dart';
|
||||||
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
import 'package:weblibre/presentation/widgets/url_icon.dart';
|
||||||
import 'package:weblibre/utils/form_validators.dart';
|
import 'package:weblibre/utils/form_validators.dart';
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
|
||||||
|
|
||||||
enum _DialogMode { create, edit }
|
enum _DialogMode { create, edit }
|
||||||
|
|
||||||
@@ -156,19 +155,22 @@ class _FeedEditContent extends HookConsumerWidget {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (formKey.currentState?.validate() ?? false) {
|
if (formKey.currentState?.validate() ?? false) {
|
||||||
final feedData = FeedData(
|
final feedData = FeedData(
|
||||||
url: uri_parser.tryParseUrl(
|
url: parseValidatedUrl(
|
||||||
urlTextController.text,
|
urlTextController.text,
|
||||||
eagerParsing: true,
|
eagerParsing: false,
|
||||||
|
onlyHttpProtocol: true,
|
||||||
)!,
|
)!,
|
||||||
authors: initialFeed.authors,
|
authors: initialFeed.authors,
|
||||||
description: descriptionTextController.text.whenNotEmpty,
|
description: descriptionTextController.text.whenNotEmpty,
|
||||||
icon: uri_parser.tryParseUrl(
|
icon: parseValidatedUrl(
|
||||||
iconUrlTextController.text,
|
iconUrlTextController.text,
|
||||||
eagerParsing: true,
|
eagerParsing: false,
|
||||||
|
onlyHttpProtocol: true,
|
||||||
),
|
),
|
||||||
siteLink: uri_parser.tryParseUrl(
|
siteLink: parseValidatedUrl(
|
||||||
siteLinkTextController.text,
|
siteLinkTextController.text,
|
||||||
eagerParsing: true,
|
eagerParsing: false,
|
||||||
|
onlyHttpProtocol: true,
|
||||||
),
|
),
|
||||||
tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(),
|
tags: tags.value.map((tag) => FeedCategory(id: tag)).toList(),
|
||||||
title: titleTextController.text.whenNotEmpty,
|
title: titleTextController.text.whenNotEmpty,
|
||||||
|
|||||||
@@ -21,8 +21,25 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:nullability/nullability.dart';
|
import 'package:nullability/nullability.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:weblibre/extensions/uri.dart';
|
import 'package:weblibre/utils/uri_input_parser.dart';
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
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? validateUrl(
|
||||||
String? value, {
|
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) {
|
case final Uri url) {
|
||||||
if (!requireAuthority || url.authority.isNotEmpty) {
|
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'}) {
|
String? validateRequired(String? value, {String message = 'Value required'}) {
|
||||||
|
|||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<String> 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<String>? 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();
|
||||||
|
}
|
||||||
@@ -18,49 +18,13 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'dart:io';
|
import 'package:weblibre/utils/uri_input_parser.dart';
|
||||||
|
import 'package:weblibre/utils/uri_policy.dart';
|
||||||
import 'package:weblibre/extensions/uri.dart';
|
|
||||||
|
|
||||||
final _domainRegex = RegExp(r'\.[a-zA-Z]{2,}$');
|
|
||||||
|
|
||||||
Uri? tryParseUrl(String? input, {bool eagerParsing = false}) {
|
Uri? tryParseUrl(String? input, {bool eagerParsing = false}) {
|
||||||
if (input != null) {
|
return parseUserInputUrl(
|
||||||
var uri = Uri.tryParse(input);
|
input,
|
||||||
if (uri != null) {
|
policy: SchemePolicy.internalIntent,
|
||||||
if (uri.authority.isEmpty && eagerParsing) {
|
allowSchemelessHosts: 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<Scheme> get _schemes => switch (this) {
|
||||||
|
SchemePolicy.addressBarTyped => allSupportedSchemes,
|
||||||
|
SchemePolicy.strictHttpOnly => httpOnlySchemes,
|
||||||
|
SchemePolicy.sharedIntent => httpOnlySchemes,
|
||||||
|
SchemePolicy.internalIntent => allSupportedSchemes,
|
||||||
|
};
|
||||||
|
|
||||||
|
Set<String> get allowedSchemes => _schemes.map((s) => s.name).toSet();
|
||||||
|
|
||||||
|
bool allows(String scheme) {
|
||||||
|
return allowedSchemes.contains(scheme.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<SharedUrl>());
|
||||||
|
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<SharedText>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns SharedText for plain sentence', () {
|
||||||
|
final parsed = SharedContent.parse('WebLibre README.md');
|
||||||
|
|
||||||
|
expect(parsed, isA<SharedText>());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<SearchInputClassification>());
|
||||||
|
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<NavigateInputClassification>());
|
||||||
|
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<NavigateInputClassification>());
|
||||||
|
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<NavigateInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
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<SearchInputClassification>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects control chars as invalid', () {
|
||||||
|
final result = classifyAddressBarInput('example.com\x00path');
|
||||||
|
|
||||||
|
expect(result, isA<InvalidInputClassification>());
|
||||||
|
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<SearchInputClassification>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit disallowed scheme with whitespace remains invalid', () {
|
||||||
|
final result = classifyAddressBarInput('javascript: alert(1)');
|
||||||
|
|
||||||
|
expect(result, isA<InvalidInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
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<NavigateInputClassification>());
|
||||||
|
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<NavigateInputClassification>());
|
||||||
|
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<NavigateInputClassification>());
|
||||||
|
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,<script>alert(1)</script>',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, isA<InvalidInputClassification>());
|
||||||
|
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<SearchInputClassification>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigates URL with credentials', () {
|
||||||
|
final result = classifyAddressBarInput('https://user:pass@example.com');
|
||||||
|
|
||||||
|
expect(result, isA<NavigateInputClassification>());
|
||||||
|
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<InvalidInputClassification>());
|
||||||
|
final invalid = result as InvalidInputClassification;
|
||||||
|
expect(invalid.reason, InvalidReason.tooLong);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty input as emptyInput', () {
|
||||||
|
final result = classifyAddressBarInput('');
|
||||||
|
|
||||||
|
expect(result, isA<InvalidInputClassification>());
|
||||||
|
final invalid = result as InvalidInputClassification;
|
||||||
|
expect(invalid.reason, InvalidReason.emptyInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects whitespace-only input as emptyInput', () {
|
||||||
|
final result = classifyAddressBarInput(' ');
|
||||||
|
|
||||||
|
expect(result, isA<InvalidInputClassification>());
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user