Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 85 additions & 22 deletions src/PackageUrl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public PackageUrl(
)
{
Type = ValidateType(type);
Qualifiers = qualifiers;
Qualifiers = ValidateQualifierEntries(qualifiers);
Namespace = ValidateNamespace(@namespace);
Name = ValidateName(name);
Version = ValidateVersion(version);
Expand Down Expand Up @@ -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('/');
}
Expand Down Expand Up @@ -286,6 +286,8 @@ private static bool QualifiersEqual(
/// Decodes percent-encoded sequences (%XX) without treating '+' as space.
/// Unlike <see cref="WebUtility.UrlDecode"/>, 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.
/// </summary>
private static string PercentDecode(string value)
{
Expand All @@ -295,6 +297,7 @@ private static string PercentDecode(string value)
}

var sb = new StringBuilder(value.Length);
var byteBuffer = new List<byte>();
for (int i = 0; i < value.Length; i++)
{
if (
Expand All @@ -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();
}

Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -483,27 +495,42 @@ 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();
}

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++)
Expand All @@ -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 '-'."
);
}
}
Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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,
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -742,6 +765,46 @@ private static SortedDictionary<string, string> ValidateQualifiers(string qualif
return list;
}

/// <summary>
/// Validates pre-built qualifier entries, normalizing keys to lowercase and
/// filtering out entries with empty values per ECMA-427 §5.6.6.
/// </summary>
private static SortedDictionary<string, string>? ValidateQualifierEntries(
SortedDictionary<string, string>? qualifiers
)
{
if (qualifiers == null || qualifiers.Count == 0)
{
return qualifiers;
}

var normalized = new SortedDictionary<string, string>();
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)
Expand Down