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)