use bitcoin::Txid;
use clap::ValueEnum;
use std::{
collections::BTreeMap,
fmt::{Display, Formatter},
};
use urma_chain::observation::Chain;
use urma_names::name::Name;
use urma_runtime::error::{Error, ensure};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum Suffix {
Ltc,
Btc,
Tltc,
Tbtc,
}
impl Suffix {
pub(crate) const ALL: [Self; 4] = [Self::Ltc, Self::Btc, Self::Tltc, Self::Tbtc];
pub(crate) fn label(self) -> &'static str {
match self {
Self::Ltc => "ltc",
Self::Btc => "btc",
Self::Tltc => "tltc",
Self::Tbtc => "tbtc",
}
}
pub(crate) fn parse(label: &str) -> Result<Self, HostError> {
for suffix in Self::ALL {
if suffix.label() == label {
return Ok(suffix);
}
}
Err(HostError::Suffix(label.to_owned()))
}
pub(crate) fn chain(self) -> Result<Chain, Error> {
match self {
Self::Ltc => Ok(Chain::LitecoinMainnet),
Self::Tltc => Ok(Chain::LitecoinTestnet),
Self::Tbtc => Ok(Chain::BitcoinTestnet4),
Self::Btc => Err(Error::Unsupported(
"the .btc suffix names bitcoin mainnet, which this runtime does not support".into(),
)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub(crate) enum LinkScheme {
Http,
Https,
}
impl LinkScheme {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PublicPort {
Default,
Explicit(u16),
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HostError {
Malformed(String),
Suffix(String),
Name(String),
Publication,
Registry,
}
impl Display for HostError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Malformed(detail) => write!(formatter, "malformed host: {detail}"),
Self::Suffix(label) => write!(
formatter,
"unknown network suffix {label:?}; URMA suffixes are ltc, btc, tltc and tbtc"
),
Self::Name(cause) => write!(formatter, "not a registry name: {cause}"),
Self::Publication => formatter.write_str(
"publication (TXID) hosts are not served; this portal serves registry names only",
),
Self::Registry => formatter.write_str(
"explicit-registry hosts are not served; this portal serves <name>.<suffix> hosts of its registry",
),
}
}
}
impl std::error::Error for HostError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SiteHost {
pub(crate) name: Name,
pub(crate) suffix: Suffix,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Host {
Apex,
Site(SiteHost),
Foreign,
}
#[derive(Debug)]
pub(crate) struct Portal {
domain: String,
authority: String,
scheme: LinkScheme,
registries: BTreeMap<Suffix, Txid>,
}
impl Portal {
pub(crate) const MAX_HOST_BYTES: usize = 253;
const LOCAL_DOMAIN: &'static str = "localhost";
pub(crate) fn new(
domain: &str,
scheme: LinkScheme,
port: PublicPort,
registries: BTreeMap<Suffix, Txid>,
) -> Result<Self, Error> {
ensure!(
(1..=Self::MAX_HOST_BYTES).contains(&domain.len()),
"portal domain must be 1..253 bytes"
);
for label in domain.split('.') {
ensure!(
is_dns_label(label),
"portal domain {domain:?} must be lowercase DNS labels (a-z, 0-9, hyphen)"
);
}
ensure!(
!registries.is_empty(),
"configure at least one registry with --registry <suffix>=<genesis>"
);
let expected = if domain == Self::LOCAL_DOMAIN {
LinkScheme::Http
} else {
LinkScheme::Https
};
ensure!(
scheme == expected,
"links-v1 uses {} for {domain}: https, or http when the domain is localhost",
expected.label()
);
let authority = match port {
PublicPort::Default => domain.to_owned(),
PublicPort::Explicit(port) => format!("{domain}:{port}"),
};
Ok(Self {
domain: domain.to_owned(),
authority,
scheme,
registries,
})
}
pub(crate) fn domain(&self) -> &str {
&self.domain
}
pub(crate) fn authority(&self) -> &str {
&self.authority
}
pub(crate) fn registries(&self) -> &BTreeMap<Suffix, Txid> {
&self.registries
}
pub(crate) fn is_registry(&self, suffix: Suffix, genesis: &str) -> bool {
let Some(configured) = self.registries.get(&suffix) else {
return false;
};
configured.to_string() == genesis
}
pub(crate) fn apex_origin(&self) -> String {
format!("{}://{}", self.scheme.label(), self.authority)
}
pub(crate) fn site_origin(&self, name: &Name, suffix: Suffix) -> String {
format!(
"{}://{name}.{}.{}",
self.scheme.label(),
suffix.label(),
self.authority
)
}
pub(crate) fn unavailable(&self, original: &[u8]) -> String {
let mut link = format!("{}/unavailable?u=", self.apex_origin());
for byte in original {
if byte.is_ascii_alphanumeric() || b"-._~".contains(byte) {
link.push(char::from(*byte));
} else {
link.push_str(&format!("%{byte:02X}"));
}
}
link
}
}
fn is_dns_label(label: &str) -> bool {
(1..=Name::MAX_BYTES).contains(&label.len())
&& label
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
pub(crate) fn is_txid(label: &str) -> bool {
label.len() == 64
&& label
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
pub(crate) fn authority(header: &str) -> Result<String, HostError> {
let host = without_port(header)?;
if host.is_empty() || host.len() > Portal::MAX_HOST_BYTES {
return Err(HostError::Malformed(format!(
"host must be 1..{} bytes",
Portal::MAX_HOST_BYTES
)));
}
if !host.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(HostError::Malformed(
"host carries a space, a control byte or non-ASCII".into(),
));
}
Ok(host.to_owned())
}
fn without_port(header: &str) -> Result<&str, HostError> {
if header.starts_with('[') {
let Some(end) = header.find(']') else {
return Err(HostError::Malformed("unterminated IPv6 literal".into()));
};
let (literal, rest) = header.split_at(end + 1);
check_port(rest, rest.strip_prefix(':'))?;
return Ok(literal);
}
let Some((host, port)) = header.rsplit_once(':') else {
return Ok(header);
};
check_port(port, Some(port))?;
Ok(host)
}
fn check_port(rest: &str, digits: Option<&str>) -> Result<(), HostError> {
if rest.is_empty() {
return Ok(());
}
let Some(digits) = digits else {
return Err(HostError::Malformed("bytes after the IPv6 literal".into()));
};
if digits.is_empty() || digits.len() > 5 || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(HostError::Malformed(format!("invalid port {digits:?}")));
}
Ok(())
}
pub(crate) fn classify(host: &str, portal: &Portal) -> Result<Host, HostError> {
if host == portal.domain {
return Ok(Host::Apex);
}
let Some(prefix) = host.strip_suffix(portal.domain.as_str()) else {
return Ok(Host::Foreign);
};
let Some(labels) = prefix.strip_suffix('.') else {
return Ok(Host::Foreign);
};
Ok(Host::Site(site(labels)?))
}
fn site(labels: &str) -> Result<SiteHost, HostError> {
let parts: Vec<&str> = labels.split('.').collect();
if parts.iter().any(|part| part.is_empty()) {
return Err(HostError::Malformed("empty label".into()));
}
match parts.as_slice() {
[name, suffix] => {
let suffix = Suffix::parse(suffix)?;
if is_txid(name) {
return Err(HostError::Publication);
}
let name = Name::parse(name).map_err(|cause| HostError::Name(cause.to_string()))?;
Ok(SiteHost { name, suffix })
}
[first, second, third] if is_txid(first) => {
Suffix::parse(third)?;
Name::parse(second).map_err(|cause| HostError::Name(cause.to_string()))?;
Err(HostError::Registry)
}
[single] => Err(HostError::Malformed(format!(
"{single:?} has no name before the suffix"
))),
other => Err(HostError::Malformed(format!(
"{} labels before the portal domain; expected <name>.<suffix>",
other.len()
))),
}
}