// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers.Text;
using System.Diagnostics;
using System.Globalization;
using System.IO.Hashing;
using System.Security.Cryptography;
using System.Text;
using Aspire.Hosting.Utils;
namespace Aspire.Hosting.Backchannel;
/// <summary>
/// Shared constants and helpers for backchannel socket communication between
/// AppHost and CLI. These MUST stay in sync between both components.
/// </summary>
/// <remarks>
/// <para>
/// <strong>Architecture Overview</strong>
/// </para>
/// <para>
/// The backchannel is a Unix domain socket that enables bidirectional communication:
/// </para>
/// <list type="bullet">
/// <item>CLI -> AppHost: Commands (stop, get info, etc.)</item>
/// <item>AppHost -> CLI: Status updates, events</item>
/// </list>
/// <para>
/// <strong>Socket File Location</strong>
/// </para>
/// <para>
/// Compact socket files are stored in: <c>~/.aspire/cli/bch/</c>
/// </para>
/// <para>
/// <strong>Socket Naming Format</strong>
/// </para>
/// <para>
/// Compact format: <c>{appHostId}{instanceId}.{pid}</c>
/// </para>
/// <list type="bullet">
/// <item><c>{appHostId}</c> - xxHash(AppHost project path) encoded as 11 base64url chars - identifies the AppHost project</item>
/// <item><c>{instanceId}</c> - 48-bit random identifier encoded as 8 base64url chars - makes each socket name non-deterministic</item>
/// <item><c>{pid}</c> - Process ID of the AppHost - identifies the specific instance</item>
/// </list>
/// <para>
/// Legacy current format: <c>auxi.sock.{appHostHash}.{instanceHash}.{pid}</c>
/// </para>
/// <para>
/// Legacy previous format: <c>auxi.sock.{appHostHash}.{pid}</c>
/// </para>
/// <para>
/// Legacy old format: <c>auxi.sock.{appHostHash}</c>
/// </para>
/// </remarks>
internal static class BackchannelConstants
{
/// <summary>
/// Prefix for legacy auxiliary backchannel sockets.
/// </summary>
/// <remarks>
/// Uses "auxi" instead of "aux" because "aux" is a reserved device name on Windows
/// (from DOS days: CON, PRN, AUX, NUL, COM1-9, LPT1-9). Using "aux" causes
/// "SocketException: A socket operation encountered a dead network" on Windows.
/// </remarks>
public const string SocketPrefix = "auxi.sock";
/// <summary>
/// Number of hex characters to use from the stable xxHash-based legacy AppHost identifier.
/// </summary>
public const int HashLength = 16;
/// <summary>
/// Number of hex characters to use for compact legacy local identifiers.
/// </summary>
public const int CompactIdentifierLength = 12;
/// <summary>
/// Number of hex characters to use from the randomized legacy instance identifier.
/// </summary>
public const int InstanceHashLength = CompactIdentifierLength;
/// <summary>
/// Number of base64url characters in a compact AppHost identifier.
/// </summary>
public const int CompactAppHostIdLength = 11;
/// <summary>
/// Number of base64url characters in a compact instance identifier.
/// </summary>
public const int CompactInstanceIdLength = 8;
private const int CompactAppHostIdByteCount = 8;
private const int CompactInstanceIdByteCount = 6;
private const int MacOSSocketPathBytesIncludingNull = 104;
private const int DefaultSocketPathBytesIncludingNull = 108;
/// <summary>
/// Gets the compact backchannels directory path for the given home directory.
/// </summary>
/// <param name="homeDirectory">The user's home directory.</param>
/// <returns>The full path to the compact backchannels directory.</returns>
public static string GetBackchannelsDirectory(string homeDirectory)
=> Path.Combine(homeDirectory, ".aspire", "cli", "bch");
/// <summary>
/// Gets the legacy backchannels directory path for the given home directory.
/// </summary>
/// <param name="homeDirectory">The user's home directory.</param>
/// <returns>The full path to the legacy backchannels directory.</returns>
public static string GetLegacyBackchannelsDirectory(string homeDirectory)
=> Path.Combine(homeDirectory, ".aspire", "cli", "backchannels");
/// <summary>
/// Computes the compact AppHost identifier from an AppHost path.
/// </summary>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <returns>An 11-character base64url string.</returns>
public static string ComputeAppHostId(string appHostPath)
{
return ComputeStableBase64UrlIdentifier(NormalizePath(appHostPath), CompactAppHostIdByteCount);
}
/// <summary>
/// Computes the legacy hash portion of the socket name from an AppHost path.
/// </summary>
/// <remarks>
/// On Windows the entire path is upper-cased before hashing so that paths differing
/// only in casing (for example, <c>FileInfo.FullName</c> on the CLI side vs. MSBuild
/// metadata on the AppHost side) produce the same hash. On other platforms the path
/// is hashed as-is because case-sensitive APFS exists on macOS and Linux file systems
/// are case-sensitive.
/// </remarks>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <returns>A 16-character lowercase hex string.</returns>
public static string ComputeHash(string appHostPath)
{
return ComputeStableIdentifier(NormalizePath(appHostPath), HashLength);
}
/// <summary>
/// Computes the legacy (pre-normalization) hash for backward compatibility.
/// </summary>
/// <remarks>
/// Older AppHost versions hashed the raw path without case normalization.
/// The CLI uses this to find sockets created by older AppHosts.
/// Returns <c>null</c> when the legacy hash would be identical to <see cref="ComputeHash"/>.
/// </remarks>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <returns>A 16-character lowercase hex string, or <c>null</c> if it matches the current hash.</returns>
public static string? ComputeLegacyHash(string appHostPath)
{
if (!OperatingSystem.IsWindows())
{
return null;
}
var currentHash = ComputeHash(appHostPath);
var legacyHash = ComputeStableIdentifier(appHostPath, HashLength);
if (!string.Equals(legacyHash, currentHash, StringComparison.Ordinal))
{
return legacyHash;
}
// If the input path was already normalized (for example, an uppercase drive letter on Windows),
// also try the opposite drive-letter casing to preserve compatibility with older AppHosts that
// may have hashed the same path with a lowercase drive letter from MSBuild metadata.
if (appHostPath.Length >= 2 && appHostPath[1] == ':' && char.IsLetter(appHostPath[0]))
{
var alternateDriveLetter = char.IsUpper(appHostPath[0])
? char.ToLowerInvariant(appHostPath[0])
: char.ToUpperInvariant(appHostPath[0]);
var alternatePath = alternateDriveLetter + appHostPath[1..];
var alternateLegacyHash = ComputeStableIdentifier(alternatePath, HashLength);
if (!string.Equals(alternateLegacyHash, currentHash, StringComparison.Ordinal))
{
return alternateLegacyHash;
}
}
return null;
}
/// <summary>
/// Computes all legacy hashes that should be searched for an AppHost path.
/// </summary>
/// <remarks>
/// Returns, in order: the current normalized hash, the drive-letter-only normalized hash
/// (produced by AppHost versions that only upper-cased the drive letter before hashing),
/// and any raw-path fallback returned by <see cref="ComputeLegacyHash"/>. Duplicates are
/// removed.
/// </remarks>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <returns>All hash variants that should be searched.</returns>
public static string[] ComputeLegacyHashes(string appHostPath)
{
var currentHash = ComputeHash(appHostPath);
var rawLegacyHash = ComputeLegacyHash(appHostPath);
var results = new List<string>() { currentHash };
if (rawLegacyHash is not null && !results.Contains(rawLegacyHash, StringComparer.Ordinal))
{
results.Add(rawLegacyHash);
}
return results.ToArray();
}
/// <summary>
/// Normalizes the path for consistent hashing.
/// </summary>
/// <remarks>
/// On Windows the entire path is upper-cased with <see cref="string.ToUpperInvariant"/>.
/// NTFS/ReFS treat paths as case-insensitive, but neither <c>FileInfo.FullName</c> (CLI side)
/// nor MSBuild metadata (AppHost side) canonicalizes casing against disk, so any segment
/// can differ between the two sides. <c>ToUpperInvariant</c> is deterministic across
/// machines and cultures, so both sides always agree on the same hash input.
/// On macOS and Linux the path is returned unchanged: case-sensitive APFS exists on macOS
/// and Linux file systems are case-sensitive by default.
/// </remarks>
private static string NormalizePath(string path)
{
return OperatingSystem.IsWindows() ? path.ToUpperInvariant() : path;
}
/// <summary>
/// Computes the full socket path for an AppHost instance.
/// </summary>
/// <remarks>
/// Called by AppHost when creating the socket. Includes a randomized instance ID and the PID
/// to ensure uniqueness across multiple instances of the same AppHost.
/// </remarks>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <param name="homeDirectory">The user's home directory.</param>
/// <param name="processId">The process ID of the AppHost.</param>
/// <returns>The full socket path including PID.</returns>
public static string ComputeSocketPath(string appHostPath, string homeDirectory, int processId)
{
var appHostId = ComputeAppHostId(appHostPath);
return ComputeSocketPathFromAppHostId(appHostId, homeDirectory, processId);
}
/// <summary>
/// Computes the full socket path for an AppHost identifier.
/// </summary>
/// <param name="appHostId">The compact AppHost identifier.</param>
/// <param name="homeDirectory">The user's home directory.</param>
/// <param name="processId">The process ID of the AppHost.</param>
/// <returns>The full socket path including PID.</returns>
public static string ComputeSocketPathFromAppHostId(string appHostId, string homeDirectory, int processId)
{
ValidateCompactAppHostId(appHostId);
var dir = GetBackchannelsDirectory(homeDirectory);
var instanceId = CreateRandomBase64UrlIdentifier();
var socketPath = Path.Combine(dir, $"{appHostId}{instanceId}.{processId.ToString(CultureInfo.InvariantCulture)}");
ValidateSocketPathLength(socketPath);
return socketPath;
}
/// <summary>
/// Computes a randomized CLI-managed Unix socket path.
/// </summary>
/// <param name="homeDirectory">The user's home directory.</param>
/// <param name="socketPrefix">The logical socket prefix requested by the caller.</param>
/// <returns>The full socket path.</returns>
public static string ComputeCliSocketPath(string homeDirectory, string socketPrefix)
{
ArgumentException.ThrowIfNullOrEmpty(socketPrefix);
var dir = GetBackchannelsDirectory(homeDirectory);
var socketName = ComputeSocketFileName(socketPrefix);
var socketPath = Path.Combine(dir, socketName);
ValidateSocketPathLength(socketPath);
return socketPath;
}
public static string ComputeSocketFileName(string socketPrefix)
{
ArgumentException.ThrowIfNullOrEmpty(socketPrefix);
return $"{GetCompactCliSocketPrefix(socketPrefix)}{CreateRandomBase64UrlIdentifier()}";
}
/// <summary>
/// Computes the search pattern for randomized socket files created with the specified logical
/// socket prefix.
/// </summary>
public static string ComputeSocketFileSearchPattern(string socketPrefix)
{
ArgumentException.ThrowIfNullOrEmpty(socketPrefix);
return $"{GetCompactCliSocketPrefix(socketPrefix)}{new string('?', CompactInstanceIdLength)}";
}
/// <summary>
/// Computes the socket path prefix for finding compact sockets.
/// </summary>
/// <remarks>
/// Called by CLI when searching for sockets. Since the CLI doesn't know the
/// AppHost's PID or instance ID, it uses this prefix with a glob pattern to find matching sockets.
/// </remarks>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <param name="homeDirectory">The user's home directory.</param>
/// <returns>The socket path prefix.</returns>
public static string ComputeSocketPrefix(string appHostPath, string homeDirectory)
{
var dir = GetBackchannelsDirectory(homeDirectory);
var appHostId = ComputeAppHostId(appHostPath);
return Path.Combine(dir, appHostId);
}
/// <summary>
/// Finds all socket files matching the given AppHost path.
/// </summary>
/// <remarks>
/// Returns all compact socket files for an AppHost and falls back to legacy
/// <c>auxi.sock.{hash}</c>, <c>auxi.sock.{hash}.{pid}</c>, and
/// <c>auxi.sock.{hash}.{instanceHash}.{pid}</c> names for older AppHosts.
/// </remarks>
/// <param name="appHostPath">The full path to the AppHost project file.</param>
/// <param name="homeDirectory">The user's home directory.</param>
/// <returns>An array of socket file paths, or empty if none found.</returns>
public static string[] FindMatchingSockets(string appHostPath, string homeDirectory)
{
var results = new List<string>();
var compactDir = GetBackchannelsDirectory(homeDirectory);
var appHostId = ComputeAppHostId(appHostPath);
results.AddRange(FindCompactSocketsByAppHostId(compactDir, appHostId));
var legacyDir = GetLegacyBackchannelsDirectory(homeDirectory);
foreach (var legacyHash in ComputeLegacyHashes(appHostPath))
{
results.AddRange(FindLegacySocketsByPrefix(legacyDir, $"{SocketPrefix}.{legacyHash}"));
results.AddRange(FindLegacySocketsByPrefix(legacyDir, $"aux.sock.{legacyHash}"));
}
return results.Distinct(StringComparers.FileSystemPath).ToArray();
}
/// <summary>
/// Extracts the AppHost identifier from a socket filename.
/// </summary>
/// <remarks>
/// Works with compact format (<c>{appHostId}{instanceId}.{pid}</c>) and legacy
/// formats (<c>auxi.sock.{hash}</c>, <c>auxi.sock.{hash}.{pid}</c>,
/// and <c>auxi.sock.{hash}.{instanceHash}.{pid}</c>).
/// </remarks>
/// <param name="socketPath">The full socket path or filename.</param>
/// <returns>The AppHost identifier or hash portion, or <c>null</c> if the format is unrecognized.</returns>
public static string? ExtractHash(string socketPath)
{
var fileName = Path.GetFileName(socketPath);
if (TryExtractCompactAppHostId(fileName, out var appHostId))
{
return appHostId;
}
// Handle legacy current format: auxi.sock.{hash}.{instanceHash}.{pid}
// Handle legacy previous format: auxi.sock.{hash}.{pid}
// Handle legacy old format: auxi.sock.{hash}
if (fileName.StartsWith($"{SocketPrefix}.", StringComparison.Ordinal))
{
var afterPrefix = fileName[$"{SocketPrefix}.".Length..];
var dotIndex = afterPrefix.IndexOf('.');
return dotIndex > 0 ? afterPrefix[..dotIndex] : afterPrefix;
}
// Handle oldest legacy format: aux.sock.{hash}
if (fileName.StartsWith("aux.sock.", StringComparison.Ordinal))
{
var afterPrefix = fileName["aux.sock.".Length..];
var dotIndex = afterPrefix.IndexOf('.');
return dotIndex > 0 ? afterPrefix[..dotIndex] : afterPrefix;
}
return null;
}
/// <summary>
/// Extracts the PID from a socket filename when one is present.
/// </summary>
/// <param name="socketPath">The full socket path or filename.</param>
/// <returns>The PID if present and valid, or <c>null</c> for old format sockets.</returns>
public static int? ExtractPid(string socketPath)
{
var fileName = Path.GetFileName(socketPath);
var lastDot = fileName.LastIndexOf('.');
if (lastDot > 0 && int.TryParse(fileName[(lastDot + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var pid))
{
return pid;
}
return null;
}
/// <summary>
/// Checks if a process with the given PID exists and is running.
/// </summary>
/// <remarks>
/// Used for orphan detection. If the PID from a socket filename doesn't correspond
/// to a running process, the socket is orphaned and can be safely deleted.
/// </remarks>
/// <param name="pid">The process ID to check.</param>
/// <returns><c>true</c> if the process exists and is running; otherwise, <c>false</c>.</returns>
public static bool ProcessExists(int pid)
{
try
{
using var process = Process.GetProcessById(pid);
return !process.HasExited;
}
catch (ArgumentException)
{
// Process doesn't exist
return false;
}
catch (InvalidOperationException)
{
// Process has exited
return false;
}
}
/// <summary>
/// Computes a compact stable identifier from a string value.
/// </summary>
/// <remarks>
/// Uses XxHash3 because these identifiers are only used for local naming and lookup. They do not
/// protect secrets or cross a trust boundary, so a fast non-cryptographic hash is preferable to SHA-2.
/// </remarks>
/// <param name="value">The string value to hash.</param>
/// <param name="length">The number of lowercase hex characters to return.</param>
/// <returns>A lowercase hex identifier truncated to <paramref name="length"/> characters.</returns>
public static string ComputeStableIdentifier(string value, int length = CompactIdentifierLength)
{
ArgumentException.ThrowIfNullOrEmpty(value);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length);
var hashBytes = XxHash3.Hash(Encoding.UTF8.GetBytes(value));
return ToLowerHexIdentifier(hashBytes, length);
}
/// <summary>
/// Creates a compact randomized hex identifier.
/// </summary>
/// <param name="length">The number of lowercase hex characters to return.</param>
/// <returns>A lowercase hex identifier truncated to <paramref name="length"/> characters.</returns>
public static string CreateRandomIdentifier(int length = CompactIdentifierLength)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length);
Span<byte> randomBytes = stackalloc byte[(length + 1) / 2];
RandomNumberGenerator.Fill(randomBytes);
return ToLowerHexIdentifier(randomBytes, length);
}
/// <summary>
/// Creates a compact randomized base64url identifier.
/// </summary>
/// <param name="byteCount">The number of random bytes to encode.</param>
/// <returns>An unpadded base64url identifier.</returns>
public static string CreateRandomBase64UrlIdentifier(int byteCount = CompactInstanceIdByteCount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
Span<byte> randomBytes = stackalloc byte[byteCount];
RandomNumberGenerator.Fill(randomBytes);
return ToBase64UrlIdentifier(randomBytes);
}
/// <summary>
/// Gets the Unix domain socket path byte limit for the current platform, including the trailing null byte.
/// </summary>
/// <returns>The maximum number of bytes including the trailing null byte.</returns>
public static int GetMaxSocketPathBytesIncludingNull()
=> OperatingSystem.IsMacOS() ? MacOSSocketPathBytesIncludingNull : DefaultSocketPathBytesIncludingNull;
/// <summary>
/// Gets the UTF-8 byte count for a Unix domain socket path, including the trailing null byte.
/// </summary>
/// <param name="socketPath">The socket path.</param>
/// <returns>The byte count including the trailing null byte.</returns>
public static int GetSocketPathByteCountIncludingNull(string socketPath)
{
ArgumentNullException.ThrowIfNull(socketPath);
return Encoding.UTF8.GetByteCount(socketPath) + 1;
}
/// <summary>
/// Validates that a Unix domain socket path fits within the current platform's byte limit.
/// </summary>
/// <param name="socketPath">The socket path to validate.</param>
/// <exception cref="InvalidOperationException">Thrown when the path exceeds the platform byte limit.</exception>
public static void ValidateSocketPathLength(string socketPath)
{
var byteCount = GetSocketPathByteCountIncludingNull(socketPath);
var maxByteCount = GetMaxSocketPathBytesIncludingNull();
if (byteCount > maxByteCount)
{
throw new InvalidOperationException(
$"The Unix domain socket path '{socketPath}' is {byteCount.ToString(CultureInfo.InvariantCulture)} bytes including the trailing null byte, " +
$"which exceeds the {maxByteCount.ToString(CultureInfo.InvariantCulture)}-byte limit on this platform.");
}
}
private static string[] FindCompactSocketsByAppHostId(string directory, string appHostId)
{
if (!Directory.Exists(directory))
{
return [];
}
return Directory.GetFiles(directory, appHostId + "*")
.Where(f =>
{
var fileName = Path.GetFileName(f);
return TryExtractCompactAppHostId(fileName, out var extractedAppHostId) &&
string.Equals(extractedAppHostId, appHostId, StringComparison.Ordinal);
})
.ToArray();
}
/// <summary>
/// Finds socket files in <paramref name="directory"/> whose names match
/// <paramref name="prefixFileName"/> in any of the supported legacy socket name formats.
/// </summary>
private static string[] FindLegacySocketsByPrefix(string directory, string prefixFileName)
{
if (!Directory.Exists(directory))
{
return [];
}
// Match old format (auxi.sock.{hash}), previous format (auxi.sock.{hash}.{pid}),
// and current legacy format (auxi.sock.{hash}.{instanceHash}.{pid}).
var allMatches = Directory.GetFiles(directory, prefixFileName + "*");
// Filter to only include exact match (old format), .{pid} suffix (previous format),
// or .{instanceHash}.{pid} suffix (current legacy format). This avoids matching
// auxi.sock.{hash}abc (different hash that starts with same chars) and files
// like auxi.sock.{hash}.12345.bak.
return allMatches.Where(f =>
{
var fileName = Path.GetFileName(f);
if (fileName == prefixFileName)
{
return true; // Old format: exact match
}
if (!fileName.StartsWith(prefixFileName + ".", StringComparison.Ordinal))
{
return false;
}
var suffix = fileName[(prefixFileName.Length + 1)..];
var segments = suffix.Split('.');
if (segments.Length == 1 &&
int.TryParse(segments[0], NumberStyles.None, CultureInfo.InvariantCulture, out _))
{
return true; // Previous format: prefix followed by integer PID
}
return segments.Length == 2 &&
IsHex(segments[0]) &&
int.TryParse(segments[1], NumberStyles.None, CultureInfo.InvariantCulture, out _);
}).ToArray();
}
private static bool TryExtractCompactAppHostId(string fileName, out string appHostId)
{
appHostId = string.Empty;
if (fileName.Length == CompactAppHostIdLength && IsBase64UrlIdentifier(fileName))
{
appHostId = fileName;
return true;
}
var pidSeparatorIndex = CompactAppHostIdLength + CompactInstanceIdLength;
if (fileName.Length <= pidSeparatorIndex ||
fileName[pidSeparatorIndex] != '.')
{
return false;
}
var candidateAppHostId = fileName[..CompactAppHostIdLength];
var instanceId = fileName[CompactAppHostIdLength..pidSeparatorIndex];
var pidText = fileName[(pidSeparatorIndex + 1)..];
if (IsBase64UrlIdentifier(candidateAppHostId) &&
IsBase64UrlIdentifier(instanceId) &&
int.TryParse(pidText, NumberStyles.None, CultureInfo.InvariantCulture, out _))
{
appHostId = candidateAppHostId;
return true;
}
return false;
}
private static void ValidateCompactAppHostId(string appHostId)
{
ArgumentException.ThrowIfNullOrEmpty(appHostId);
if (appHostId.Length != CompactAppHostIdLength || !IsBase64UrlIdentifier(appHostId))
{
throw new ArgumentException(
$"The compact AppHost identifier must be {CompactAppHostIdLength.ToString(CultureInfo.InvariantCulture)} base64url characters.",
nameof(appHostId));
}
}
private static string ComputeStableBase64UrlIdentifier(string value, int byteCount)
{
ArgumentException.ThrowIfNullOrEmpty(value);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(byteCount);
var xxHash = new XxHash3();
xxHash.Append(Encoding.UTF8.GetBytes(value));
var hash = xxHash.GetCurrentHash();
ArgumentOutOfRangeException.ThrowIfGreaterThan(byteCount, hash.Length);
return ToBase64UrlIdentifier(hash.AsSpan(0, byteCount));
}
private static string GetCompactCliSocketPrefix(string socketPrefix)
{
if (socketPrefix.StartsWith("cli", StringComparison.OrdinalIgnoreCase))
{
return "c";
}
if (socketPrefix.StartsWith("apphost", StringComparison.OrdinalIgnoreCase))
{
return "h";
}
return "s";
}
private static bool IsHex(string value)
=> !string.IsNullOrEmpty(value) && value.All(static c => char.IsAsciiHexDigit(c));
private static bool IsBase64UrlIdentifier(string value)
=> !string.IsNullOrEmpty(value) && value.All(static c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_');
private static string ToLowerHexIdentifier(ReadOnlySpan<byte> bytes, int length)
{
var hex = Convert.ToHexString(bytes).ToLowerInvariant();
return hex[..Math.Min(length, hex.Length)];
}
private static string ToBase64UrlIdentifier(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}