Skip to main content

ssh_cli/domain/
ports.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Port newtypes (SSH vs bind/ephemeral).
3#![forbid(unsafe_code)]
4
5use super::error::DomainError;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::fmt;
8use std::num::NonZeroU16;
9
10/// SSH TCP port in `1..=65535` (zero is unrepresentable).
11#[repr(transparent)]
12#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct SshPort(NonZeroU16);
14
15impl SshPort {
16    /// Parses a non-zero SSH port.
17    pub fn try_new(port: u16) -> Result<Self, DomainError> {
18        NonZeroU16::new(port)
19            .map(Self)
20            .ok_or_else(|| DomainError::new("ssh_port", "invalid SSH port: 0 (use 1..=65535)"))
21    }
22
23    /// Port as `u16` (always ≥ 1).
24    #[must_use]
25    pub const fn get(self) -> u16 {
26        self.0.get()
27    }
28
29    /// Alias for [`Self::get`].
30    #[must_use]
31    pub const fn as_u16(self) -> u16 {
32        self.get()
33    }
34}
35
36impl fmt::Display for SshPort {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        write!(f, "{}", self.get())
39    }
40}
41
42impl Serialize for SshPort {
43    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
44        s.serialize_u16(self.get())
45    }
46}
47
48impl<'de> Deserialize<'de> for SshPort {
49    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
50        let v = u16::deserialize(d)?;
51        Self::try_new(v).map_err(serde::de::Error::custom)
52    }
53}
54
55/// Local bind port: `0` means ephemeral OS assignment; otherwise `1..=65535`.
56///
57/// Distinct from [`SshPort`] (DRY with discipline — do not unify).
58#[repr(transparent)]
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
60pub struct BindPort(u16);
61
62impl BindPort {
63    /// Accepts any `u16` (including 0 for ephemeral).
64    #[must_use]
65    pub const fn new(port: u16) -> Self {
66        Self(port)
67    }
68
69    /// Underlying port number.
70    #[must_use]
71    pub const fn get(self) -> u16 {
72        self.0
73    }
74
75    /// True when the OS should pick an ephemeral port.
76    #[must_use]
77    pub const fn is_ephemeral(self) -> bool {
78        self.0 == 0
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use std::mem::{align_of, size_of};
86
87    #[test]
88    fn ssh_port_rejects_zero() {
89        assert!(SshPort::try_new(0).is_err());
90        assert_eq!(SshPort::try_new(22).unwrap().get(), 22);
91    }
92
93    #[test]
94    fn ssh_port_zero_cost() {
95        assert_eq!(size_of::<SshPort>(), size_of::<u16>());
96        assert_eq!(size_of::<Option<SshPort>>(), size_of::<u16>());
97        assert_eq!(align_of::<SshPort>(), align_of::<u16>());
98    }
99
100    #[test]
101    fn bind_port_allows_zero() {
102        assert!(BindPort::new(0).is_ephemeral());
103        assert!(!BindPort::new(8080).is_ephemeral());
104    }
105}