300 lines
8.3 KiB
C#
300 lines
8.3 KiB
C#
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Text;
|
|
|
|
namespace StellaOps.Feedser.Normalization.Identifiers;
|
|
|
|
/// <summary>
|
|
/// Represents a parsed Package URL (purl) identifier with canonical string rendering.
|
|
/// </summary>
|
|
public sealed class PackageUrl
|
|
{
|
|
private PackageUrl(
|
|
string type,
|
|
ImmutableArray<string> namespaceSegments,
|
|
string name,
|
|
string? version,
|
|
ImmutableArray<KeyValuePair<string, string>> qualifiers,
|
|
ImmutableArray<string> subpathSegments,
|
|
string original)
|
|
{
|
|
Type = type;
|
|
NamespaceSegments = namespaceSegments;
|
|
Name = name;
|
|
Version = version;
|
|
Qualifiers = qualifiers;
|
|
SubpathSegments = subpathSegments;
|
|
Original = original;
|
|
}
|
|
|
|
public string Type { get; }
|
|
|
|
public ImmutableArray<string> NamespaceSegments { get; }
|
|
|
|
public string Name { get; }
|
|
|
|
public string? Version { get; }
|
|
|
|
public ImmutableArray<KeyValuePair<string, string>> Qualifiers { get; }
|
|
|
|
public ImmutableArray<string> SubpathSegments { get; }
|
|
|
|
public string Original { get; }
|
|
|
|
private static readonly HashSet<string> LowerCaseNamespaceTypes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"maven",
|
|
"npm",
|
|
"pypi",
|
|
"nuget",
|
|
"composer",
|
|
"gem",
|
|
"apk",
|
|
"deb",
|
|
"rpm",
|
|
"oci",
|
|
};
|
|
|
|
private static readonly HashSet<string> 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<string>(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<KeyValuePair<string, string>> ParseQualifiers(string qualifierPart)
|
|
{
|
|
if (string.IsNullOrEmpty(qualifierPart))
|
|
{
|
|
return ImmutableArray<KeyValuePair<string, string>>.Empty;
|
|
}
|
|
|
|
var entries = qualifierPart.Split('&', StringSplitOptions.RemoveEmptyEntries);
|
|
var map = new SortedDictionary<string, string>(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<string, string>(kvp.Key, kvp.Value)).ToImmutableArray();
|
|
}
|
|
|
|
private static ImmutableArray<string> ParseSubpath(string subpathPart)
|
|
{
|
|
if (string.IsNullOrEmpty(subpathPart))
|
|
{
|
|
return ImmutableArray<string>.Empty;
|
|
}
|
|
|
|
var segments = subpathPart.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
var builder = ImmutableArray.CreateBuilder<string>(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);
|
|
}
|
|
}
|