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