From 5bafa57a67ca459893d123071fff9864fb76dbb8 Mon Sep 17 00:00:00 2001 From: "Shenglong Li (from Dev Box)" Date: Thu, 21 Nov 2024 15:25:09 -0800 Subject: [PATCH 1/2] Fix issues in ResourceIdentifier --- .../Abstraction/ResourceIdentifierTests.cs | 133 ++++++++++++---- .../Abstraction/ResourceIdentifier.cs | 146 ++++++++++++------ .../Abstraction/ResourceIdentifierScheme.cs | 34 ++++ .../Abstraction/ResourceIdentifierSchemes.cs | 16 -- 4 files changed, 242 insertions(+), 87 deletions(-) create mode 100644 src/Bicep.IO/Abstraction/ResourceIdentifierScheme.cs delete mode 100644 src/Bicep.IO/Abstraction/ResourceIdentifierSchemes.cs diff --git a/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs b/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs index 09a32bacc92..c6e6a05ad2c 100644 --- a/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs +++ b/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading.Tasks; using Bicep.IO.Abstraction; +using FluentAssertions; namespace Bicep.IO.UnitTests.Abstraction { @@ -20,82 +21,160 @@ public class ResourceIdentifierTests [DataRow("/a/b/c/", "/a/b/c/")] [DataRow("/a//b/c", "/a/b/c")] [DataRow("/a/b/c/..", "/a/b")] - public void ResourceIdentifier_ByDefault_CanonicalizePath(string inputPath, string expectedPath) + public void ResourceIdentifier_ByDefault_CanolicalizesPath(string inputPath, string expectedPath) { - // Act + // Arrange & Act. var resourceIdentifier = new ResourceIdentifier("http", "example.com", inputPath); - // Assert - Assert.AreEqual(expectedPath, resourceIdentifier.Path); + // Assert. + resourceIdentifier.Path.Should().Be(expectedPath); } [DataTestMethod] - [DataRow("http", "example.com", "/a/b/c", "http://example.com/a/b/c")] - [DataRow("https", "example.com", "/a/b/c", "https://example.com/a/b/c")] - [DataRow("file", "", "/a/b/c", "/a/b/c")] - public void ResourceIdentifier_ToString_ReturnsCorrectUriOrPath(string scheme, string authority, string path, string expectedOutput) + [DataRow("http", "EXAMPLE.COM", "example.com")] + [DataRow("http", "Example.Com", "example.com")] + [DataRow("http", "example.com", "example.com")] + [DataRow("https", "EXAMPLE.COM:80", "example.com:80")] + [DataRow("https", "Example.Com:443", "example.com:443")] + [DataRow("file", "localhost", "")] + [DataRow("file", "", "")] + [DataRow("file", null, "")] + public void ResourceIdentifier_ByDefault_NormalizesAuthority(string scheme, string? authority, string expectedAuthority) { - // Act - var resourceIdentifier = new ResourceIdentifier(scheme, authority, path); + // Arrange & Act. + var resourceIdentifier = new ResourceIdentifier(scheme, authority, "/a/b/c"); - // Assert - Assert.AreEqual(expectedOutput, resourceIdentifier.ToString()); + // Assert. + resourceIdentifier.Authority.Should().Be(expectedAuthority); + } + + [TestMethod] + [DataRow("https", "")] + [DataRow("https", null)] + [DataRow("http", "")] + [DataRow("http", null)] + public void ResourceIdentifier_NullOrEmptyHttpOrHttpsAuthority_ThrowsArgumentException(string scheme, string? authority) + { + FluentActions + .Invoking(() => new ResourceIdentifier(scheme, authority, "/a/b/c")) + .Should().Throw(); } [TestMethod] - [ExpectedException(typeof(ArgumentException))] public void ResourceIdentifier_InvalidPath_ThrowsArgumentException() { - // Act - var resourceIdentifier = new ResourceIdentifier("http", "example.com", "a/b/c"); + FluentActions + .Invoking(() => new ResourceIdentifier("http", "example.com", "a/b/c")) + .Should().Throw(); + } + + [DataTestMethod] + [DataRow("http", "example.com", "/a/b/c", "http://example.com/a/b/c")] + [DataRow("https", "example.com", "/a/b/c", "https://example.com/a/b/c")] + [DataRow("file", "", "/a/b/c", "/a/b/c")] + public void ToString_ByDefault_ReturnsUriOrLocalFilePath(string scheme, string authority, string path, string expectedOutput) + { + // Arrange & Act. + var resourceIdentifier = new ResourceIdentifier(scheme, authority, path); + + // Assert + resourceIdentifier.ToString().Should().Be(expectedOutput); } [TestMethod] - public void ResourceIdentifier_Equals_ReturnsTrueForIdenticalIdentifiers() + public void Equals_IdenticalIdentifiers_ReturnsTrue() { // Arrange var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b/c"); var identifier2 = new ResourceIdentifier("http", "example.com", "/a/b/c"); // Act & Assert - Assert.IsTrue(identifier1.Equals(identifier2)); - Assert.IsTrue(identifier1 == identifier2); - Assert.IsFalse(identifier1 != identifier2); + identifier1.Equals(identifier2).Should().BeTrue(); + (identifier1 == identifier2).Should().BeTrue(); + (identifier1 != identifier2).Should().BeFalse(); } [TestMethod] - public void ResourceIdentifier_Equals_ReturnsFalseForDifferentIdentifiers() + public void Equals_DifferentIdentifiers_ReturnsFalse() { // Arrange var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b/c"); var identifier2 = new ResourceIdentifier("http", "example.com", "/a/b/d"); // Act & Assert - Assert.IsFalse(identifier1.Equals(identifier2)); - Assert.IsFalse(identifier1 == identifier2); - Assert.IsTrue(identifier1 != identifier2); + identifier1.Equals(identifier2).Should().BeFalse(); + (identifier1 == identifier2).Should().BeFalse(); + (identifier1 != identifier2).Should().BeTrue(); } [TestMethod] - public void ResourceIdentifier_GetHashCode_ReturnsConsistentHashCode() + public void GetHashCode_IdenticalIdentifiers_ReturnsConsistentHashCode() { // Arrange var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b/c"); var identifier2 = new ResourceIdentifier("http", "example.com", "/a/b/c"); // Act & Assert - Assert.AreEqual(identifier1.GetHashCode(), identifier2.GetHashCode()); + identifier1.GetHashCode().Should().Be(identifier2.GetHashCode()); } [TestMethod] - public void ResourceIdentifier_GetHashCode_ReturnsDifferentHashCodesForDifferentIdentifiers() + public void GetHashCode_DifferentIdentifiers_ReturnsDifferentHashCodes() { // Arrange var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b/c"); var identifier2 = new ResourceIdentifier("http", "example.com", "/a/b/d"); // Act & Assert - Assert.AreNotEqual(identifier1.GetHashCode(), identifier2.GetHashCode()); + identifier1.GetHashCode().Should().NotBe(identifier2.GetHashCode()); + } + + [DataTestMethod] + [DataRow("http", "example.com", "/a/b/c", "/a/b", "c")] + [DataRow("http", "example.com", "/a/b/c/", "/a/b", "c/")] + [DataRow("http", "example.com", "/a/b/c", "/a/b/c", "")] + [DataRow("http", "example.com", "/a/b/c", "/a/b/d", "../c")] + [DataRow("http", "example.com", "/a/b/c/d", "/a/b", "c/d")] + [DataRow("http", "example.com", "/a/b/c", "/a/b/c/d", "..")] + public void GetPathRelativeTo_ValidPaths_ReturnsCorrectRelativePath(string scheme, string authority, string path, string otherPath, string expectedRelativePath) + { + // Arrange. + var identifier = new ResourceIdentifier(scheme, authority, path); + var otherIdentifier = new ResourceIdentifier(scheme, authority, otherPath); + + // Act. + var relativePath = identifier.GetPathRelativeTo(otherIdentifier); + + // Assert. + relativePath.Should().Be(expectedRelativePath); + } + + [TestMethod] + public void GetPathRelativeTo_DifferentSchemes_ThrowsInvalidOperationException() + { + // Arrange. + var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b/c"); + var identifier2 = new ResourceIdentifier("https", "example.com", "/a/b/c"); + + // Act. + Action act = () => identifier1.GetPathRelativeTo(identifier2); + + // Assert. + act.Should().Throw(); + } + + [TestMethod] + public void GetPathRelativeTo_DifferentAuthorities_ThrowsInvalidOperationException() + { + // Arrange. + var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b/c"); + var identifier2 = new ResourceIdentifier("http", "example.org", "/a/b/c"); + + // Act. + Action act = () => identifier1.GetPathRelativeTo(identifier2); + + // Assert. + act.Should().Throw(); } } } diff --git a/src/Bicep.IO/Abstraction/ResourceIdentifier.cs b/src/Bicep.IO/Abstraction/ResourceIdentifier.cs index 254f8e6eb99..837fd1da890 100644 --- a/src/Bicep.IO/Abstraction/ResourceIdentifier.cs +++ b/src/Bicep.IO/Abstraction/ResourceIdentifier.cs @@ -18,14 +18,14 @@ namespace Bicep.IO.Abstraction { public static class GlobalSettings { - public static bool FilePathCaseSensitive { get; set; } = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + public static bool LocalFilePathCaseSensitive { get; set; } = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - public static StringComparer FilePathComparer => FilePathCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; + public static StringComparer LocalFilePathComparer => LocalFilePathCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; - public static StringComparison FilePathComparison => FilePathCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + public static StringComparison LocalFilePathComparison => LocalFilePathCaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; } - public ResourceIdentifier(string scheme, string? authority, string path, string? query = null, string? fragment = null) + public ResourceIdentifier(ResourceIdentifierScheme scheme, string? authority, string path, string? query = null, string? fragment = null) { if (!path.StartsWith('/')) { @@ -33,13 +33,13 @@ public ResourceIdentifier(string scheme, string? authority, string path, string? } this.Scheme = scheme; - this.Authority = authority; + this.Authority = NormalizeAuthority(scheme, authority); this.Path = CanonicalizePath(path); this.Query = query; this.Fragment = fragment; } - public string Scheme { get; } + public ResourceIdentifierScheme Scheme { get; } public string? Authority { get; } @@ -49,24 +49,113 @@ public ResourceIdentifier(string scheme, string? authority, string path, string? public string? Fragment { get; } - public bool IsFile => this.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase); + public string[] PathSegments => this.Path.Split('/', StringSplitOptions.RemoveEmptyEntries); - public bool IsLocal => string.IsNullOrEmpty(this.Authority) || this.Authority.Equals("localhost", StringComparison.OrdinalIgnoreCase); + public bool IsLocalFile => this.Scheme.IsFile && this.Authority == ""; + + public StringComparison PathComparison => this.IsLocalFile ? GlobalSettings.LocalFilePathComparison : StringComparison.Ordinal; + + public StringComparer PathComparer => this.IsLocalFile ? GlobalSettings.LocalFilePathComparer : StringComparer.Ordinal; public static implicit operator string(ResourceIdentifier identifier) => identifier.ToString(); public override string ToString() => this.TryGetLocalFilePath() ?? this.ToUriString(); // See: The "file" URI Scheme (https://datatracker.ietf.org/doc/html/rfc8089). - // Note that we don't handle the case where the host IP resolves to the local machine. - public string? TryGetLocalFilePath() => this.IsFile && this.IsLocal ? new UriBuilder { Scheme = Scheme, Host = "", Path = Path }.Uri.LocalPath : null; + // Note that we don't handle user info and the case where the host IP resolves to the local machine. + public string? TryGetLocalFilePath() => this.IsLocalFile ? new UriBuilder { Scheme = this.Scheme, Host = "", Path = Path }.Uri.LocalPath : null; // See: Uniform Resource Identifier (URI): Generic Syntax (https://datatracker.ietf.org/doc/html/rfc3986). public string ToUriString() => this.Authority is null ? $"{Scheme}:{Path}" : $"{Scheme}://{Authority}{Path}"; + public static bool operator ==(ResourceIdentifier left, ResourceIdentifier right) => left.Equals(right); + + public static bool operator !=(ResourceIdentifier left, ResourceIdentifier right) => !(left == right); + + public override int GetHashCode() + { + var hash = new HashCode(); + + // Scheme and Authority are case-insenstive. + hash.Add(this.Scheme); + hash.Add(this.Authority, StringComparer.OrdinalIgnoreCase); + hash.Add(this.Path, this.PathComparer); + hash.Add(this.Query, StringComparer.Ordinal); + hash.Add(this.Fragment, StringComparer.Ordinal); + + return hash.ToHashCode(); + } + + public override bool Equals(object? @object) => @object is ResourceIdentifier other && this.Equals(other); + + public bool Equals(ResourceIdentifier other) => + this.Scheme.Equals(other.Scheme) && + string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase) && + string.Equals(this.Query, other.Query, StringComparison.Ordinal) && + string.Equals(this.Fragment, other.Fragment, StringComparison.Ordinal) && + string.Equals(Path, other.Path, this.PathComparison); + + public string GetPathRelativeTo(ResourceIdentifier other) + { + if (!this.Scheme.Equals(other.Scheme) || + !string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Both ResourceIdentifiers must have the same scheme and authority."); + } + + var thisPathSegments = this.PathSegments; + var otherPathSegments = other.PathSegments; + var commonSegmentIndex = 0; + + while ( + commonSegmentIndex < thisPathSegments.Length && + commonSegmentIndex < otherPathSegments.Length && + thisPathSegments[commonSegmentIndex].Equals(otherPathSegments[commonSegmentIndex], this.PathComparison)) + { + commonSegmentIndex++; + } + + var relativePathSegments = new List(); + + for (int i = commonSegmentIndex; i < otherPathSegments.Length; i++) + { + relativePathSegments.Add(".."); + } + + for (int i = commonSegmentIndex; i < thisPathSegments.Length; i++) + { + relativePathSegments.Add(thisPathSegments[i]); + } + + var relativePath = string.Join("/", relativePathSegments); + + return this.Path.EndsWith('/') ? relativePath + '/' : relativePath; + } + + private static string? NormalizeAuthority(ResourceIdentifierScheme scheme, string? authority) + { + if (scheme.IsHttp || scheme.IsHttps) + { + if (string.IsNullOrEmpty(authority)) + { + throw new ArgumentException("Authority must be non-empty for HTTP/HTTPS schemes."); + } + } + + if (scheme.IsFile) + { + if (string.IsNullOrEmpty(authority) || + string.Equals(authority, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return ""; + } + } + + return authority; + } + private static string CanonicalizePath(string path) { - var hasTrailingSlash = path.EndsWith('/'); var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var stack = new Stack(); @@ -90,40 +179,9 @@ private static string CanonicalizePath(string path) } } - var canonicalPath = string.Join("/", stack.Reverse()); + var canonicalPath = '/' + string.Join("/", stack.Reverse()); - return hasTrailingSlash - ? "/" + canonicalPath + "/" - : "/" + canonicalPath; + return path.EndsWith('/') ? canonicalPath + "/" : canonicalPath; } - - public override int GetHashCode() - { - var hash = new HashCode(); - - // Scheme and Authority are case-insenstive. - hash.Add(this.Scheme, StringComparer.OrdinalIgnoreCase); - hash.Add(this.Authority, StringComparer.OrdinalIgnoreCase); - hash.Add(this.Path, this.IsLocal && this.IsFile ? GlobalSettings.FilePathComparer : StringComparer.Ordinal); - hash.Add(this.Query, StringComparer.Ordinal); - hash.Add(this.Fragment, StringComparer.Ordinal); - - return hash.ToHashCode(); - } - - public override bool Equals(object? @object) => @object is ResourceIdentifier other && this.Equals(other); - - public bool Equals(ResourceIdentifier other) => - string.Equals(this.Scheme, other.Scheme, StringComparison.OrdinalIgnoreCase) && - string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase) && - string.Equals(this.Query, other.Query, StringComparison.Ordinal) && - string.Equals(this.Fragment, other.Fragment, StringComparison.Ordinal) && - (this.IsLocal && this.IsFile - ? string.Equals(Path, other.Path, GlobalSettings.FilePathComparison) - : string.Equals(Path, other.Path, StringComparison.Ordinal)); - - public static bool operator ==(ResourceIdentifier left, ResourceIdentifier right) => left.Equals(right); - - public static bool operator !=(ResourceIdentifier left, ResourceIdentifier right) => !(left == right); } } diff --git a/src/Bicep.IO/Abstraction/ResourceIdentifierScheme.cs b/src/Bicep.IO/Abstraction/ResourceIdentifierScheme.cs new file mode 100644 index 00000000000..40e6aa3f868 --- /dev/null +++ b/src/Bicep.IO/Abstraction/ResourceIdentifierScheme.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bicep.IO.Abstraction +{ + public readonly record struct ResourceIdentifierScheme(string Name) + { + public static readonly ResourceIdentifierScheme Http = new("http"); + + public static readonly ResourceIdentifierScheme Https = new("https"); + + public static readonly ResourceIdentifierScheme File = new("file"); + + public readonly string Name { get; } = Name.ToLowerInvariant(); + + public static implicit operator string(ResourceIdentifierScheme scheme) => scheme.ToString(); + + public static implicit operator ResourceIdentifierScheme(string name) => new(name); + + public readonly bool IsHttp => this.Equals(Http); + + public readonly bool IsHttps => this.Equals(Https); + + public readonly bool IsFile => this.Equals(File); + + public override string ToString() => this.Name; + } +} diff --git a/src/Bicep.IO/Abstraction/ResourceIdentifierSchemes.cs b/src/Bicep.IO/Abstraction/ResourceIdentifierSchemes.cs deleted file mode 100644 index 6a90aad1ed5..00000000000 --- a/src/Bicep.IO/Abstraction/ResourceIdentifierSchemes.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Bicep.IO.Abstraction -{ - public class ResourceIdentifierSchemes - { - public static string File = "file"; - } -} From 4ae0257ca2ff9b2e73c9106b85d7b71144d6741a Mon Sep 17 00:00:00 2001 From: Shenglong Li Date: Fri, 22 Nov 2024 16:52:31 -0800 Subject: [PATCH 2/2] Add more functionalities --- .../ResourceIdentifierExtensions.cs | 51 ++++++++++ .../Abstraction/ResourceIdentifierTests.cs | 94 +++++++++++++++---- .../Abstraction/ResourceIdentifier.cs | 74 ++++++++++++--- .../ResourceIdentifierExtensions.cs | 65 +++++++++++++ 4 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierExtensions.cs create mode 100644 src/Bicep.IO/Abstraction/ResourceIdentifierExtensions.cs diff --git a/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierExtensions.cs b/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierExtensions.cs new file mode 100644 index 00000000000..38e2242ac25 --- /dev/null +++ b/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Bicep.IO.Abstraction; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bicep.IO.UnitTests.Abstraction +{ + [TestClass] + public class ResourceIdentifierExtensionsTests + { + [DataTestMethod] + [DataRow("/a/b/c.txt", ".txt")] + [DataRow("/a/b/c.tar.gz", ".gz")] + [DataRow("/a/b/c", "")] + [DataRow("/a/b/c.", "")] + [DataRow("/a/b/c.d/e", "")] + public void GetExtension_ValidPaths_ReturnsCorrectExtension(string path, string expectedExtension) + { + // Arrange. + var resourceIdentifier = new ResourceIdentifier("file", "", path); + + // Act. + var extension = resourceIdentifier.GetExtension(); + + // Assert. + extension.ToString().Should().Be(expectedExtension); + } + + [DataTestMethod] + [DataRow("/a/b/c.txt", ".bak", "/a/b/c.bak")] + [DataRow("/a/b/c.tar.gz", ".zip", "/a/b/c.tar.zip")] + [DataRow("/a/b/c.tar.gz", "zip", "/a/b/c.tar.zip")] + [DataRow("/a/b/c", ".txt", "/a/b/c.txt")] + [DataRow("/a/b/c.", ".txt", "/a/b/c.txt")] + [DataRow("/a/b/c.d/e", ".txt", "/a/b/c.d/e.txt")] + public void WithExtension_ValidPaths_ReturnsPathWithNewExtension(string path, string newExtension, string expectedPath) + { + // Arrange + var resourceIdentifier = new ResourceIdentifier("file", "", path); + + // Act + var newResourceIdentifier = resourceIdentifier.WithExtension(newExtension); + + // Assert + newResourceIdentifier.Path.Should().Be(expectedPath); + } + } +} diff --git a/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs b/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs index c6e6a05ad2c..b9f275a2dac 100644 --- a/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs +++ b/src/Bicep.IO.UnitTests/Abstraction/ResourceIdentifierTests.cs @@ -14,22 +14,6 @@ namespace Bicep.IO.UnitTests.Abstraction [TestClass] public class ResourceIdentifierTests { - [DataTestMethod] - [DataRow("/a/b/c", "/a/b/c")] - [DataRow("/a/b/../c", "/a/c")] - [DataRow("/a/./b/c", "/a/b/c")] - [DataRow("/a/b/c/", "/a/b/c/")] - [DataRow("/a//b/c", "/a/b/c")] - [DataRow("/a/b/c/..", "/a/b")] - public void ResourceIdentifier_ByDefault_CanolicalizesPath(string inputPath, string expectedPath) - { - // Arrange & Act. - var resourceIdentifier = new ResourceIdentifier("http", "example.com", inputPath); - - // Assert. - resourceIdentifier.Path.Should().Be(expectedPath); - } - [DataTestMethod] [DataRow("http", "EXAMPLE.COM", "example.com")] [DataRow("http", "Example.Com", "example.com")] @@ -48,7 +32,23 @@ public void ResourceIdentifier_ByDefault_NormalizesAuthority(string scheme, stri resourceIdentifier.Authority.Should().Be(expectedAuthority); } - [TestMethod] + [DataTestMethod] + [DataRow("/a/b/c", "/a/b/c")] + [DataRow("/a/b/../c", "/a/c")] + [DataRow("/a/./b/c", "/a/b/c")] + [DataRow("/a/b/c/", "/a/b/c/")] + [DataRow("/a//b/c", "/a/b/c")] + [DataRow("/a/b/c/..", "/a/b")] + public void ResourceIdentifier_ByDefault_NormalizesPath(string inputPath, string expectedPath) + { + // Arrange & Act. + var resourceIdentifier = new ResourceIdentifier("http", "example.com", inputPath); + + // Assert. + resourceIdentifier.Path.Should().Be(expectedPath); + } + + [DataTestMethod] [DataRow("https", "")] [DataRow("https", null)] [DataRow("http", "")] @@ -60,11 +60,14 @@ public void ResourceIdentifier_NullOrEmptyHttpOrHttpsAuthority_ThrowsArgumentExc .Should().Throw(); } - [TestMethod] - public void ResourceIdentifier_InvalidPath_ThrowsArgumentException() + [DataTestMethod] + [DataRow("http", "example.com", "a/b/c")] + [DataRow("http", null, "//a/b/c")] + [DataRow("file", null, "a/b/c")] + public void ResourceIdentifier_InvalidPath_ThrowsArgumentException(string scheme, string? authorty, string path) { FluentActions - .Invoking(() => new ResourceIdentifier("http", "example.com", "a/b/c")) + .Invoking(() => new ResourceIdentifier(scheme, authorty, path)) .Should().Throw(); } @@ -176,5 +179,56 @@ public void GetPathRelativeTo_DifferentAuthorities_ThrowsInvalidOperationExcepti // Assert. act.Should().Throw(); } + + [DataTestMethod] + [DataRow("http", "example.com", "/a/b", "/a/b/c", true)] + [DataRow("http", "example.com", "/a/b", "/a/b/c/d", true)] + [DataRow("http", "example.com", "/a/b", "/a/b", true)] + [DataRow("http", "example.com", "/a/b", "/a/c", false)] + [DataRow("http", "example.com", "/a/b", "/a/bc", false)] + [DataRow("http", "example.com", "/a/b", "/a/bc/d", false)] + [DataRow("http", "example.com", "/a/b", "/a/b/../c", false)] + [DataRow("http", "example.com", "/a/b", "/a/b/./c", true)] + [DataRow("http", "example.com", "/a/b", "/a/b/c/..", true)] + public void IsBaseOf_ValidPaths_ReturnsExpectedResult(string scheme, string authority, string basePath, string otherPath, bool expectedResult) + { + // Arrange. + var baseIdentifier = new ResourceIdentifier(scheme, authority, basePath); + var otherIdentifier = new ResourceIdentifier(scheme, authority, otherPath); + + // Act. + var result = baseIdentifier.IsBaseOf(otherIdentifier); + + // Assert. + result.Should().Be(expectedResult); + } + + [TestMethod] + public void IsBaseOf_DifferentSchemes_ReturnsFalse() + { + // Arrange. + var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b"); + var identifier2 = new ResourceIdentifier("https", "example.com", "/a/b/c"); + + // Act. + var result = identifier1.IsBaseOf(identifier2); + + // Assert. + result.Should().BeFalse(); + } + + [TestMethod] + public void IsBaseOf_DifferentAuthorities_ReturnsFalse() + { + // Arrange. + var identifier1 = new ResourceIdentifier("http", "example.com", "/a/b"); + var identifier2 = new ResourceIdentifier("http", "example.org", "/a/b/c"); + + // Act. + var result = identifier1.IsBaseOf(identifier2); + + // Assert. + result.Should().BeFalse(); + } } } diff --git a/src/Bicep.IO/Abstraction/ResourceIdentifier.cs b/src/Bicep.IO/Abstraction/ResourceIdentifier.cs index 837fd1da890..7cbb00817a8 100644 --- a/src/Bicep.IO/Abstraction/ResourceIdentifier.cs +++ b/src/Bicep.IO/Abstraction/ResourceIdentifier.cs @@ -12,8 +12,14 @@ namespace Bicep.IO.Abstraction { /// - /// A ResourceIdentifier is a RFC3986 URI with absolute path. + /// A primitive URI implementation. /// + /// + /// This implementation is intentionally limited to the subset of + /// RFC3986 and + /// RFC8089 + /// to satisfy the functionality requirements of Bicep. + /// public readonly struct ResourceIdentifier : IEquatable { public static class GlobalSettings @@ -27,14 +33,9 @@ public static class GlobalSettings public ResourceIdentifier(ResourceIdentifierScheme scheme, string? authority, string path, string? query = null, string? fragment = null) { - if (!path.StartsWith('/')) - { - throw new ArgumentException("Path must be absolute.", nameof(path)); - } - this.Scheme = scheme; this.Authority = NormalizeAuthority(scheme, authority); - this.Path = CanonicalizePath(path); + this.Path = NormalizePath(scheme, authority, path); this.Query = query; this.Fragment = fragment; } @@ -89,16 +90,41 @@ public override int GetHashCode() public override bool Equals(object? @object) => @object is ResourceIdentifier other && this.Equals(other); public bool Equals(ResourceIdentifier other) => - this.Scheme.Equals(other.Scheme) && - string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase) && + this.SchemeEquals(other) && + this.AuthorityEquals(other) && string.Equals(this.Query, other.Query, StringComparison.Ordinal) && string.Equals(this.Fragment, other.Fragment, StringComparison.Ordinal) && string.Equals(Path, other.Path, this.PathComparison); + public bool IsBaseOf(ResourceIdentifier other) + { + if (!this.SchemeEquals(other) || !this.AuthorityEquals(other)) + { + return false; + } + + var thisPathSegments = this.PathSegments; + var otherPathSegments = other.PathSegments; + + if (thisPathSegments.Length > otherPathSegments.Length) + { + return false; + } + + for (int i = 0; i < thisPathSegments.Length; i++) + { + if (!thisPathSegments[i].Equals(otherPathSegments[i], this.PathComparison)) + { + return false; + } + } + + return true; + } + public string GetPathRelativeTo(ResourceIdentifier other) { - if (!this.Scheme.Equals(other.Scheme) || - !string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase)) + if (!this.Scheme.Equals(other.Scheme) || !string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("Both ResourceIdentifiers must have the same scheme and authority."); } @@ -151,11 +177,29 @@ public string GetPathRelativeTo(ResourceIdentifier other) } } - return authority; + return authority?.ToLowerInvariant(); } - private static string CanonicalizePath(string path) + private static string NormalizePath(ResourceIdentifierScheme scheme, string? authority, string path) { + if (authority is not null && !(path.Length == 0 || path.StartsWith('/'))) + { + // https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + throw new ArgumentException("Path must be empty or absolute when authority is non-null."); + } + + if (authority is null && path.StartsWith("//")) + { + // https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 + throw new ArgumentException("Path cannot start with '//' when authority is null."); + } + + if (scheme.IsFile && !path.StartsWith('/')) + { + // https://datatracker.ietf.org/doc/html/rfc8089#section-2 + throw new ArgumentException("File path must be absolute."); + } + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); var stack = new Stack(); @@ -183,5 +227,9 @@ private static string CanonicalizePath(string path) return path.EndsWith('/') ? canonicalPath + "/" : canonicalPath; } + + private bool SchemeEquals(ResourceIdentifier other) => this.Scheme.Equals(other.Scheme); + + private bool AuthorityEquals(ResourceIdentifier other) => string.Equals(this.Authority, other.Authority, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Bicep.IO/Abstraction/ResourceIdentifierExtensions.cs b/src/Bicep.IO/Abstraction/ResourceIdentifierExtensions.cs new file mode 100644 index 00000000000..92eff4c19bc --- /dev/null +++ b/src/Bicep.IO/Abstraction/ResourceIdentifierExtensions.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Bicep.IO.Abstraction +{ + public static class ResourceIdentifierExtensions + { + public static ReadOnlySpan GetExtension(this ResourceIdentifier identifier) + { + int lastDotIndex = GetExtensionStartIndex(identifier.Path); + + if (lastDotIndex == -1 || lastDotIndex == identifier.Path.Length - 1) + { + return ""; + } + + return identifier.Path.AsSpan()[lastDotIndex..]; + } + + public static ResourceIdentifier WithExtension(this ResourceIdentifier identifier, string extension) + { + if (!extension.StartsWith('.')) + { + extension = "." + extension; + } + + var extensionStartIndex = GetExtensionStartIndex(identifier.Path); + var newPath = extensionStartIndex == -1 + ? identifier.Path + extension + : string.Concat(identifier.Path.AsSpan(0, extensionStartIndex), extension); + + return new ResourceIdentifier( + identifier.Scheme, + identifier.Authority, + newPath, + identifier.Query, + identifier.Fragment); + } + + private static int GetExtensionStartIndex(string path) + { + int extensionStartIndex = -1; + + for (int i = 0; i < path.Length; i++) + { + if (path[i] == '.') + { + extensionStartIndex = i; + } + else if (path[i] == '/') + { + extensionStartIndex = -1; + } + } + + return extensionStartIndex; + } + } +}