Skip to content
Merged
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
22 changes: 16 additions & 6 deletions GenHub/GenHub.Core/Constants/CommandLineConstants.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
namespace GenHub.Core.Constants;

/// <summary>
/// Constants for command line arguments and URI schemes.
/// Constants for command line arguments and the <c>genhub://</c> URI scheme.
/// </summary>
/// <remarks>
/// Subscription links use <c>genhub://subscribe?url=&lt;absolute-url&gt;</c>.
/// Today <c>url</c> is a hosted GenHub <c>catalog.json</c>. Publisher Studio will also share
/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time.
/// </remarks>
public static class CommandLineConstants
{
/// <summary>
Expand All @@ -16,22 +21,27 @@ public static class CommandLineConstants
public const string LaunchProfileInlinePrefix = "--launch-profile=";

/// <summary>
/// URI scheme used for protocol handling.
/// Scheme name for custom protocol registration.
/// </summary>
public const string UriScheme = "genhub://";
public const string SchemeName = "genhub";

/// <summary>
/// Command for subscribing to a catalog via URI.
/// Custom URI scheme registered so OS/browser links can open GenHub.
/// </summary>
public const string UriScheme = SchemeName + "://";

/// <summary>
/// URI path segment for content subscription (<c>genhub://subscribe?url=...</c>).
/// </summary>
public const string SubscribeCommand = "subscribe";

/// <summary>
/// Full prefix for subscription URI.
/// Full prefix for subscription URIs (<c>genhub://subscribe</c>).
/// </summary>
public const string SubscribeUriPrefix = UriScheme + SubscribeCommand;

/// <summary>
/// Query parameter name for the catalog URL in a subscription URI.
/// Query parameter carrying the absolute URL of a catalog (or future provider definition).
/// </summary>
public const string SubscribeUrlParam = "?url=";
}
3 changes: 2 additions & 1 deletion GenHub/GenHub.Core/Constants/IpcCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public static class IpcCommands
public const string LaunchProfilePrefix = "launch-profile:";

/// <summary>
/// Command prefix used to subscribe to a catalog via IPC.
/// Command prefix used to forward a subscribe URL to the primary instance
/// (<c>subscribe:&lt;absolute-url&gt;</c>). Same payload as <c>genhub://subscribe?url=...</c>.
/// </summary>
public const string SubscribePrefix = "subscribe:";
}
45 changes: 35 additions & 10 deletions GenHub/GenHub.Core/Helpers/CommandLineParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ public static class CommandLineParser
/// <returns>The extracted profile identifier if present; otherwise, <c>null</c>.</returns>
public static string? ExtractProfileId(string[] args)
{
for (var i = 0; i < args.Length; i++)
for (int i = 0; i < args.Length; i++)
{
var arg = args[i];
string arg = args[i];

if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
Expand All @@ -34,23 +34,48 @@ public static class CommandLineParser
}

/// <summary>
/// Extracts a subscription URL from command line arguments.
/// Supports the URI scheme format: <c>genhub://subscribe?url=&lt;url&gt;</c>.
/// Extracts the absolute URL from a <c>genhub://subscribe?url=...</c> startup argument.
/// </summary>
/// <remarks>
/// The returned value is the <c>url</c> query value only (not the <c>genhub://</c> wrapper).
/// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider
/// Definition URL without changing this parser.
/// </remarks>
/// <param name="args">The command line arguments.</param>
/// <returns>The extracted catalog URL if present; otherwise, <c>null</c>.</returns>
/// <returns>The decoded absolute URL if present; otherwise, <c>null</c>.</returns>
public static string? ExtractSubscriptionUrl(string[] args)
{
foreach (var arg in args)
foreach (string arg in args)
{
if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase))
{
// Simple parsing for ?url=...
var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..];
if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal))
{
continue;
}

int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
if (queryStart != -1)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
return Uri.UnescapeDataString(url).Trim('"');
string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
string unescaped = Uri.UnescapeDataString(url)
.Replace("\r", string.Empty)
.Replace("\n", string.Empty)
.Trim('"', '\'', ' ', '\t');

if (string.IsNullOrWhiteSpace(unescaped))
{
return null;
}

if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
{
return unescaped;
}

return null;
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
using System;
using GenHub.Core.Helpers;
using Xunit;

namespace GenHub.Tests.Core.Helpers;

/// <summary>
/// Unit tests for <see cref="CommandLineParser"/>.
/// </summary>
public sealed class CommandLineParserTests
{
/// <summary>
/// Verifies that ExtractProfileId correctly extracts profile id from spaced argument.
/// </summary>
[Fact]
public void ExtractProfileId_WithSpacedArgument_ReturnsProfileId()
{
var args = new[] { "--other", "value", "--launch-profile", "test-profile-123" };

var result = CommandLineParser.ExtractProfileId(args);

Assert.Equal("test-profile-123", result);
}

/// <summary>
/// Verifies that ExtractProfileId correctly extracts profile id from inline argument.
/// </summary>
[Fact]
public void ExtractProfileId_WithInlineArgument_ReturnsProfileId()
{
var args = new[] { "--launch-profile=test-profile-456" };

var result = CommandLineParser.ExtractProfileId(args);

Assert.Equal("test-profile-456", result);
}

/// <summary>
/// Verifies that ExtractProfileId trims surrounding quotes.
/// </summary>
[Fact]
public void ExtractProfileId_WithQuotedValues_ReturnsTrimmedProfileId()
{
var argsSpaced = new[] { "--launch-profile", "\"quoted-profile\"" };
var argsInline = new[] { "--launch-profile=\"quoted-profile\"" };

Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsSpaced));
Assert.Equal("quoted-profile", CommandLineParser.ExtractProfileId(argsInline));
}

