Skip to main content

agent_migrate_core/
endpoint.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::{AgentKind, AgentParseError};
8
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(try_from = "String", into = "String")]
11pub struct SshHost(String);
12
13impl SshHost {
14    #[must_use]
15    pub fn as_str(&self) -> &str {
16        &self.0
17    }
18}
19
20impl TryFrom<String> for SshHost {
21    type Error = EndpointError;
22
23    fn try_from(value: String) -> Result<Self, Self::Error> {
24        Self::from_str(&value)
25    }
26}
27
28impl From<SshHost> for String {
29    fn from(value: SshHost) -> Self {
30        value.0
31    }
32}
33
34impl FromStr for SshHost {
35    type Err = EndpointError;
36
37    fn from_str(value: &str) -> Result<Self, Self::Err> {
38        let mut bytes = value.bytes();
39        let first = bytes.next().ok_or(EndpointError::InvalidHost)?;
40        let valid_first = first.is_ascii_alphanumeric();
41        let valid_rest =
42            bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
43        if value.len() > 64 || !valid_first || !valid_rest || value == "local" {
44            return Err(EndpointError::InvalidHost);
45        }
46        Ok(Self(value.to_owned()))
47    }
48}
49
50impl fmt::Display for SshHost {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        formatter.write_str(&self.0)
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum Host {
59    Local,
60    Ssh(SshHost),
61}
62
63impl fmt::Display for Host {
64    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Local => formatter.write_str("local"),
67            Self::Ssh(host) => host.fmt(formatter),
68        }
69    }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct Endpoint {
74    agent: AgentKind,
75    host: Host,
76}
77
78impl Endpoint {
79    #[must_use]
80    pub fn new(agent: AgentKind, host: Host) -> Self {
81        Self { agent, host }
82    }
83
84    #[must_use]
85    pub fn agent(&self) -> AgentKind {
86        self.agent
87    }
88
89    #[must_use]
90    pub fn host(&self) -> &Host {
91        &self.host
92    }
93}
94
95impl FromStr for Endpoint {
96    type Err = EndpointError;
97
98    fn from_str(value: &str) -> Result<Self, Self::Err> {
99        let (agent, host) = value.split_once('@').ok_or(EndpointError::InvalidFormat)?;
100        if host.contains('@') {
101            return Err(EndpointError::InvalidFormat);
102        }
103        let agent = AgentKind::from_str(agent)?;
104        let host = match host {
105            "local" => Host::Local,
106            remote => Host::Ssh(SshHost::from_str(remote)?),
107        };
108        Ok(Self { agent, host })
109    }
110}
111
112impl fmt::Display for Endpoint {
113    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(formatter, "{}@{}", self.agent, self.host)
115    }
116}
117
118#[derive(Debug, Error, PartialEq, Eq)]
119pub enum EndpointError {
120    #[error("endpoint must use <agent>@<host>")]
121    InvalidFormat,
122    #[error("invalid SSH config host alias")]
123    InvalidHost,
124    #[error(transparent)]
125    Agent(#[from] AgentParseError),
126}