prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::mem;

use super::error::SamProtocolError;
use super::value::{I2pPrivateDestination, I2pPublicDestination, SamValueError};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SamVersion {
    pub major: u8,
    pub minor: u8,
}

impl SamVersion {
    pub const V3_1: Self = Self { major: 3, minor: 1 };
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SamRejection {
    NoVersion,
    DuplicatedDestination,
    DuplicatedId,
    I2pError,
    InvalidKey,
    InvalidId,
    CantReachPeer,
    Timeout,
    KeyNotFound,
    PeerNotFound,
    Other(String),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SamReplyKind {
    Hello,
    Destination,
    Session,
    Stream,
    Naming,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SamSessionReplyDestination {
    Returned(I2pPrivateDestination),
    Omitted,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SamReply {
    Hello(SamVersion),
    DestinationGenerated {
        public: Option<I2pPublicDestination>,
        private: I2pPrivateDestination,
    },
    SessionCreated {
        destination: SamSessionReplyDestination,
    },
    StreamReady,
    NameResolved {
        destination: I2pPublicDestination,
    },
    Rejected {
        kind: SamReplyKind,
        rejection: SamRejection,
        message: Option<String>,
    },
}

impl SamReply {
    pub const fn kind(&self) -> SamReplyKind {
        match self {
            Self::Hello(_) => SamReplyKind::Hello,
            Self::DestinationGenerated { .. } => SamReplyKind::Destination,
            Self::SessionCreated { .. } => SamReplyKind::Session,
            Self::StreamReady => SamReplyKind::Stream,
            Self::NameResolved { .. } => SamReplyKind::Naming,
            Self::Rejected { kind, .. } => *kind,
        }
    }
}

pub fn parse_reply(line: &str) -> Result<SamReply, SamProtocolError> {
    let words = tokenize(line.trim_end_matches(['\r', '\n']))?;
    let domain = words
        .first()
        .ok_or(SamProtocolError::MalformedReply("missing reply domain"))?;
    let action = words
        .get(1)
        .ok_or(SamProtocolError::MalformedReply("missing reply action"))?;
    let mut fields = fields(&words[2..])?;
    let kind = reply_kind(domain, action)?;
    let rejection = match fields.remove("RESULT") {
        Some(result) => sam_rejection(&result),
        None if kind == SamReplyKind::Destination => None,
        None => return Err(SamProtocolError::MalformedReply("missing result")),
    };
    let message = fields.remove("MESSAGE");
    if let Some(rejection) = rejection {
        return Ok(SamReply::Rejected {
            kind,
            rejection,
            message,
        });
    }
    match kind {
        SamReplyKind::Hello => {
            let version = required(&mut fields, "VERSION", "missing negotiated version")?;
            Ok(SamReply::Hello(parse_version(version)?))
        }
        SamReplyKind::Destination => Ok(SamReply::DestinationGenerated {
            public: optional_token(&mut fields, "PUB")?,
            private: required_token(&mut fields, "PRIV", "missing private destination")?,
        }),
        SamReplyKind::Session => Ok(SamReply::SessionCreated {
            destination: optional_token(&mut fields, "DESTINATION")?.map_or(
                SamSessionReplyDestination::Omitted,
                SamSessionReplyDestination::Returned,
            ),
        }),
        SamReplyKind::Stream => Ok(SamReply::StreamReady),
        SamReplyKind::Naming => Ok(SamReply::NameResolved {
            destination: required_token(&mut fields, "VALUE", "missing lookup destination")?,
        }),
    }
}

fn tokenize(line: &str) -> Result<Vec<String>, SamProtocolError> {
    let mut words = Vec::new();
    let mut current = String::new();
    let mut quoted = false;
    let mut escaped = false;
    for character in line.chars() {
        if escaped {
            current.push(character);
            escaped = false;
        } else if character == '\\' {
            escaped = true;
        } else if character == '"' {
            quoted = !quoted;
        } else if character.is_whitespace() && !quoted {
            if !current.is_empty() {
                words.push(mem::take(&mut current));
            }
        } else {
            current.push(character);
        }
    }
    if escaped || quoted {
        return Err(SamProtocolError::MalformedReply(
            "unterminated escape or quoted value",
        ));
    }
    if !current.is_empty() {
        words.push(current);
    }
    Ok(words)
}

fn fields(words: &[String]) -> Result<BTreeMap<String, String>, SamProtocolError> {
    let mut fields = BTreeMap::new();
    for word in words {
        let (key, value) = word
            .split_once('=')
            .ok_or(SamProtocolError::MalformedReply("field has no value"))?;
        if key.is_empty() || fields.insert(key.to_string(), value.to_string()).is_some() {
            return Err(SamProtocolError::MalformedReply(
                "field name is empty or duplicated",
            ));
        }
    }
    Ok(fields)
}

fn reply_kind(domain: &str, action: &str) -> Result<SamReplyKind, SamProtocolError> {
    match (domain, action) {
        ("HELLO", "REPLY") => Ok(SamReplyKind::Hello),
        ("DEST", "REPLY") => Ok(SamReplyKind::Destination),
        ("SESSION", "STATUS") => Ok(SamReplyKind::Session),
        ("STREAM", "STATUS") => Ok(SamReplyKind::Stream),
        ("NAMING", "REPLY") => Ok(SamReplyKind::Naming),
        _ => Err(SamProtocolError::MalformedReply("unknown reply kind")),
    }
}

fn required(
    fields: &mut BTreeMap<String, String>,
    key: &str,
    reason: &'static str,
) -> Result<String, SamProtocolError> {
    fields
        .remove(key)
        .filter(|value| !value.is_empty())
        .ok_or(SamProtocolError::MalformedReply(reason))
}

fn required_token<Token>(
    fields: &mut BTreeMap<String, String>,
    key: &'static str,
    reason: &'static str,
) -> Result<Token, SamProtocolError>
where
    Token: TryFrom<String, Error = SamValueError>,
{
    required(fields, key, reason)?
        .try_into()
        .map_err(|source| SamProtocolError::InvalidToken { field: key, source })
}

fn optional_token<Token>(
    fields: &mut BTreeMap<String, String>,
    key: &'static str,
) -> Result<Option<Token>, SamProtocolError>
where
    Token: TryFrom<String, Error = SamValueError>,
{
    fields
        .remove(key)
        .filter(|value| !value.is_empty())
        .map(TryInto::try_into)
        .transpose()
        .map_err(|source| SamProtocolError::InvalidToken { field: key, source })
}

fn parse_version(version: String) -> Result<SamVersion, SamProtocolError> {
    let (major, minor) = version
        .split_once('.')
        .ok_or_else(|| SamProtocolError::InvalidVersion(version.clone()))?;
    Ok(SamVersion {
        major: major
            .parse()
            .map_err(|_| SamProtocolError::InvalidVersion(version.clone()))?,
        minor: minor
            .parse()
            .map_err(|_| SamProtocolError::InvalidVersion(version))?,
    })
}

fn sam_rejection(result: &str) -> Option<SamRejection> {
    match result {
        "OK" => None,
        "NOVERSION" => Some(SamRejection::NoVersion),
        "DUPLICATED_DEST" => Some(SamRejection::DuplicatedDestination),
        "DUPLICATED_ID" => Some(SamRejection::DuplicatedId),
        "I2P_ERROR" => Some(SamRejection::I2pError),
        "INVALID_KEY" => Some(SamRejection::InvalidKey),
        "INVALID_ID" => Some(SamRejection::InvalidId),
        "CANT_REACH_PEER" => Some(SamRejection::CantReachPeer),
        "TIMEOUT" => Some(SamRejection::Timeout),
        "KEY_NOT_FOUND" => Some(SamRejection::KeyNotFound),
        "PEER_NOT_FOUND" => Some(SamRejection::PeerNotFound),
        other => Some(SamRejection::Other(other.to_string())),
    }
}