Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 59 additions & 19 deletions Flow.Launcher.Test/Plugins/UrlPluginTest.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,73 @@
using NUnit.Framework;

Check warning on line 1 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`NUnit` is not a recognized word. (unrecognized-spelling)
using NUnit.Framework.Legacy;
using Flow.Launcher.Plugin.Url;
using System.Reflection;

namespace Flow.Launcher.Test.Plugins
{
[TestFixture]
public class UrlPluginTest
{
[Test]
public void URLMatchTest()
private static Main plugin;

[OneTimeSetUp]
public void OneTimeSetup()
{
var plugin = new Main();
ClassicAssert.IsTrue(plugin.IsURL("http://www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("https://www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("http://google.com"));
ClassicAssert.IsTrue(plugin.IsURL("www.google.com"));
ClassicAssert.IsTrue(plugin.IsURL("google.com"));
ClassicAssert.IsTrue(plugin.IsURL("http://localhost"));
ClassicAssert.IsTrue(plugin.IsURL("https://localhost"));
ClassicAssert.IsTrue(plugin.IsURL("http://localhost:80"));
ClassicAssert.IsTrue(plugin.IsURL("https://localhost:80"));
ClassicAssert.IsTrue(plugin.IsURL("http://110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("110.10.10.10"));
ClassicAssert.IsTrue(plugin.IsURL("ftp://110.10.10.10"));
var settingsField = typeof(Main).GetField("Settings", BindingFlags.NonPublic | BindingFlags.Static);
settingsField?.SetValue(null, new Settings());
Comment on lines +15 to +16
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OneTimeSetUp uses reflection to set the static Settings field directly, which is a workaround because the Main.Init() method is not called. This approach bypasses the normal initialization flow and creates a testing pattern that doesn't reflect how the plugin actually works. Consider refactoring the Main class to make it more testable, perhaps by injecting dependencies or providing a test-friendly initialization method, rather than using reflection to manipulate internal state.

Copilot uses AI. Check for mistakes.

plugin = new Main();
}

[TestCase("http://www.google.com")]
[TestCase("https://www.google.com")]
[TestCase("http://google.com")]
[TestCase("ftp://google.com")]
[TestCase("www.google.com")]

Check warning on line 25 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`google` is not a recognized word. (unrecognized-spelling)
[TestCase("google.com")]

Check warning on line 26 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`google` is not a recognized word. (unrecognized-spelling)

Check warning on line 26 in Flow.Launcher.Test/Plugins/UrlPluginTest.cs

View workflow job for this annotation

GitHub Actions / Check Spelling

`google` is not a recognized word. (unrecognized-spelling)
[TestCase("http://localhost")]
[TestCase("https://localhost")]
[TestCase("http://localhost:80")]
[TestCase("https://localhost:80")]
[TestCase("localhost")]
[TestCase("localhost:8080")]
[TestCase("http://110.10.10.10")]
[TestCase("110.10.10.10")]
[TestCase("110.10.10.10:8080")]
[TestCase("192.168.1.1")]
[TestCase("192.168.1.1:3000")]
[TestCase("ftp://110.10.10.10")]
[TestCase("[2001:db8::1]")]
[TestCase("[2001:db8::1]:8080")]
[TestCase("http://[2001:db8::1]")]
[TestCase("https://[2001:db8::1]:8080")]
[TestCase("[::1]")]
[TestCase("[::1]:8080")]
[TestCase("2001:db8::1")]
[TestCase("fe80:1:2::3:4")]
[TestCase("::1")]
[TestCase("HTTP://EXAMPLE.COM")]
[TestCase("HTTPS://EXAMPLE.COM")]
[TestCase("EXAMPLE.COM")]
[TestCase("LOCALHOST")]
public void WhenValidUrlThenIsUrlReturnsTrue(string url)
{
Assert.That(plugin.IsURL(url), Is.True);
}

ClassicAssert.IsFalse(plugin.IsURL("wwww"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
ClassicAssert.IsFalse(plugin.IsURL("wwww.c"));
[TestCase("wwww")]
[TestCase("wwww.c")]
[TestCase("not a url")]
[TestCase("just text")]
[TestCase("http://")]
[TestCase("://example.com")]
[TestCase("0.0.0.0")] // Pattern excludes 0.0.0.0
[TestCase("256.1.1.1")] // Invalid IPv4
[TestCase("example")] // No TLD
[TestCase(".com")]
[TestCase("http://.com")]
public void WhenInvalidUrlThenIsUrlReturnsFalse(string url)
{
Assert.That(plugin.IsURL(url), Is.False);
}
}
}
63 changes: 34 additions & 29 deletions Plugins/Flow.Launcher.Plugin.Url/Main.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Controls;
using Flow.Launcher.Plugin.SharedCommands;
Expand All @@ -15,19 +16,28 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider
// user:pass authentication
"(?:\\S+(?::\\S*)?@)?" +
"(?:" +
// IP address exclusion
// private & local networks
"(?!(?:10|127)(?:\\.\\d{1,3}){3})" +
"(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})" +
"(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})" +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broacast addresses
// (first & last IP address of each class)
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
// IPv6 address with optional brackets (brackets required if followed by port)
// IPv6 with brackets
"(?:\\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\]|" + // standard IPv6
"\\[(?:[0-9a-fA-F]{1,4}:){1,7}:\\]|" + // IPv6 with trailing ::
"\\[(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}\\]|" + // IPv6 compressed
"\\[::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with leading ::
"\\[(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\\]|" + // IPv6 with :: in the middle
"\\[::1\\])" + // IPv6 loopback
"|" +
Comment on lines +21 to +27
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VictoriousRaptor instead of a massive regex string, would Uri.TryCreate work for our scenarios? (Also IPAddress.TryParse for IPs)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@VictoriousRaptor instead of a massive regex string, would Uri.TryCreate work for our scenarios? (Also IPAddress.TryParse for IPs)

I did try but they made it more complicated. Uri.TryCreate can't deal with strings without a xxx:// scheme and IPAddress.TryParse can't parse strings with a scheme. I didnt figure out how to mix them, but we need both of them parsed.

// IPv6 without brackets (only when no port follows)
"(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|" + // standard IPv6
"(?:[0-9a-fA-F]{1,4}:){1,7}:|" + // IPv6 with trailing ::
"(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|" + // IPv6 compressed
"::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}|" + // IPv6 with leading ::
"(?:(?:[0-9a-fA-F]{1,4}:){1,6}|:):(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}|" + // IPv6 with :: in the middle
"::1)(?!:[0-9])" + // IPv6 loopback (not followed by port)
Comment on lines +29 to +34
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative lookahead (?!:[0-9]) only applies to the "::1" pattern on line 34, not to the other unbracketed IPv6 patterns (lines 29-33). This means addresses like "2001:db8::1" could be followed by ":8080" and the pattern would try to interpret ":8080" as part of the IPv6 address rather than as a port number, leading to ambiguous parsing. RFC 3986 requires brackets around IPv6 addresses in URLs specifically to avoid this ambiguity. The negative lookahead should apply to all unbracketed IPv6 alternatives, or unbracketed IPv6 support should be reconsidered given the inherent ambiguity.

Copilot uses AI. Check for mistakes.
"|" +
// IPv4 address - all valid addresses including private networks (excluding 0.0.0.0)
"(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|[1-9])\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d))" +
"|" +
// localhost
"localhost" +
"|" +
// host name
"(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
Expand All @@ -37,20 +47,25 @@ public class Main : IPlugin, IPluginI18n, ISettingProvider
"(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" +
")" +
// port number
"(?::\\d{2,5})?" +
"(?::\\d{1,5})?" +
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The port pattern :\d{1,5} allows ports from 1 to 99999, but valid port numbers range from 1 to 65535. This will incorrectly match invalid ports like ":99999" or ":70000". While the original code had the same issue with high ports, changing from \d{2,5} to \d{1,5} now also allows single-digit ports (which are valid but uncommon). Consider using a more precise regex pattern that validates the actual port range (1-65535), such as :(6553[0-5]|655[0-2][0-9]|65[0-4][0-9]{2}|6[0-4][0-9]{3}|[1-5][0-9]{4}|[1-9][0-9]{0,3}).

Copilot uses AI. Check for mistakes.
// resource path
"(?:/\\S*)?" +
"$";
private readonly Regex UrlRegex = new(UrlPattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static PluginInitContext Context { get; private set; }
internal static Settings Settings { get; private set; }

private static readonly string[] UrlSchemes = ["http://", "https://", "ftp://"];

public List<Result> Query(Query query)
{
var raw = query.Search;
if (IsURL(raw))
if (!IsURL(raw))
{
return
return [];
}

return
[
new()
{
Expand All @@ -60,7 +75,8 @@ public List<Result> Query(Query query)
Score = 8,
Action = _ =>
{
if (!raw.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !raw.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
// not a recognized scheme, add preferred http scheme
if (!UrlSchemes.Any(scheme => raw.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)))
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code uses UrlSchemes.Any(scheme => raw.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)) which iterates through the array and performs string comparisons for each scheme. Given that this array only contains 3 elements, the performance impact is minimal. However, for consistency with the original approach and slightly better performance, you could use explicit OR conditions with StartsWith for each scheme, avoiding the LINQ overhead entirely.

Suggested change
if (!UrlSchemes.Any(scheme => raw.StartsWith(scheme, StringComparison.OrdinalIgnoreCase)))
if (!(raw.StartsWith(UrlSchemes[0], StringComparison.OrdinalIgnoreCase)
|| raw.StartsWith(UrlSchemes[1], StringComparison.OrdinalIgnoreCase)
|| raw.StartsWith(UrlSchemes[2], StringComparison.OrdinalIgnoreCase)))

Copilot uses AI. Check for mistakes.
{
raw = GetHttpPreference() + "://" + raw;
}
Expand Down Expand Up @@ -92,9 +108,6 @@ public List<Result> Query(Query query)
}
}
];
}

return [];
}

private static string GetHttpPreference()
Expand All @@ -108,14 +121,6 @@ public bool IsURL(string raw)

if (UrlRegex.Match(raw).Value == raw) return true;

if (raw == "localhost" || raw.StartsWith("localhost:") ||
raw == "http://localhost" || raw.StartsWith("http://localhost:") ||
raw == "https://localhost" || raw.StartsWith("https://localhost:")
)
{
return true;
}

return false;
}

Expand Down
Loading