using System.Collections.Immutable; using System.Linq; using System.Text; namespace StellaOps.Feedser.Normalization.Identifiers; /// /// Represents a parsed Package URL (purl) identifier with canonical string rendering. /// public sealed class PackageUrl { private PackageUrl( string type, ImmutableArray namespaceSegments, string name, string? version, ImmutableArray> qualifiers, ImmutableArray subpathSegments, string original) { Type = type; NamespaceSegments = namespaceSegments; Name = name; Version = version; Qualifiers = qualifiers; SubpathSegments = subpathSegments; Original = original; } public string Type { get; } public ImmutableArray NamespaceSegments { get; } public string Name { get; } public string? Version { get; } public ImmutableArray> Qualifiers { get; } public ImmutableArray SubpathSegments { get; } public string Original { get; } private static readonly HashSet LowerCaseNamespaceTypes = new(StringComparer.OrdinalIgnoreCase) { "maven", "npm", "pypi", "nuget", "composer", "gem", "apk", "deb", "rpm", "oci", }; private static readonly HashSet LowerCaseNameTypes = new(StringComparer.OrdinalIgnoreCase) { "npm", "pypi", "nuget", "composer", "gem", "apk", "deb", "rpm", "oci", }; public static bool TryParse(string? value, out PackageUrl? packageUrl) { packageUrl = null; if (string.IsNullOrWhiteSpace(value)) { return false; } var trimmed = value.Trim(); if (!trimmed.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)) { return false; } var remainder = trimmed[4..]; var firstSlash = remainder.IndexOf('/'); if (firstSlash <= 0) { return false; } var type = remainder[..firstSlash].Trim().ToLowerInvariant(); remainder = remainder[(firstSlash + 1)..]; var subpathPart = string.Empty; var subpathIndex = remainder.IndexOf('#'); if (subpathIndex >= 0) { subpathPart = remainder[(subpathIndex + 1)..]; remainder = remainder[..subpathIndex]; } var qualifierPart = string.Empty; var qualifierIndex = remainder.IndexOf('?'); if (qualifierIndex >= 0) { qualifierPart = remainder[(qualifierIndex + 1)..]; remainder = remainder[..qualifierIndex]; } string? version = null; var versionIndex = remainder.LastIndexOf('@'); if (versionIndex >= 0) { version = remainder[(versionIndex + 1)..]; remainder = remainder[..versionIndex]; } if (string.IsNullOrWhiteSpace(remainder)) { return false; } var rawSegments = remainder.Split('/', StringSplitOptions.RemoveEmptyEntries); if (rawSegments.Length == 0) { return false; } var shouldLowerNamespace = LowerCaseNamespaceTypes.Contains(type); var shouldLowerName = LowerCaseNameTypes.Contains(type); var namespaceBuilder = ImmutableArray.CreateBuilder(Math.Max(0, rawSegments.Length - 1)); for (var i = 0; i < rawSegments.Length - 1; i++) { var segment = Uri.UnescapeDataString(rawSegments[i].Trim()); if (segment.Length == 0) { continue; } if (shouldLowerNamespace) { segment = segment.ToLowerInvariant(); } namespaceBuilder.Add(EscapePathSegment(segment)); } var nameSegment = Uri.UnescapeDataString(rawSegments[^1].Trim()); if (nameSegment.Length == 0) { return false; } if (shouldLowerName) { nameSegment = nameSegment.ToLowerInvariant(); } var canonicalName = EscapePathSegment(nameSegment); var canonicalVersion = NormalizeComponent(version, escape: true, lowerCase: false); var qualifiers = ParseQualifiers(qualifierPart); var subpath = ParseSubpath(subpathPart); packageUrl = new PackageUrl( type, namespaceBuilder.ToImmutable(), canonicalName, canonicalVersion, qualifiers, subpath, trimmed); return true; } public static PackageUrl Parse(string value) { if (!TryParse(value, out var parsed)) { throw new FormatException($"Input '{value}' is not a valid Package URL."); } return parsed!; } public string ToCanonicalString() { var builder = new StringBuilder("pkg:"); builder.Append(Type); builder.Append('/'); if (!NamespaceSegments.IsDefaultOrEmpty) { builder.Append(string.Join('/', NamespaceSegments)); builder.Append('/'); } builder.Append(Name); if (!string.IsNullOrEmpty(Version)) { builder.Append('@'); builder.Append(Version); } if (!Qualifiers.IsDefaultOrEmpty && Qualifiers.Length > 0) { builder.Append('?'); builder.Append(string.Join('&', Qualifiers.Select(static kvp => $"{kvp.Key}={kvp.Value}"))); } if (!SubpathSegments.IsDefaultOrEmpty && SubpathSegments.Length > 0) { builder.Append('#'); builder.Append(string.Join('/', SubpathSegments)); } return builder.ToString(); } public override string ToString() => ToCanonicalString(); private static ImmutableArray> ParseQualifiers(string qualifierPart) { if (string.IsNullOrEmpty(qualifierPart)) { return ImmutableArray>.Empty; } var entries = qualifierPart.Split('&', StringSplitOptions.RemoveEmptyEntries); var map = new SortedDictionary(StringComparer.Ordinal); foreach (var entry in entries) { var trimmed = entry.Trim(); if (trimmed.Length == 0) { continue; } var equalsIndex = trimmed.IndexOf('='); if (equalsIndex <= 0) { continue; } var key = Uri.UnescapeDataString(trimmed[..equalsIndex]).Trim().ToLowerInvariant(); var valuePart = equalsIndex < trimmed.Length - 1 ? trimmed[(equalsIndex + 1)..] : string.Empty; var value = NormalizeComponent(valuePart, escape: true, lowerCase: false); map[key] = value; } return map.Select(static kvp => new KeyValuePair(kvp.Key, kvp.Value)).ToImmutableArray(); } private static ImmutableArray ParseSubpath(string subpathPart) { if (string.IsNullOrEmpty(subpathPart)) { return ImmutableArray.Empty; } var segments = subpathPart.Split('/', StringSplitOptions.RemoveEmptyEntries); var builder = ImmutableArray.CreateBuilder(segments.Length); foreach (var raw in segments) { var segment = Uri.UnescapeDataString(raw.Trim()); if (segment.Length == 0) { continue; } builder.Add(EscapePathSegment(segment)); } return builder.ToImmutable(); } private static string NormalizeComponent(string? value, bool escape, bool lowerCase) { if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } var unescaped = Uri.UnescapeDataString(value.Trim()); if (lowerCase) { unescaped = unescaped.ToLowerInvariant(); } return escape ? Uri.EscapeDataString(unescaped) : unescaped; } private static string EscapePathSegment(string value) { return Uri.EscapeDataString(value); } }