diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs index 2ae52f2..87ddd8f 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitRepositoryConfigBuilderTests.cs @@ -65,6 +65,22 @@ public void BuildConfig_ShouldUseProvidedValues_WhenValuesAreProvided() config.FileSystem.Should().Be(fileSystem); } + [Fact] + public void BuildConfig_ShouldUseDecodedRepositoryName_ForAzureDevOpsUrls() + { + // Arrange + const string gitUrl = "https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site"; + + RepositoryConfigBuilder builder = new RepositoryConfigBuilder() + .WithGitUrl(gitUrl); + + // Act + RepositoryConfig config = builder.Build(); + + // Assert + config.RepositoryPath.Should().Be(Path.Combine(Path.GetFullPath("."), "example web site")); + } + [Fact] public void WithCloneTargetPath_ShouldSetCloneTargetPath() { diff --git a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs index 6214e32..8f63808 100644 --- a/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs +++ b/src/Dutchskull.Aspire.PolyRepo.Tests.Unit/GitUrlUtilitiesTests.cs @@ -6,10 +6,15 @@ public class GitUrlUtilitiesTests { private const string GitUrl = "https://github.com/example/repo.git"; private const string ExpectedProjectName = "repo"; + private const string AzureDevOpsGitUrl = "https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site"; [Theory] [InlineData("https://github.com/example/repo.git", true)] [InlineData("https://github.com/example/repo", false)] + [InlineData("https://example@dev.azure.com/example/example%20web%20site/_git/example%20web%20site", true)] + [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site", true)] + [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site/", true)] + [InlineData("git@ssh.dev.azure.com:v3/example/example%20web%20site/example%20web%20site", true)] [InlineData("git@github.com:example/repo", false)] [InlineData("git://github.com/example/repo", false)] [InlineData("https://example.com", false)] @@ -56,4 +61,27 @@ public void GetProjectNameFromGitUrl_ShouldReturnLastSegment_WhenUrlDoesNotEndWi // Assert projectName.Should().Be(ExpectedProjectName); } + + [Theory] + [InlineData(AzureDevOpsGitUrl)] + [InlineData("https://dev.azure.com/example/example%20web%20site/_git/example%20web%20site/")] + [InlineData("git@ssh.dev.azure.com:v3/example/example%20web%20site/example%20web%20site")] + public void GetProjectNameFromGitUrl_ShouldDecodePercentEncodedRepositoryNames(string gitUrl) + { + // Act + string projectName = GitUrlUtilities.GetProjectNameFromGitUrl(gitUrl); + + // Assert + projectName.Should().Be("example web site"); + } + + [Fact] + public void GetProjectNameFromGitUrl_ShouldThrow_WhenDecodedRepositoryNameContainsPathSeparators() + { + // Act + Action act = () => GitUrlUtilities.GetProjectNameFromGitUrl("https://dev.azure.com/example/project/_git/repository%2Fchild"); + + // Assert + act.Should().Throw(); + } } \ No newline at end of file diff --git a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs index 2ff045f..482097e 100644 --- a/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs +++ b/src/Dutchskull.Aspire.PolyRepo/GitUrlUtilities.cs @@ -4,12 +4,42 @@ namespace Dutchskull.Aspire.PolyRepo; internal static partial class GitUrlUtilities { - internal static string GetProjectNameFromGitUrl(string gitUrl) => - gitUrl.RemovePostfix(".git").Split('/')[^1]; + internal static string GetProjectNameFromGitUrl(string gitUrl) + { + ReadOnlySpan gitUrlSpan = gitUrl.AsSpan().TrimEnd('/'); + int projectNameStart = gitUrlSpan.LastIndexOf('/'); + + if (projectNameStart < 0 || projectNameStart == gitUrlSpan.Length - 1) + { + throw new ArgumentException("Git URL could not be parsed.", nameof(gitUrl)); + } + + ReadOnlySpan encodedProjectName = gitUrlSpan[(projectNameStart + 1)..]; + + if (encodedProjectName.EndsWith(".git", StringComparison.Ordinal)) + { + encodedProjectName = encodedProjectName[..^4]; + } + + string projectName = encodedProjectName.Contains('%') + ? Uri.UnescapeDataString(encodedProjectName.ToString()) + : encodedProjectName.ToString(); + + if (string.IsNullOrWhiteSpace(projectName) || + projectName is "." or ".." || + projectName.Contains('/') || + projectName.Contains('\\') || + projectName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException("Git URL resolved to an unsafe repository name.", nameof(gitUrl)); + } + + return projectName; + } internal static bool IsValidGitUrl(string url) => !string.IsNullOrEmpty(url) && GitUrlRegex().IsMatch(url); - [GeneratedRegex(@"^(?:git|https?|git@[\w\.]+):\/\/[\w\.@\:\/\-~]+\.git(?:\/)?$")] + [GeneratedRegex(@"^(?:(?:git|https?):\/\/[^\s]+\.git(?:\/)?|https:\/\/(?:[\w.-]+@)?dev\.azure\.com\/[^\/\s]+\/[^\/\s]+\/_git\/[^\/\s]+(?:\/)?|git@ssh\.dev\.azure\.com:v3\/[^\/\s]+\/[^\/\s]+\/[^\/\s]+(?:\/)?)$")] private static partial Regex GitUrlRegex(); }