diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2b13e85 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "spec"] + path = spec + url = https://github.com/package-url/purl-spec.git diff --git a/spec b/spec new file mode 160000 index 0000000..9041aa7 --- /dev/null +++ b/spec @@ -0,0 +1 @@ +Subproject commit 9041aa74236686b23153652f8cd3862eef8c33a9 diff --git a/src/PackageUrl.cs b/src/PackageUrl.cs index b86abf9..e006b81 100644 --- a/src/PackageUrl.cs +++ b/src/PackageUrl.cs @@ -20,294 +20,185 @@ using System; using System.Collections.Generic; -using System.Net; +using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace PackageUrl { - /// - /// Provides an object representation of a Package URL and easy access to its parts. - /// - /// A purl is a URL composed of seven components: - /// scheme:type/namespace/name@version?qualifiers#subpath - /// - /// Components are separated by a specific character for unambiguous parsing. - /// A purl must NOT contain a URL Authority i.e. there is no support for username, - /// password, host and port components. A namespace segment may sometimes look - /// like a host but its interpretation is specific to a type. - /// - /// To read full-spec, visit https://github.com/package-url/purl-spec - /// - [Serializable] - public sealed class PackageURL + public 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); - - /// - /// The PackageURL scheme constant. - /// - public string Scheme { get; private set; } = "pkg"; - - /// - /// The package "type" or package "protocol" such as nuget, npm, nuget, gem, pypi, etc. - /// - public string Type { get; private set; } + private static readonly Regex SchemeRegex = new Regex(@"^[a-z][a-z0-9+.-]*$", RegexOptions.IgnoreCase); + private static readonly Regex TypeRegex = new Regex(@"^[a-z][a-z0-9+.-]*$", RegexOptions.IgnoreCase); + private static readonly Regex NameRegex = new Regex(@"^[a-zA-Z0-9_.\\-]+$", RegexOptions.Compiled); - /// - /// The name prefix such as a Maven groupid, a Docker image owner, a GitHub user or organization. - /// + public string Scheme { get; private set; } + public string Type { get; private set; } public string Namespace { get; private set; } - - /// - /// The name of the package. - /// public string Name { get; private set; } - - /// - /// The version of the package. - /// public string Version { get; private set; } - - /// - /// Extra qualifying data for a package such as an OS, architecture, a distro, etc. - /// - public SortedDictionary Qualifiers { get; private set; } - - /// - /// Extra subpath within a package, relative to the package root. - /// + public Dictionary Qualifiers { get; private set; } public string Subpath { get; private set; } - /// - /// Constructs a new PackageURL object by parsing the specified string. - /// - /// A valid package URL string to parse. - /// Thrown when parsing fails. - public PackageURL(string purl) + public PackageUrl( + string type, + string name, + string version = null, + string @namespace = null, + Dictionary qualifiers = null, + string subpath = null, + string scheme = "pkg") { - Parse(purl); + Scheme = ValidateScheme(scheme); + Type = ValidateType(type); + Namespace = NormalizeNamespace(@namespace); + Name = ValidateName(name); + Version = version?.Trim(); + Qualifiers = qualifiers != null + ? new Dictionary(qualifiers) + : new Dictionary(); + Subpath = NormalizeSubpath(subpath); } - /// - /// Constructs a new PackageURL object by specifying only the required - /// parameters necessary to create a valid PackageURL. - /// - /// Type of package (i.e. nuget, npm, gem, etc). - /// Name of the package. - /// Thrown when parsing fails. - public PackageURL(string type, string name) : this(type, null, name, null, null, null) + private static string ValidateScheme(string scheme) { + if (string.IsNullOrWhiteSpace(scheme) || !SchemeRegex.IsMatch(scheme)) + throw new ArgumentException($"Invalid scheme: {scheme}"); + return scheme.ToLowerInvariant(); } - /// - /// Constructs a new PackageURL object. - /// - /// Type of package (i.e. nuget, npm, gem, etc). - /// Namespace of package (i.e. group, owner, organization). - /// Name of the package. - /// Version of the package. - /// of key/value pair qualifiers. - /// @param qualifiers an array of key/value pair qualifiers - /// @param subpath the subpath string - /// Thrown when parsing fails. - public PackageURL(string type, string @namespace, string name, string version, SortedDictionary qualifiers, string subpath) + private static string ValidateType(string type) { - Type = ValidateType(type); - Namespace = ValidateNamespace(@namespace); - Name = ValidateName(name); - Version = version; - Qualifiers = qualifiers; - Subpath = ValidateSubpath(subpath); + if (string.IsNullOrWhiteSpace(type) || !TypeRegex.IsMatch(type)) + throw new ArgumentException($"Invalid type: {type}"); + return type.ToLowerInvariant(); } - /// - /// Returns a canonicalized representation of the purl. - /// - public override string ToString() + private static string ValidateName(string name) { - var purl = new StringBuilder(); - purl.Append(Scheme).Append(':'); - if (Type != null) - { - purl.Append(Type); - } - purl.Append('/'); - if (Namespace != null) - { - string encodedNamespace = WebUtility.UrlEncode(Namespace).Replace(EncodedSlash, "/"); - purl.Append(encodedNamespace); - purl.Append('/'); - } - if (Name != null) - { - string encodedName = WebUtility.UrlEncode(Name).Replace(EncodedColon, ":"); - purl.Append(encodedName); - } - if (Version != null) - { - 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(encodedValue); - purl.Append('&'); - } - purl.Remove(purl.Length - 1, 1); - } - if (Subpath != null) - { - string encodedSubpath = WebUtility.UrlEncode(Subpath).Replace(EncodedSlash, "/").Replace(EncodedColon, ":"); - purl.Append("#").Append(encodedSubpath); - } - return purl.ToString(); + if (string.IsNullOrWhiteSpace(name) || !NameRegex.IsMatch(name)) + throw new ArgumentException($"Invalid name: {name}"); + return name; } - private void Parse(string purl) + private static string NormalizeNamespace(string ns) { - if (purl == null || string.IsNullOrWhiteSpace(purl)) - { - throw new MalformedPackageUrlException("Invalid purl: Contains an empty or null value"); - } + if (string.IsNullOrWhiteSpace(ns)) + return null; - Uri uri; - try - { - uri = new Uri(purl); - } - catch (UriFormatException e) - { - throw new MalformedPackageUrlException("Invalid purl: " + e.Message); - } + return ns.Replace('\\', '/').Trim('/'); + } - // Check to ensure that none of these parts are parsed. If so, it's an invalid purl. - if (!string.IsNullOrEmpty(uri.UserInfo) || uri.Port != -1) - { - throw new MalformedPackageUrlException("Invalid purl: Contains parts not supported by the purl spec"); - } + private static string NormalizeSubpath(string subpath) + { + if (string.IsNullOrWhiteSpace(subpath)) + return null; - if (uri.Scheme != "pkg") - { - throw new MalformedPackageUrlException("The PackageURL scheme is invalid"); - } + return subpath.Replace('\\', '/').Trim('/'); + } + + public static PackageUrl FromString(string purl) + { + if (string.IsNullOrWhiteSpace(purl)) + throw new ArgumentException("purl cannot be null or empty."); + + if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("Package URL must start with 'pkg:'"); - // This is the purl (minus the scheme) that needs parsed. string remainder = purl.Substring(4); + string scheme = "pkg"; + string subpath = null; + string qualifiers = null; + string version = null; - if (remainder.Contains("#")) - { // subpath is optional - check for existence - int index = remainder.LastIndexOf("#"); - Subpath = ValidateSubpath(WebUtility.UrlDecode(remainder.Substring(index + 1))); - remainder = remainder.Substring(0, index); + // Extract subpath + var subpathSplit = remainder.Split('#'); + if (subpathSplit.Length > 1) + { + remainder = subpathSplit[0]; + subpath = subpathSplit[1]; } - if (remainder.Contains("?")) - { // qualifiers are optional - check for existence - int index = remainder.LastIndexOf("?"); - Qualifiers = ValidateQualifiers(remainder.Substring(index + 1)); - remainder = remainder.Substring(0, index); + // Extract qualifiers + var qualifierSplit = remainder.Split('?'); + if (qualifierSplit.Length > 1) + { + remainder = qualifierSplit[0]; + qualifiers = qualifierSplit[1]; } - if (remainder.Contains("@")) - { // version is optional - check for existence - int index = remainder.LastIndexOf("@"); - Version = WebUtility.UrlDecode(remainder.Substring(index + 1)); - remainder = remainder.Substring(0, index); + // Extract version + var versionSplit = remainder.Split('@'); + if (versionSplit.Length > 1) + { + remainder = versionSplit[0]; + version = versionSplit[1]; } - // The 'remainder' should now consist of the type, an optional namespace, and the name + // Extract type / namespace / name + var parts = remainder.Split('/'); + if (parts.Length < 2) + throw new ArgumentException($"Invalid purl: {purl}"); - // Strip zero or more '/' ('type') - remainder = remainder.Trim('/'); + string type = parts[0]; + string name; + string ns = null; - string[] firstPartArray = remainder.Split('/'); - if (firstPartArray.Length < 2) - { // The array must contain a 'type' and a 'name' at minimum - throw new MalformedPackageUrlException("Invalid purl: Does not contain a minimum of a 'type' and a 'name'"); + if (parts.Length == 2) + name = parts[1]; + else + { + ns = string.Join("/", parts.Skip(1).Take(parts.Length - 2)); + name = parts.Last(); } - Type = ValidateType(firstPartArray[0]); - Name = ValidateName(WebUtility.UrlDecode(firstPartArray[firstPartArray.Length - 1])); + var qualifiersDict = ParseQualifiers(qualifiers); - // Test for namespaces - if (firstPartArray.Length > 2) - { - string @namespace = ""; - int i; - for (i = 1; i < firstPartArray.Length - 2; ++i) - { - @namespace += firstPartArray[i] + '/'; - } - @namespace += firstPartArray[i]; - - Namespace = ValidateNamespace(WebUtility.UrlDecode(@namespace)); - } + return new PackageUrl(type, name, version, ns, qualifiersDict, subpath, scheme); } - private static string ValidateType(string type) + private static Dictionary ParseQualifiers(string qualifiers) { - if (type == null || !s_typePattern.IsMatch(type)) - { - throw new MalformedPackageUrlException("The PackageURL type specified is invalid"); - } - return type.ToLower(); - } + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); - private string ValidateNamespace(string @namespace) - { - if (@namespace == null) - { - return null; - } - return Type switch - { - "bitbucket" or "github" or "pypi" or "gitlab" => @namespace.ToLower(), - _ => @namespace - }; - } + if (string.IsNullOrWhiteSpace(qualifiers)) + return dict; - private string ValidateName(string name) - { - if (name == null) + foreach (var pair in qualifiers.Split('&')) { - throw new MalformedPackageUrlException("The PackageURL name specified is invalid"); + var kv = pair.Split('='); + if (kv.Length == 2) + dict[kv[0].ToLowerInvariant()] = kv[1]; } - return Type switch - { - "bitbucket" or "github" or "gitlab" => name.ToLower(), - "pypi" => name.Replace('_', '-').ToLower(), - _ => name - }; + + return dict; } - private static SortedDictionary ValidateQualifiers(string qualifiers) + public override string ToString() { - var list = new SortedDictionary(); - string[] pairs = qualifiers.Split('&'); - foreach (var pair in pairs) + var builder = new StringBuilder(); + builder.Append($"{Scheme}:{Type}/"); + + if (!string.IsNullOrWhiteSpace(Namespace)) + builder.Append($"{Namespace.TrimEnd('/')}/"); + + builder.Append(Name); + + if (!string.IsNullOrWhiteSpace(Version)) + builder.Append($"@{Version}"); + + if (Qualifiers?.Count > 0) { - if (pair.Contains("=")) - { - string[] kvpair = pair.Split('='); - list.Add(kvpair[0], WebUtility.UrlDecode(kvpair[1])); - } + builder.Append("?"); + builder.Append(string.Join("&", Qualifiers.Select(kv => $"{kv.Key}={kv.Value}"))); } - return list; + + if (!string.IsNullOrWhiteSpace(Subpath)) + builder.Append($"#{Subpath}"); + + return builder.ToString(); } - private static string ValidateSubpath(string subpath) => subpath?.Trim('/'); // leading and trailing slashes always need to be removed + public string ToCoordinates() => $"{Type}/{Namespace}/{Name}@{Version}"; } } diff --git a/tests/PackageUrlSpec.Tests.cs b/tests/PackageUrlSpec.Tests.cs new file mode 100644 index 0000000..4dd6641 --- /dev/null +++ b/tests/PackageUrlSpec.Tests.cs @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) the purl authors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace PackageUrl.Tests +{ + public record PurlTestCase( + string Description, + string TestType, + object Input, + object ExpectedOutput = null, + bool ExpectedFailure = false, + string TestGroup = null + ); + + public static class SpecLoader + { + public static List LoadTestFile(string filePath) + { + var json = File.ReadAllText(filePath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var tests = new List(); + + foreach (var el in root.GetProperty("tests").EnumerateArray()) + { + tests.Add(new PurlTestCase( + el.GetProperty("description").GetString(), + el.GetProperty("test_type").GetString(), + JsonElementToObject(el.GetProperty("input")), + el.TryGetProperty("expected_output", out var eo) ? JsonElementToObject(eo) : null, + el.TryGetProperty("expected_failure", out var ef) && ef.GetBoolean(), + el.TryGetProperty("test_group", out var tg) ? tg.GetString() : null + )); + } + + return tests; + } + + public static Dictionary> LoadSpecFiles(string directory) + { + var dict = new Dictionary>(); + foreach (var file in Directory.EnumerateFiles(directory, "*-test.json")) + { + try + { + dict[Path.GetFileName(file)] = LoadTestFile(file); + } + catch (Exception ex) + { + Console.WriteLine($"Error parsing {file}: {ex.Message}"); + } + } + return dict; + } + + private static object JsonElementToObject(JsonElement el) + { + return el.ValueKind switch + { + JsonValueKind.Object => JsonSerializer.Deserialize>(el.GetRawText()), + JsonValueKind.Array => JsonSerializer.Deserialize>(el.GetRawText()), + JsonValueKind.String => el.GetString(), + JsonValueKind.Number => el.TryGetInt64(out var i) ? i : el.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + _ => null + }; + } + } + + public class PackageUrlTests + { + private static readonly string RootDir = Path.Combine(AppContext.BaseDirectory, "..", "..", ".."); + private static readonly string SpecPath = Path.Combine(RootDir, "spec", "tests", "spec", "specification-test.json"); + private static readonly string SpecDir = Path.Combine(RootDir, "spec", "tests", "types"); + + public static IEnumerable ParseTests => SpecLoader.LoadTestFile(SpecPath) + .Where(tc => tc.TestType == "parse") + .Select(tc => new object[] { tc }); + + public static IEnumerable BuildTests => SpecLoader.LoadTestFile(SpecPath) + .Where(tc => tc.TestType == "build") + .Select(tc => new object[] { tc }); + + public static IEnumerable FlattenedCases => SpecLoader.LoadSpecFiles(SpecDir) + .SelectMany(kv => kv.Value.Select(v => new object[] { kv.Key, v.Description, v })); + + [Theory(DisplayName = "Parse tests")] + [MemberData(nameof(ParseTests))] + public void TestParse(PurlTestCase caseData) + { + if (caseData.ExpectedFailure) + { + Assert.ThrowsAny(() => PackageUrl.FromString(caseData.Input.ToString())); + } + else + { + var result = PackageUrl.FromString(caseData.Input.ToString()); + Assert.Equal(caseData.ExpectedOutput?.ToString(), result.ToString()); + } + } + + [Theory(DisplayName = "Build tests")] + [MemberData(nameof(BuildTests))] + public void TestBuild(PurlTestCase caseData) + { + var input = caseData.Input as Dictionary; + var qualifiers = input.ContainsKey("qualifiers") + ? JsonSerializer.Deserialize>(input["qualifiers"].ToString()) + : null; + + if (caseData.ExpectedFailure) + { + Assert.ThrowsAny(() => + new PackageUrl( + input["type"].ToString(), + input["name"].ToString(), + input.GetValueOrDefault("version")?.ToString(), + input.GetValueOrDefault("namespace")?.ToString(), + qualifiers, + input.GetValueOrDefault("subpath")?.ToString() + ).ToString()); + } + else + { + var purl = new PackageUrl( + input["type"].ToString(), + input["name"].ToString(), + input.GetValueOrDefault("version")?.ToString(), + input.GetValueOrDefault("namespace")?.ToString(), + qualifiers, + input.GetValueOrDefault("subpath")?.ToString() + ); + Assert.Equal(caseData.ExpectedOutput?.ToString(), purl.ToString()); + } + } + + [Theory(DisplayName = "Package type case tests")] + [MemberData(nameof(FlattenedCases))] + public void TestPackageTypeCases(string filename, string description, PurlTestCase caseData) + { + if (caseData.ExpectedFailure) + Assert.ThrowsAny(() => RunTestCase(caseData)); + else + RunTestCase(caseData); + } + + private void RunTestCase(PurlTestCase caseData) + { + switch (caseData.TestType) + { + case "parse": + var purl = PackageUrl.FromString(caseData.Input.ToString()); + var expected = caseData.ExpectedOutput as Dictionary; + Assert.Equal(expected["type"], purl.Type); + Assert.Equal(expected["namespace"], purl.Namespace); + Assert.Equal(expected["name"], purl.Name); + Assert.Equal(expected["version"], purl.Version); + if (expected.ContainsKey("qualifiers") && expected["qualifiers"] is Dictionary q) + Assert.Equal(q.ToDictionary(k => k.Key, v => v.Value.ToString()), purl.Qualifiers); + else + Assert.Empty(purl.Qualifiers); + Assert.Equal(expected["subpath"], purl.Subpath); + break; + + case "roundtrip": + var rt = PackageUrl.FromString(caseData.Input.ToString()); + Assert.Equal(caseData.ExpectedOutput.ToString(), rt.ToString()); + break; + + case "build": + var inp = caseData.Input as Dictionary; + var pq = inp.ContainsKey("qualifiers") + ? JsonSerializer.Deserialize>(inp["qualifiers"].ToString()) + : null; + var purl2 = new PackageUrl( + inp["type"].ToString(), + inp["name"].ToString(), + inp.GetValueOrDefault("version")?.ToString(), + inp.GetValueOrDefault("namespace")?.ToString(), + pq, + inp.GetValueOrDefault("subpath")?.ToString() + ); + Assert.Equal(caseData.ExpectedOutput.ToString(), purl2.ToString()); + break; + + default: + throw new Exception($"Unknown test type: {caseData.TestType}"); + } + } + } +}