using System.Diagnostics.CodeAnalysis;
using System.Text;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Session;
///
/// A dodossh://spelunk/connect link from Spelunk's web interface, read and checked.
///
///
///
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
/// dodossh://spelunk/connect?api=…&server=…&name=…&host=…. It says which Spelunk and which
/// server, and what to call it. It never carries a credential, and nothing reads one out of it: the
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
/// supply somebody else's.
///
///
/// Anything on the machine can hand this application a link — a web page, a mail, another program —
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
/// not already send it. See VaultViewModel.OpenSpelunkLinkAsync.
///
///
/// The Spelunk API's base address, normalised as stores it.
/// The server's id in Spelunk.
/// What Spelunk calls the server, cleaned for display.
/// The server's public address as Spelunk knows it, or null when it gave none.
public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host)
{
/// The URL scheme DodoSSH registers.
public const string Scheme = "dodossh";
/// The longest link read at all. Anything longer is refused before it is parsed.
///
/// A real link is a few hundred characters. The bound is what a second launch may put down the doorbell
/// pipe, and what the parser is asked to walk — neither of which should be up to the sender.
///
public const int MaxLength = 4096;
/// The longest name kept. Longer is cut, not refused: it is only a label.
public const int MaxNameLength = 100;
private const string Authority = "spelunk";
private const string Path = "/connect";
///
/// The hostname a new host from this link is given: Spelunk's address for the server, or a name made
/// from its id when there is none.
///
///
/// It is not dialled — the tunnel ignores it — but the host key is pinned under it, so it has to be
/// stable for this server and unlike any other. The id is both; the address is also what the user would
/// recognise, and a key already trusted for a direct connection to that address is found again.
///
public string Hostname => Host ?? $"spelunk-{ServerId}";
/// Whether a string looks like it is meant to be one of these, before checking it is one.
///
/// For the launcher, which is handed arguments of every kind and needs to tell a link that failed the
/// checks from something that was never a link.
///
public static bool LooksLikeOne(string? text) =>
text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase);
/// Reads a link, or refuses it.
///
///
/// Strict on everything that decides where anything goes: the scheme, spelunk as the authority
/// with no user, password or port, /connect as the path, no fragment, each known parameter at
/// most once, an API address accepts, and a server id
/// that is a real one. Unknown parameters are ignored rather than refused, so a newer Spelunk can add
/// one without every existing DodoSSH ignoring its links outright.
///
///
/// Lenient only on the name, which is display text: control characters become spaces and a long one is
/// cut. A missing name falls back to the host, then to a plain one.
///
///
public static bool TryParse(string? text, [NotNullWhen(true)] out SpelunkLink? link)
{
link = null;
if (text is null
|| text.Length > MaxLength
|| text.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|| !Uri.TryCreate(text, UriKind.Absolute, out var uri)
|| !string.Equals(uri.Scheme, Scheme, StringComparison.Ordinal)
|| !string.Equals(uri.Host, Authority, StringComparison.Ordinal)
|| uri.UserInfo.Length > 0
|| !uri.IsDefaultPort
|| !string.Equals(uri.AbsolutePath, Path, StringComparison.Ordinal)
|| uri.Fragment.Length > 0
|| !TryReadQuery(QueryOf(text), out var parameters)
|| !parameters.TryGetValue("api", out var api)
|| !SpelunkRoute.TryNormalizeApiBase(api, out var apiBase, out _)
|| !parameters.TryGetValue("server", out var server)
|| !Guid.TryParseExact(server, "D", out var serverId)
|| serverId == Guid.Empty)
{
return false;
}
string? host = null;
if (parameters.TryGetValue("host", out var given) && given.Trim() is { Length: > 0 } trimmed)
{
if (trimmed.Length > 253 || Uri.CheckHostName(trimmed) is UriHostNameType.Unknown)
{
return false;
}
host = trimmed;
}
var name = Clean(parameters.GetValueOrDefault("name")) ?? host ?? "Spelunk server";
link = new SpelunkLink(apiBase, serverId, name, host);
return true;
}
/// The link written out again, in the one form reads back to the same value.
///
/// What a second launch sends the running instance, so the instance parses a link this build wrote rather
/// than whatever the browser handed over.
///
public override string ToString()
{
var builder = new StringBuilder($"{Scheme}://{Authority}{Path}?api=")
.Append(Uri.EscapeDataString(ApiBase))
.Append("&server=")
.Append(ServerId.ToString("D"))
.Append("&name=")
.Append(Uri.EscapeDataString(Name));
if (Host is not null)
{
builder.Append("&host=").Append(Uri.EscapeDataString(Host));
}
return builder.ToString();
}
///
/// Splits a query into its known parameters, refusing one that names any of them twice.
///
///
/// By hand rather than through a web utility, because this project has none and because the rule that
/// matters — twice is refused, not "last wins" — is one no general-purpose parser applies. + is a
/// space, as a browser's form encoding writes it.
///
private static bool TryReadQuery(string query, out Dictionary parameters)
{
parameters = new Dictionary(StringComparer.Ordinal);
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var equals = pair.IndexOf('=', StringComparison.Ordinal);
var key = equals < 0 ? pair : pair[..equals];
var value = equals < 0 ? string.Empty : pair[(equals + 1)..];
if (key is not ("api" or "server" or "name" or "host"))
{
continue;
}
string decoded;
try
{
decoded = Uri.UnescapeDataString(value.Replace('+', ' '));
}
catch (UriFormatException)
{
return false;
}
if (!parameters.TryAdd(key, decoded))
{
return false;
}
}
return true;
}
/// The query exactly as it was written, rather than as re-escapes it.
///
/// has no rules for a scheme it does not know, and how much of the query it unescapes
/// for one is its business. Taking the text after the first ? means every parameter is decoded
/// exactly once, here.
///
private static string QueryOf(string text) =>
text.IndexOf('?', StringComparison.Ordinal) is var at and >= 0 ? text[(at + 1)..] : string.Empty;
private static string? Clean(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var cleaned = new string([.. name.Trim().Select(character => char.IsControl(character) ? ' ' : character)]);
return cleaned.Length > MaxNameLength ? cleaned[..MaxNameLength].TrimEnd() : cleaned;
}
}