/// <summary>
/// Verifies that ExtractProfileId returns null when launch profile argument is absent.
/// </summary>
[Fact]
public void ExtractProfileId_WhenMissing_ReturnsNull()
{
var args = new[] { "--verbose", "--other" };

var result = CommandLineParser.ExtractProfileId(args);

Assert.Null(result);
}

/// <summary>
/// Verifies that ExtractProfileId returns null when spaced argument has no subsequent value.
/// </summary>
[Fact]
public void ExtractProfileId_WhenFlagAtEndWithoutValue_ReturnsNull()
{
var args = new[] { "--launch-profile" };

var result = CommandLineParser.ExtractProfileId(args);

Assert.Null(result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl parses direct catalog URLs.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithDirectUrl_ReturnsDecodedUrl()
{
var args = new[] { "genhub://subscribe?url=https://example.com/catalog.json" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Equal("https://example.com/catalog.json", result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl correctly decodes URL encoded parameters.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithUrlEncodedParameter_ReturnsDecodedUrl()
{
var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%3Fversion%3D1" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Equal("https://example.com/catalog.json?version=1", result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl trims quotes around the url value.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithQuotedArgument_ReturnsTrimmedUrl()
{
var argsClean = new[] { "genhub://subscribe?url=\"https://example.com/catalog.json\"" };

Assert.Equal("https://example.com/catalog.json", CommandLineParser.ExtractSubscriptionUrl(argsClean));
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl returns null when no subscribe URI is present.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WhenNotPresent_ReturnsNull()
Comment thread
undead2146 marked this conversation as resolved.
{
var args = new[] { "--launch-profile", "test" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Null(result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl is case insensitive with protocol prefix and query parameter.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_CaseInsensitivePrefix_ReturnsUrl()
{
var args = new[] { "GENHUB://SUBSCRIBE?URL=https://example.com/catalog.json" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Equal("https://example.com/catalog.json", result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl returns null when subscribe URI lacks the url query parameter.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithoutUrlParameter_ReturnsNull()
{
var args = new[] { "genhub://subscribe" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Null(result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl returns null when the url query parameter is empty.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithEmptyUrlParameter_ReturnsNull()
{
var args = new[] { "genhub://subscribe?url=" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Null(result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl extracts the URL even when preceded by other arguments.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WhenNotFirstArgument_ReturnsUrl()
{
var args = new[] { "--verbose", "--launch-profile", "test-profile", "genhub://subscribe?url=https://example.com/catalog.json" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Equal("https://example.com/catalog.json", result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl returns the first matching subscription URL when multiple are present.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_MultipleUrls_ReturnsFirstMatch()
{
var args = new[]
{
"genhub://subscribe?url=https://example.com/first.json",
"genhub://subscribe?url=https://example.com/second.json",
};

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Equal("https://example.com/first.json", result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl returns null for non-HTTP and non-HTTPS URI schemes.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_NonHttpOrHttpsScheme_ReturnsNull()
{
var fileSchemeArgs = new[] { "genhub://subscribe?url=file:///C:/malicious.exe" };
var jsSchemeArgs = new[] { "genhub://subscribe?url=javascript:alert(1)" };

Assert.Null(CommandLineParser.ExtractSubscriptionUrl(fileSchemeArgs));
Assert.Null(CommandLineParser.ExtractSubscriptionUrl(jsSchemeArgs));
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl strips newlines and control characters from the URL.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithNewlinesAndControlChars_ReturnsSanitizedUrl()
{
var args = new[] { "genhub://subscribe?url=https%3A%2F%2Fexample.com%2Fcatalog.json%0D%0A" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Equal("https://example.com/catalog.json", result);
}

/// <summary>
/// Verifies that ExtractSubscriptionUrl returns null for non-command subscribe-prefixed URIs.
/// </summary>
[Fact]
public void ExtractSubscriptionUrl_WithNonCommandSubscribePrefixedUri_ReturnsNull()
{
var args = new[] { "genhub://subscribe-anything?url=https://example.com/catalog.json" };

var result = CommandLineParser.ExtractSubscriptionUrl(args);

Assert.Null(result);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -81,8 +80,6 @@ public ContentReconciliationServiceTests()
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
[SuppressMessage("DeepSource", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")]
[SuppressMessage("csharp", "CS-R1136", Justification = "Expression tree lambdas in Moq do not support null propagation")]
public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToPool_AndUpdateProfilesAsync()
{
// Arrange
Expand Down Expand Up @@ -129,7 +126,7 @@ public async Task OrchestrateLocalUpdateAsync_WhenIdChanges_ShouldAddManifestToP
_profileManagerMock.Verify(
x => x.UpdateProfileAsync(
"profile-1",
It.Is<UpdateProfileRequest>(r => r.GameClient != null && r.GameClient.Id == newId),
It.Is<UpdateProfileRequest>(r => MatchesGameClientId(r, newId)),
It.IsAny<CancellationToken>()),
Times.Once,
"Should update profile with new manifest ID");
Expand Down Expand Up @@ -283,4 +280,7 @@ public async Task ScheduleGarbageCollectionAsync_WhenDisabled_ReturnsFailureAsyn
result.FirstError.Should().Be(
GenHub.Core.Constants.CasDefaults.GarbageCollectionDisabledMessage);
}

private static bool MatchesGameClientId(UpdateProfileRequest request, string expectedId) =>
request.GameClient?.Id == expectedId;
}
Loading
Loading