diff --git a/src/PackageUrl.cs b/src/PackageUrl.cs
index 9957858..c4c8c71 100644
--- a/src/PackageUrl.cs
+++ b/src/PackageUrl.cs
@@ -119,7 +119,7 @@ public PackageUrl(
)
{
Type = ValidateType(type);
- Qualifiers = qualifiers;
+ Qualifiers = ValidateQualifierEntries(qualifiers);
Namespace = ValidateNamespace(@namespace);
Name = ValidateName(name);
Version = ValidateVersion(version);
@@ -162,7 +162,7 @@ public override string ToString()
purl.Append('/');
if (Namespace != null)
{
- string encodedNamespace = PercentEncode(Namespace, "/");
+ string encodedNamespace = PercentEncode(Namespace, "/:");
purl.Append(encodedNamespace);
purl.Append('/');
}
@@ -286,6 +286,8 @@ private static bool QualifiersEqual(
/// Decodes percent-encoded sequences (%XX) without treating '+' as space.
/// Unlike , which uses form-encoding rules,
/// PURL uses strict percent-encoding where '+' is a literal character.
+ /// Consecutive percent-encoded bytes are accumulated and decoded as a single
+ /// UTF-8 sequence per ECMA-427 §5.4.
///
private static string PercentDecode(string value)
{
@@ -295,6 +297,7 @@ private static string PercentDecode(string value)
}
var sb = new StringBuilder(value.Length);
+ var byteBuffer = new List();
for (int i = 0; i < value.Length; i++)
{
if (
@@ -306,14 +309,23 @@ private static string PercentDecode(string value)
{
int hi = HexVal(value[i + 1]);
int lo = HexVal(value[i + 2]);
- sb.Append((char)((hi << 4) | lo));
+ byteBuffer.Add((byte)((hi << 4) | lo));
i += 2;
}
else
{
+ if (byteBuffer.Count > 0)
+ {
+ sb.Append(Encoding.UTF8.GetString(byteBuffer.ToArray()));
+ byteBuffer.Clear();
+ }
sb.Append(value[i]);
}
}
+ if (byteBuffer.Count > 0)
+ {
+ sb.Append(Encoding.UTF8.GetString(byteBuffer.ToArray()));
+ }
return sb.ToString();
}
@@ -405,10 +417,7 @@ private void Parse(string purl)
if (remainder.Length >= 2 && remainder[0] == '/' && remainder[1] == '/')
{
int authorityEnd = remainder.IndexOf('/', 2);
- string authority =
- authorityEnd == -1
- ? remainder.Substring(2)
- : remainder.Substring(2, authorityEnd - 2);
+ string authority = authorityEnd == -1 ? remainder.Substring(2) : remainder.Substring(2, authorityEnd - 2);
if (authority.IndexOf('@') >= 0)
{
@@ -438,14 +447,17 @@ private void Parse(string purl)
}
}
- int subpathIndex = remainder.LastIndexOf('#');
+ // Per RFC 3986, the fragment starts at the first '#'.
+ int subpathIndex = remainder.IndexOf('#');
if (subpathIndex >= 0)
{
Subpath = ValidateSubpath(PercentDecode(remainder.Substring(subpathIndex + 1)));
remainder = remainder.Substring(0, subpathIndex);
}
- int qualifiersIndex = remainder.LastIndexOf('?');
+ // Per RFC 3986, the query starts at the first '?' (before any fragment,
+ // which has already been stripped above).
+ int qualifiersIndex = remainder.IndexOf('?');
if (qualifiersIndex >= 0)
{
Qualifiers = ValidateQualifiers(remainder.Substring(qualifiersIndex + 1));
@@ -483,9 +495,24 @@ private void Parse(string purl)
// Test for namespaces
if (firstPartArray.Length > 2)
{
- string @namespace = string.Join("/", firstPartArray, 1, firstPartArray.Length - 2);
+ // Decode each namespace segment individually per ECMA-427 §5.6.3.
+ // A decoded segment must not contain '/' characters; decoding the
+ // joined string would silently turn %2F into a segment separator.
+ var nsSegments = new string[firstPartArray.Length - 2];
+ for (int i = 1; i < firstPartArray.Length - 1; i++)
+ {
+ string decoded = PercentDecode(firstPartArray[i]);
+ if (decoded.IndexOf('/') >= 0)
+ {
+ throw new MalformedPackageUrlException(
+ "A purl namespace segment must not contain '/' when percent-decoded."
+ );
+ }
+ nsSegments[i - 1] = decoded;
+ }
+ string @namespace = string.Join("/", nsSegments);
- Namespace = ValidateNamespace(PercentDecode(@namespace));
+ Namespace = ValidateNamespace(@namespace);
}
ValidateTypeConstraints();
@@ -493,17 +520,17 @@ private void Parse(string purl)
private static string ValidateType(string type)
{
- if (type == null || type.Length < 2)
+ if (type == null || type.Length == 0)
{
throw new MalformedPackageUrlException(
- "The purl type is invalid. Must be at least two characters, start with a letter, and contain only letters, digits, '.', or '-'."
+ "The purl type is invalid. Must start with a letter and contain only letters, digits, '.', or '-'."
);
}
char first = type[0];
if (!((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z')))
{
throw new MalformedPackageUrlException(
- "The purl type is invalid. Must be at least two characters, start with a letter, and contain only letters, digits, '.', or '-'."
+ "The purl type is invalid. Must start with a letter and contain only letters, digits, '.', or '-'."
);
}
for (int i = 1; i < type.Length; i++)
@@ -520,7 +547,7 @@ private static string ValidateType(string type)
)
{
throw new MalformedPackageUrlException(
- "The purl type is invalid. Must be at least two characters, start with a letter, and contain only letters, digits, '.', or '-'."
+ "The purl type is invalid. Must start with a letter and contain only letters, digits, '.', or '-'."
);
}
}
@@ -559,8 +586,7 @@ private static string ValidateType(string type)
or "golang"
or "pypi"
or "qpkg"
- or "rpm"
- => @namespace.ToLowerInvariant(),
+ or "rpm" => @namespace.ToLowerInvariant(),
_ => @namespace,
};
}
@@ -581,8 +607,7 @@ private string ValidateName(string name)
or "deb"
or "github"
or "gitlab"
- or "golang"
- => name.ToLowerInvariant(),
+ or "golang" => name.ToLowerInvariant(),
"pypi" => name.Replace('_', '-').ToLowerInvariant(),
"mlflow" => AdjustMlflowName(name),
_ => name,
@@ -658,9 +683,7 @@ private void ValidateTypeConstraints()
case "swift":
if (Namespace == null)
{
- throw new MalformedPackageUrlException(
- "A swift purl must have a namespace."
- );
+ throw new MalformedPackageUrlException("A swift purl must have a namespace.");
}
break;
case "vscode-extension":
@@ -742,6 +765,46 @@ private static SortedDictionary ValidateQualifiers(string qualif
return list;
}
+ ///
+ /// Validates pre-built qualifier entries, normalizing keys to lowercase and
+ /// filtering out entries with empty values per ECMA-427 §5.6.6.
+ ///
+ private static SortedDictionary? ValidateQualifierEntries(
+ SortedDictionary? qualifiers
+ )
+ {
+ if (qualifiers == null || qualifiers.Count == 0)
+ {
+ return qualifiers;
+ }
+
+ var normalized = new SortedDictionary();
+ foreach (var pair in qualifiers)
+ {
+ string key = pair.Key.ToLowerInvariant();
+
+ if (!IsValidQualifierKey(key))
+ {
+ throw new MalformedPackageUrlException(
+ $"Invalid purl qualifier key: '{key}'. Keys must start with a letter and contain only letters, digits, '.', '_', or '-'."
+ );
+ }
+
+ if (string.IsNullOrEmpty(pair.Value))
+ {
+ continue;
+ }
+
+ if (normalized.ContainsKey(key))
+ {
+ throw new MalformedPackageUrlException($"Duplicate purl qualifier key: '{key}'.");
+ }
+
+ normalized.Add(key, pair.Value);
+ }
+ return normalized.Count > 0 ? normalized : null;
+ }
+
private static string? ValidateSubpath(string? subpath)
{
if (subpath == null)