1use std::{
4 fmt,
5 num::NonZeroU16,
6 path::{Component, Path, PathBuf},
7};
8
9use url::Url;
10
11use crate::{Diagnostic, DiagnosticCode, PodmanLensResult};
12
13const SOCKADDR_UN_PATH_MAX_BYTES: usize = 107;
14
15#[derive(Clone, Eq, PartialEq, Hash)]
20pub struct OpaqueReference(String);
21
22impl OpaqueReference {
23 pub fn new(reference: impl Into<String>) -> PodmanLensResult<Self> {
29 let reference = reference.into();
30 if reference.trim().is_empty() || contains_control_character(&reference) {
31 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
32 }
33 Ok(Self(reference))
34 }
35
36 #[must_use]
38 pub fn as_str(&self) -> &str {
39 &self.0
40 }
41}
42
43impl fmt::Debug for OpaqueReference {
44 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45 formatter.write_str("OpaqueReference([redacted])")
46 }
47}
48
49impl fmt::Display for OpaqueReference {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 formatter.write_str("[redacted]")
52 }
53}
54
55#[derive(Clone, Eq, PartialEq)]
57pub struct UnixConnection {
58 socket_path: PathBuf,
59}
60
61impl UnixConnection {
62 pub fn new(socket_path: impl Into<PathBuf>) -> PodmanLensResult<Self> {
68 let socket_path = socket_path.into();
69 if !is_valid_unix_socket_path(&socket_path) {
70 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
71 }
72 Ok(Self { socket_path })
73 }
74
75 pub fn parse(endpoint: &str) -> PodmanLensResult<Self> {
81 let raw_path = raw_uri_path(endpoint, "unix")?;
82 if !is_safe_raw_socket_path(raw_path) {
83 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
84 }
85 let parsed = Url::parse(endpoint).map_err(|_| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
86 if parsed.scheme() != "unix"
87 || parsed.host_str().is_some()
88 || parsed.query().is_some()
89 || parsed.fragment().is_some()
90 || parsed.path().contains('%')
91 {
92 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
93 }
94 Self::new(parsed.path())
95 }
96
97 #[must_use]
99 pub fn socket_path(&self) -> &Path {
100 &self.socket_path
101 }
102}
103
104impl fmt::Debug for UnixConnection {
105 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
106 formatter.write_str("UnixConnection { socket_path: [redacted] }")
107 }
108}
109
110impl fmt::Display for UnixConnection {
111 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112 formatter.write_str("unix socket [redacted]")
113 }
114}
115
116#[derive(Clone, Eq, PartialEq)]
118pub struct SshConnection {
119 host: String,
120 port: NonZeroU16,
121 user: String,
122 remote_socket_path: PathBuf,
123 host_key_reference: OpaqueReference,
124 authentication_reference: OpaqueReference,
125}
126
127impl SshConnection {
128 pub fn new(
134 host: impl Into<String>,
135 port: u16,
136 user: impl Into<String>,
137 remote_socket_path: impl Into<PathBuf>,
138 host_key_reference: OpaqueReference,
139 authentication_reference: OpaqueReference,
140 ) -> PodmanLensResult<Self> {
141 let host = validate_endpoint_name(host.into())?;
142 let user = validate_endpoint_name(user.into())?;
143 let remote_socket_path = remote_socket_path.into();
144 let port = NonZeroU16::new(port).ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
145 if !is_valid_unix_socket_path(&remote_socket_path) {
146 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
147 }
148 Ok(Self {
149 host,
150 port,
151 user,
152 remote_socket_path,
153 host_key_reference,
154 authentication_reference,
155 })
156 }
157
158 pub fn parse(
164 endpoint: &str,
165 host_key_reference: OpaqueReference,
166 authentication_reference: OpaqueReference,
167 ) -> PodmanLensResult<Self> {
168 let raw_path = raw_uri_path(endpoint, "ssh")?;
169 if !is_safe_raw_socket_path(raw_path) {
170 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
171 }
172 let parsed = Url::parse(endpoint).map_err(|_| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
173 if parsed.scheme() != "ssh"
174 || parsed.username().is_empty()
175 || parsed.password().is_some()
176 || parsed.query().is_some()
177 || parsed.fragment().is_some()
178 || parsed.path().contains('%')
179 {
180 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
181 }
182 let host = parsed
183 .host_str()
184 .ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
185 let port = parsed
186 .port()
187 .ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
188 Self::new(
189 host,
190 port,
191 parsed.username(),
192 parsed.path(),
193 host_key_reference,
194 authentication_reference,
195 )
196 }
197
198 #[must_use]
200 pub fn host(&self) -> &str {
201 &self.host
202 }
203
204 #[must_use]
206 pub const fn port(&self) -> u16 {
207 self.port.get()
208 }
209
210 #[must_use]
212 pub fn user(&self) -> &str {
213 &self.user
214 }
215
216 #[must_use]
218 pub fn remote_socket_path(&self) -> &Path {
219 &self.remote_socket_path
220 }
221
222 #[must_use]
224 pub fn host_key_reference(&self) -> &OpaqueReference {
225 &self.host_key_reference
226 }
227
228 #[must_use]
230 pub fn authentication_reference(&self) -> &OpaqueReference {
231 &self.authentication_reference
232 }
233}
234
235impl fmt::Debug for SshConnection {
236 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
237 formatter.write_str("SshConnection([redacted])")
238 }
239}
240
241impl fmt::Display for SshConnection {
242 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
243 formatter.write_str("SSH Podman connection [redacted]")
244 }
245}
246
247#[derive(Clone, Eq, PartialEq)]
249pub struct MutualTlsPolicy {
250 server_name: String,
251 certificate_authority_reference: OpaqueReference,
252 client_certificate_reference: OpaqueReference,
253 client_private_key_reference: OpaqueReference,
254}
255
256impl MutualTlsPolicy {
257 pub fn new(
263 server_name: impl Into<String>,
264 certificate_authority_reference: OpaqueReference,
265 client_certificate_reference: OpaqueReference,
266 client_private_key_reference: OpaqueReference,
267 ) -> PodmanLensResult<Self> {
268 Ok(Self {
269 server_name: validate_endpoint_name(server_name.into())?,
270 certificate_authority_reference,
271 client_certificate_reference,
272 client_private_key_reference,
273 })
274 }
275
276 #[must_use]
278 pub fn server_name(&self) -> &str {
279 &self.server_name
280 }
281
282 #[must_use]
284 pub fn certificate_authority_reference(&self) -> &OpaqueReference {
285 &self.certificate_authority_reference
286 }
287
288 #[must_use]
290 pub fn client_certificate_reference(&self) -> &OpaqueReference {
291 &self.client_certificate_reference
292 }
293
294 #[must_use]
296 pub fn client_private_key_reference(&self) -> &OpaqueReference {
297 &self.client_private_key_reference
298 }
299}
300
301impl fmt::Debug for MutualTlsPolicy {
302 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303 formatter.write_str("MutualTlsPolicy([redacted])")
304 }
305}
306
307impl fmt::Display for MutualTlsPolicy {
308 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
309 formatter.write_str("mutual TLS [redacted]")
310 }
311}
312
313#[derive(Clone, Eq, PartialEq)]
315pub struct TcpMutualTlsConnection {
316 host: String,
317 port: NonZeroU16,
318 policy: MutualTlsPolicy,
319}
320
321impl TcpMutualTlsConnection {
322 pub fn parse(endpoint: &str, policy: MutualTlsPolicy) -> PodmanLensResult<Self> {
330 let parsed = Url::parse(endpoint).map_err(|_| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
331 if parsed.scheme() != "tcp"
332 || !parsed.username().is_empty()
333 || parsed.password().is_some()
334 || parsed.query().is_some()
335 || parsed.fragment().is_some()
336 || !(parsed.path().is_empty() || parsed.path() == "/")
337 {
338 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
339 }
340 let host = parsed
341 .host_str()
342 .ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
343 let host = validate_endpoint_name(host.to_owned())?;
344 let port = parsed
345 .port()
346 .and_then(NonZeroU16::new)
347 .ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
348 Ok(Self { host, port, policy })
349 }
350
351 #[must_use]
353 pub fn host(&self) -> &str {
354 &self.host
355 }
356
357 #[must_use]
359 pub const fn port(&self) -> u16 {
360 self.port.get()
361 }
362
363 #[must_use]
365 pub fn policy(&self) -> &MutualTlsPolicy {
366 &self.policy
367 }
368}
369
370impl fmt::Debug for TcpMutualTlsConnection {
371 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
372 formatter.write_str("TcpMutualTlsConnection([redacted])")
373 }
374}
375
376impl fmt::Display for TcpMutualTlsConnection {
377 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
378 formatter.write_str("mutual-TLS TCP Podman connection [redacted]")
379 }
380}
381
382#[derive(Clone, Copy, Debug, Eq, PartialEq)]
384pub enum ConnectionKind {
385 Unix,
387 Ssh,
389 TcpMutualTls,
391}
392
393#[derive(Clone, Eq, PartialEq)]
395pub enum ConnectionSpec {
396 Unix(UnixConnection),
398 Ssh(SshConnection),
400 TcpMutualTls(TcpMutualTlsConnection),
402}
403
404impl ConnectionSpec {
405 #[must_use]
407 pub const fn kind(&self) -> ConnectionKind {
408 match self {
409 Self::Unix(_) => ConnectionKind::Unix,
410 Self::Ssh(_) => ConnectionKind::Ssh,
411 Self::TcpMutualTls(_) => ConnectionKind::TcpMutualTls,
412 }
413 }
414}
415
416impl fmt::Debug for ConnectionSpec {
417 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
418 match self {
419 Self::Unix(connection) => connection.fmt(formatter),
420 Self::Ssh(connection) => connection.fmt(formatter),
421 Self::TcpMutualTls(connection) => connection.fmt(formatter),
422 }
423 }
424}
425
426impl fmt::Display for ConnectionSpec {
427 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
428 match self {
429 Self::Unix(connection) => connection.fmt(formatter),
430 Self::Ssh(connection) => connection.fmt(formatter),
431 Self::TcpMutualTls(connection) => connection.fmt(formatter),
432 }
433 }
434}
435
436fn validate_endpoint_name(value: String) -> PodmanLensResult<String> {
437 if value.trim().is_empty()
438 || value != value.trim()
439 || contains_control_character(&value)
440 || value.chars().any(char::is_whitespace)
441 || value.contains(['/', '@', '?', '#'])
442 {
443 return Err(Diagnostic::new(DiagnosticCode::InvalidConnection));
444 }
445 Ok(value)
446}
447
448fn contains_control_character(value: &str) -> bool {
449 value.chars().any(char::is_control)
450}
451
452fn raw_uri_path<'a>(endpoint: &'a str, scheme: &str) -> PodmanLensResult<&'a str> {
453 let authority_and_path = endpoint
454 .strip_prefix(&format!("{scheme}://"))
455 .ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))?;
456 authority_and_path
457 .find('/')
458 .map(|index| &authority_and_path[index..])
459 .ok_or_else(|| Diagnostic::new(DiagnosticCode::InvalidConnection))
460}
461
462fn is_safe_raw_socket_path(path: &str) -> bool {
463 path.is_ascii()
464 && path.starts_with('/')
465 && !path.contains('%')
466 && !path
467 .chars()
468 .any(|character| character.is_control() || character.is_whitespace())
469 && path.split('/').all(|segment| !matches!(segment, "." | ".."))
470}
471
472fn is_valid_unix_socket_path(path: &Path) -> bool {
473 let Some(path_text) = path.to_str() else {
474 return false;
475 };
476 path.is_absolute()
477 && path.file_name().is_some()
478 && !path_text.contains('\0')
479 && path_text.len() <= SOCKADDR_UN_PATH_MAX_BYTES
480 && path_text.split('/').all(|segment| !matches!(segment, "." | ".."))
481 && path.components().all(|component| {
482 !matches!(
483 component,
484 Component::ParentDir | Component::CurDir | Component::Prefix(_)
485 )
486 })
487}