1use std::path::{Component, Path, PathBuf};
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::{TopologyError, TopologyRevision};
6
7const MAX_ENDPOINT_CHARS: usize = 512;
8const MAX_USER_CHARS: usize = 128;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "kind", rename_all = "snake_case")]
13pub enum HostEndpoint {
14 Local,
16 Ssh(SshEndpoint),
18 Http(HttpEndpoint),
20}
21
22impl HostEndpoint {
23 pub(crate) fn revision(&self) -> TopologyRevision {
24 let material = serde_json::to_vec(self).expect("fleet endpoints serialize");
25 TopologyRevision::from_material(material)
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct SshEndpoint {
32 host: String,
33 port: u16,
34 user: Option<String>,
35 identity_file: Option<PathBuf>,
36 config_file: Option<PathBuf>,
37 known_hosts_file: Option<PathBuf>,
38}
39
40impl SshEndpoint {
41 pub fn new(host: impl Into<String>) -> Result<Self, TopologyError> {
43 let host = host.into();
44 validate_endpoint_text("SSH host", &host)?;
45 Ok(Self {
46 host,
47 port: 22,
48 user: None,
49 identity_file: None,
50 config_file: None,
51 known_hosts_file: None,
52 })
53 }
54
55 pub fn with_port(mut self, port: u16) -> Result<Self, TopologyError> {
57 if port == 0 {
58 return Err(TopologyError::InvalidPort);
59 }
60 self.port = port;
61 Ok(self)
62 }
63
64 pub fn with_user(mut self, user: impl Into<String>) -> Result<Self, TopologyError> {
66 let user = user.into();
67 validate_bounded_text("SSH user", &user, MAX_USER_CHARS)?;
68 self.user = Some(user);
69 Ok(self)
70 }
71
72 pub fn with_identity_file(mut self, path: impl Into<PathBuf>) -> Result<Self, TopologyError> {
74 self.identity_file = Some(validate_absolute_path(path.into())?);
75 Ok(self)
76 }
77
78 pub fn with_config_file(mut self, path: impl Into<PathBuf>) -> Result<Self, TopologyError> {
80 self.config_file = Some(validate_absolute_path(path.into())?);
81 Ok(self)
82 }
83
84 pub fn with_known_hosts_file(
86 mut self,
87 path: impl Into<PathBuf>,
88 ) -> Result<Self, TopologyError> {
89 self.known_hosts_file = Some(validate_absolute_path(path.into())?);
90 Ok(self)
91 }
92
93 #[must_use]
95 pub fn host(&self) -> &str {
96 &self.host
97 }
98
99 #[must_use]
101 pub const fn port(&self) -> u16 {
102 self.port
103 }
104
105 #[must_use]
107 pub fn user(&self) -> Option<&str> {
108 self.user.as_deref()
109 }
110
111 #[must_use]
113 pub fn identity_file(&self) -> Option<&Path> {
114 self.identity_file.as_deref()
115 }
116
117 #[must_use]
119 pub fn config_file(&self) -> Option<&Path> {
120 self.config_file.as_deref()
121 }
122
123 #[must_use]
125 pub fn known_hosts_file(&self) -> Option<&Path> {
126 self.known_hosts_file.as_deref()
127 }
128}
129
130impl<'de> Deserialize<'de> for SshEndpoint {
131 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
132 where
133 D: Deserializer<'de>,
134 {
135 let wire = SshEndpointWire::deserialize(deserializer)?;
136 let mut endpoint = Self::new(wire.host).map_err(serde::de::Error::custom)?;
137 endpoint = endpoint
138 .with_port(wire.port)
139 .map_err(serde::de::Error::custom)?;
140 if let Some(user) = wire.user {
141 endpoint = endpoint.with_user(user).map_err(serde::de::Error::custom)?;
142 }
143 if let Some(path) = wire.identity_file {
144 endpoint = endpoint
145 .with_identity_file(path)
146 .map_err(serde::de::Error::custom)?;
147 }
148 if let Some(path) = wire.config_file {
149 endpoint = endpoint
150 .with_config_file(path)
151 .map_err(serde::de::Error::custom)?;
152 }
153 if let Some(path) = wire.known_hosts_file {
154 endpoint = endpoint
155 .with_known_hosts_file(path)
156 .map_err(serde::de::Error::custom)?;
157 }
158 Ok(endpoint)
159 }
160}
161
162#[derive(Deserialize)]
163struct SshEndpointWire {
164 host: String,
165 #[serde(default = "default_ssh_port")]
166 port: u16,
167 #[serde(default)]
168 user: Option<String>,
169 #[serde(default)]
170 identity_file: Option<PathBuf>,
171 #[serde(default)]
172 config_file: Option<PathBuf>,
173 #[serde(default)]
174 known_hosts_file: Option<PathBuf>,
175}
176
177const fn default_ssh_port() -> u16 {
178 22
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
183pub struct HttpEndpoint {
184 base_url: String,
185}
186
187impl HttpEndpoint {
188 pub fn new(base_url: impl Into<String>) -> Result<Self, TopologyError> {
190 let base_url = base_url.into();
191 validate_endpoint_text("HTTP base URL", &base_url)?;
192 let Some((scheme, remainder)) = base_url.split_once("://") else {
193 return Err(TopologyError::InvalidHttpEndpoint);
194 };
195 if !matches!(scheme, "http" | "https") {
196 return Err(TopologyError::InvalidHttpEndpoint);
197 }
198 let authority = remainder.split('/').next().unwrap_or_default();
199 if authority.is_empty() || authority.contains('@') {
200 return Err(TopologyError::InvalidHttpEndpoint);
201 }
202 Ok(Self { base_url })
203 }
204
205 #[must_use]
207 pub fn base_url(&self) -> &str {
208 &self.base_url
209 }
210}
211
212impl<'de> Deserialize<'de> for HttpEndpoint {
213 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
214 where
215 D: Deserializer<'de>,
216 {
217 let wire = HttpEndpointWire::deserialize(deserializer)?;
218 Self::new(wire.base_url).map_err(serde::de::Error::custom)
219 }
220}
221
222#[derive(Deserialize)]
223struct HttpEndpointWire {
224 base_url: String,
225}
226
227fn validate_endpoint_text(field: &'static str, value: &str) -> Result<(), TopologyError> {
228 validate_bounded_text(field, value, MAX_ENDPOINT_CHARS)
229}
230
231fn validate_bounded_text(
232 field: &'static str,
233 value: &str,
234 max_chars: usize,
235) -> Result<(), TopologyError> {
236 let count = value.chars().count();
237 if count == 0 || count > max_chars || value.chars().any(char::is_control) {
238 Err(TopologyError::InvalidEndpointText { field })
239 } else {
240 Ok(())
241 }
242}
243
244fn validate_absolute_path(path: PathBuf) -> Result<PathBuf, TopologyError> {
245 if !path.is_absolute()
246 || path
247 .components()
248 .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
249 {
250 Err(TopologyError::InvalidAbsolutePath(path))
251 } else {
252 Ok(path)
253 }
254}
255
256#[cfg(test)]
257#[path = "endpoint_tests.rs"]
258mod tests;