Skip to main content

podman_lens/
connection.rs

1//! Explicit, transport-neutral Podman service connection specifications.
2
3use 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/// An opaque external reference such as a host key, certificate, or authentication material.
16///
17/// The reference identifies material owned by the caller; it never contains the material itself.
18/// Its textual value is intentionally redacted from formatting implementations.
19#[derive(Clone, Eq, PartialEq, Hash)]
20pub struct OpaqueReference(String);
21
22impl OpaqueReference {
23    /// Validates and retains a non-empty external reference.
24    ///
25    /// # Errors
26    ///
27    /// Returns `PLN0001` when the reference is empty or contains a control character.
28    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    /// Returns the caller-owned reference for a transport implementation.
37    #[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/// A validated local Unix-domain socket connection.
56#[derive(Clone, Eq, PartialEq)]
57pub struct UnixConnection {
58    socket_path: PathBuf,
59}
60
61impl UnixConnection {
62    /// Creates a Unix connection with an absolute socket path.
63    ///
64    /// # Errors
65    ///
66    /// Returns `PLN0001` when the path is not a safe absolute Unix socket path.
67    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    /// Parses an explicit `unix:///absolute/socket` endpoint spelling.
76    ///
77    /// # Errors
78    ///
79    /// Returns `PLN0001` when the endpoint is not an explicit safe Unix socket URI.
80    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    /// Returns the selected local socket path for a caller-provided Unix transport.
98    #[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/// A validated SSH endpoint with explicit host verification and authentication references.
117#[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    /// Creates an SSH endpoint with an absolute remote socket path and required verification.
129    ///
130    /// # Errors
131    ///
132    /// Returns `PLN0001` when an endpoint component, port, or remote socket path is invalid.
133    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    /// Parses `ssh://user@host:port/absolute/socket` with separately supplied security material.
159    ///
160    /// # Errors
161    ///
162    /// Returns `PLN0001` when the endpoint omits an explicit secure SSH component.
163    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    /// Returns the explicit host for a caller-provided SSH transport.
199    #[must_use]
200    pub fn host(&self) -> &str {
201        &self.host
202    }
203
204    /// Returns the explicit non-zero SSH port.
205    #[must_use]
206    pub const fn port(&self) -> u16 {
207        self.port.get()
208    }
209
210    /// Returns the selected remote user.
211    #[must_use]
212    pub fn user(&self) -> &str {
213        &self.user
214    }
215
216    /// Returns the absolute remote socket path.
217    #[must_use]
218    pub fn remote_socket_path(&self) -> &Path {
219        &self.remote_socket_path
220    }
221
222    /// Returns the required verified-host-key reference.
223    #[must_use]
224    pub fn host_key_reference(&self) -> &OpaqueReference {
225        &self.host_key_reference
226    }
227
228    /// Returns the caller-owned authentication reference.
229    #[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/// Mandatory mutual-TLS material references for a TCP Podman endpoint.
248#[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    /// Creates a mandatory mutual-TLS policy with hostname verification and client credentials.
258    ///
259    /// # Errors
260    ///
261    /// Returns `PLN0001` when the TLS server name is invalid.
262    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    /// Returns the hostname that a caller-provided TLS transport must verify.
277    #[must_use]
278    pub fn server_name(&self) -> &str {
279        &self.server_name
280    }
281
282    /// Returns the required certificate-authority reference.
283    #[must_use]
284    pub fn certificate_authority_reference(&self) -> &OpaqueReference {
285        &self.certificate_authority_reference
286    }
287
288    /// Returns the required client-certificate reference.
289    #[must_use]
290    pub fn client_certificate_reference(&self) -> &OpaqueReference {
291        &self.client_certificate_reference
292    }
293
294    /// Returns the required client-private-key reference.
295    #[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/// A Podman-style TCP endpoint that is protected by mandatory mutual TLS.
314#[derive(Clone, Eq, PartialEq)]
315pub struct TcpMutualTlsConnection {
316    host: String,
317    port: NonZeroU16,
318    policy: MutualTlsPolicy,
319}
320
321impl TcpMutualTlsConnection {
322    /// Parses an explicit `tcp://host:port` endpoint and attaches mandatory mutual TLS policy.
323    ///
324    /// Plaintext TCP is intentionally not representable by this API.
325    ///
326    /// # Errors
327    ///
328    /// Returns `PLN0001` when the endpoint is not a host-and-port-only `tcp` URI.
329    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    /// Returns the explicit TCP host for a caller-provided mutual-TLS transport.
352    #[must_use]
353    pub fn host(&self) -> &str {
354        &self.host
355    }
356
357    /// Returns the explicit non-zero TCP port.
358    #[must_use]
359    pub const fn port(&self) -> u16 {
360        self.port.get()
361    }
362
363    /// Returns the mandatory mutual-TLS policy.
364    #[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/// The transport category selected by a [`ConnectionSpec`].
383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
384pub enum ConnectionKind {
385    /// A local Unix-domain socket.
386    Unix,
387    /// An SSH tunnel to an explicitly named remote socket.
388    Ssh,
389    /// A TCP endpoint with mandatory mutual TLS.
390    TcpMutualTls,
391}
392
393/// An explicit Podman service endpoint without ambient environment or config discovery.
394#[derive(Clone, Eq, PartialEq)]
395pub enum ConnectionSpec {
396    /// A local Unix-domain socket endpoint.
397    Unix(UnixConnection),
398    /// An SSH endpoint with explicit authentication and host-verification references.
399    Ssh(SshConnection),
400    /// A TCP endpoint that always requires mutual TLS.
401    TcpMutualTls(TcpMutualTlsConnection),
402}
403
404impl ConnectionSpec {
405    /// Returns the category of this explicit connection.
406    #[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}