diff --git a/src/PackageUrl.cs b/src/PackageUrl.cs index 3d20bd1..b86abf9 100644 --- a/src/PackageUrl.cs +++ b/src/PackageUrl.cs @@ -46,6 +46,7 @@ public sealed class PackageURL /// The url encoding of /. /// private const string EncodedSlash = "%2F"; + private const string EncodedColon = "%3A"; private static readonly Regex s_typePattern = new Regex("^[a-zA-Z][a-zA-Z0-9.+-]+$", RegexOptions.Compiled); @@ -146,27 +147,31 @@ public override string ToString() } if (Name != null) { - purl.Append(Name); + string encodedName = WebUtility.UrlEncode(Name).Replace(EncodedColon, ":"); + purl.Append(encodedName); } if (Version != null) { - purl.Append('@').Append(Version); + string encodedVersion = WebUtility.UrlEncode(Version).Replace(EncodedColon, ":"); + purl.Append('@').Append(encodedVersion); } if (Qualifiers != null && Qualifiers.Count > 0) { purl.Append("?"); foreach (var pair in Qualifiers) { + string encodedValue = WebUtility.UrlEncode(pair.Value).Replace(EncodedSlash, "/"); purl.Append(pair.Key.ToLower()); purl.Append('='); - purl.Append(pair.Value); + purl.Append(encodedValue); purl.Append('&'); } purl.Remove(purl.Length - 1, 1); } if (Subpath != null) { - purl.Append("#").Append(Subpath); + string encodedSubpath = WebUtility.UrlEncode(Subpath).Replace(EncodedSlash, "/").Replace(EncodedColon, ":"); + purl.Append("#").Append(encodedSubpath); } return purl.ToString(); } @@ -205,7 +210,7 @@ private void Parse(string purl) if (remainder.Contains("#")) { // subpath is optional - check for existence int index = remainder.LastIndexOf("#"); - Subpath = ValidateSubpath(remainder.Substring(index + 1)); + Subpath = ValidateSubpath(WebUtility.UrlDecode(remainder.Substring(index + 1))); remainder = remainder.Substring(0, index); } @@ -219,7 +224,7 @@ private void Parse(string purl) if (remainder.Contains("@")) { // version is optional - check for existence int index = remainder.LastIndexOf("@"); - Version = remainder.Substring(index + 1); + Version = WebUtility.UrlDecode(remainder.Substring(index + 1)); remainder = remainder.Substring(0, index); } @@ -235,7 +240,7 @@ private void Parse(string purl) } Type = ValidateType(firstPartArray[0]); - Name = ValidateName(firstPartArray[firstPartArray.Length - 1]); + Name = ValidateName(WebUtility.UrlDecode(firstPartArray[firstPartArray.Length - 1])); // Test for namespaces if (firstPartArray.Length > 2) @@ -248,7 +253,7 @@ private void Parse(string purl) } @namespace += firstPartArray[i]; - Namespace = ValidateNamespace(@namespace); + Namespace = ValidateNamespace(WebUtility.UrlDecode(@namespace)); } } @@ -269,8 +274,8 @@ private string ValidateNamespace(string @namespace) } return Type switch { - "bitbucket" or "github" or "pypi" or "gitlab" => WebUtility.UrlDecode(@namespace.ToLower()), - _ => WebUtility.UrlDecode(@namespace) + "bitbucket" or "github" or "pypi" or "gitlab" => @namespace.ToLower(), + _ => @namespace }; } @@ -297,7 +302,7 @@ private static SortedDictionary ValidateQualifiers(string qualif if (pair.Contains("=")) { string[] kvpair = pair.Split('='); - list.Add(kvpair[0], kvpair[1]); + list.Add(kvpair[0], WebUtility.UrlDecode(kvpair[1])); } } return list; diff --git a/tests/PackageUrl.Tests.cs b/tests/PackageUrl.Tests.cs index 3a84673..9f0a7a5 100644 --- a/tests/PackageUrl.Tests.cs +++ b/tests/PackageUrl.Tests.cs @@ -43,7 +43,6 @@ public void TestConstructorParsing(PurlTestData data) PackageURL purl = new PackageURL(data.Purl); Assert.Equal(data.CanonicalPurl, purl.ToString()); - Assert.Equal("pkg", purl.Scheme); Assert.Equal(data.Type, purl.Type); Assert.Equal(data.Namespace, purl.Namespace); diff --git a/tests/TestAssets/test-suite-data.json b/tests/TestAssets/test-suite-data.json index 42182f1..ab7f709 100644 --- a/tests/TestAssets/test-suite-data.json +++ b/tests/TestAssets/test-suite-data.json @@ -385,5 +385,65 @@ "qualifiers": null, "subpath": null, "is_invalid": false + }, + { + "description": "valid nuget purl containing a + in version", + "purl": "pkg:nuget/Microsoft.NET.Sdk.MacCatalyst.Manifest-6.0.400@15.4.471%2Bsha.9382aa8e0", + "canonical_purl": "pkg:nuget/Microsoft.NET.Sdk.MacCatalyst.Manifest-6.0.400@15.4.471%2Bsha.9382aa8e0", + "type": "nuget", + "name": "Microsoft.NET.Sdk.MacCatalyst.Manifest-6.0.400", + "version": "15.4.471+sha.9382aa8e0", + "qualifiers": null, + "namespace": null, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid maven purl containing a plus in qualifier", + "purl": "pkg:maven/mygroup/myartifact@1.0.0Final?mykey=my%2Bvalue", + "canonical_purl": "pkg:maven/mygroup/myartifact@1.0.0Final?mykey=my%2Bvalue", + "type": "maven", + "namespace": "mygroup", + "name": "myartifact", + "version": "1.0.0Final", + "qualifiers": { "mykey": "my+value" }, + "subpath": null, + "is_invalid": false + }, + { + "description": "valid go purl with plus in subpath", + "purl": "pkg:GOLANG/google.golang.org/genproto#/google%2Bapis/api/annotations/", + "canonical_purl": "pkg:golang/google.golang.org/genproto#google%2Bapis/api/annotations", + "type": "golang", + "namespace": "google.golang.org", + "name": "genproto", + "version": null, + "qualifiers": null, + "subpath": "google+apis/api/annotations", + "is_invalid": false + }, + { + "description": "valid go purl with colon unencoded in name, version and subpath", + "purl": "pkg:GOLANG/google.golang.org/gen:proto@abc:dedf#/google:apis/api/annotations/", + "canonical_purl": "pkg:golang/google.golang.org/gen:proto@abc:dedf#google:apis/api/annotations", + "type": "golang", + "namespace": "google.golang.org", + "name": "gen:proto", + "version": "abc:dedf", + "qualifiers": null, + "subpath": "google:apis/api/annotations", + "is_invalid": false + }, + { + "description": "valid go purl with slash in name, namespace, version and qualifier", + "purl": "pkg:GOLANG/google.golang.org/repo/gen%2Fproto@abc%2Fdedf?repository_url=go.googlesource.com/go", + "canonical_purl": "pkg:golang/google.golang.org/repo/gen%2Fproto@abc%2Fdedf?repository_url=go.googlesource.com/go", + "type": "golang", + "namespace": "google.golang.org/repo", + "name": "gen/proto", + "version": "abc/dedf", + "qualifiers": { "repository_url": "go.googlesource.com/go" }, + "subpath": null, + "is_invalid": false } ]