urma-cli 0.2.1

Command-line program for URMA identities, wallets, Wire, archives and Git
use crate::gateway_http::{Refusal, State};
use bitcoin::Txid;
use std::time::{Duration, Instant};
use urma_names::state::{Resolution, Target};
use urma_web::package::{Package, Resource};

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Route {
    Serve(String),
    Redirect(String),
    Undeclared,
}

impl Route {
    pub(crate) const RESERVED: &'static str = "/.well-known/urma/";
}

pub(crate) fn split_target(target: &str) -> (&str, &str) {
    for (at, byte) in target.bytes().enumerate() {
        if byte == b'?' || byte == b'#' {
            return target.split_at(at);
        }
    }
    (target, "")
}

enum Lookup {
    Declared(String),
    Absent,
}

fn lookup(package: &Package, path: &str) -> Lookup {
    if path.starts_with(Route::RESERVED) {
        return Lookup::Absent;
    }
    match package.resolve(path) {
        Resource::File(file) => Lookup::Declared(file.path.clone()),
        Resource::Pinned(pin) => Lookup::Declared(pin.path.clone()),
        Resource::Undeclared => Lookup::Absent,
    }
}

pub(crate) fn route(package: &Package, target: &str) -> Route {
    let (path, query) = split_target(target);
    match lookup(package, path) {
        Lookup::Declared(declared) => return Route::Serve(declared),
        Lookup::Absent => {}
    }
    if path.is_empty() || path.ends_with('/') {
        return Route::Undeclared;
    }
    let directory = format!("{path}/");
    match lookup(package, &directory) {
        Lookup::Declared(..) => Route::Redirect(format!("{directory}{query}")),
        Lookup::Absent => Route::Undeclared,
    }
}

pub(crate) fn standing(
    host: &str,
    registry: Txid,
    resolution: Resolution,
    tip: u64,
) -> Result<Txid, Refusal> {
    let bound = match resolution {
        Resolution::Unbound => {
            return Err(Refusal::new(
                State::Unbound,
                format!("{host} is not bound in registry {registry}"),
            ));
        }
        Resolution::Suspended => {
            return Err(Refusal::new(
                State::Suspended,
                format!("{host} is suspended by the approvers of registry {registry}"),
            ));
        }
        Resolution::Bound(bound) => bound,
    };
    let Target::Publication(root) = bound.target else {
        return Err(Refusal::new(
            State::Reserved,
            format!("{host} is reserved in registry {registry} and names no publication"),
        ));
    };
    if bound.expiry <= tip {
        return Err(Refusal::new(
            State::Expired,
            format!(
                "{host} expired at block {} (chain tip {tip}) in registry {registry}",
                bound.expiry
            ),
        ));
    }
    Ok(root)
}

#[derive(Clone, Copy, Debug)]
pub(crate) struct Freshness {
    pub(crate) max_lag: u64,
    pub(crate) max_age: Duration,
    pub(crate) retry: Duration,
}

#[derive(Clone, Copy, Debug)]
pub(crate) enum Scan {
    Never,
    At(Instant),
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Currency {
    Current,
    Behind(String),
}

impl Freshness {
    pub(crate) fn judge(&self, height: u64, tip: u64, scan: Scan, now: Instant) -> Currency {
        let Scan::At(scanned) = scan else {
            return Currency::Behind("no rescan has succeeded since the portal started".into());
        };
        let age = now.duration_since(scanned);
        if age >= self.max_age {
            return Currency::Behind(format!(
                "the last successful rescan was {} s ago; the bound is {} s",
                age.as_secs(),
                self.max_age.as_secs()
            ));
        }
        if tip > height && tip - height > self.max_lag {
            return Currency::Behind(format!(
                "the index is at height {height}, {} blocks behind the chain tip {tip}; the bound is {}",
                tip - height,
                self.max_lag
            ));
        }
        Currency::Current
    }

    pub(crate) fn refusal(&self, network: &str, reason: &str) -> Refusal {
        Refusal::new(
            State::IndexBehind,
            format!("the {network} names index is not current: {reason}"),
        )
        .with("Retry-After", self.retry.as_secs().to_string())
    }
}