Skip to main content

ssh_cli/domain/
names.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Identity-like string newtypes (VPS name, SSH host/user, tags).
3#![forbid(unsafe_code)]
4
5use super::error::DomainError;
6use crate::validation::{MAX_FIELD_LEN, MAX_TAG_LEN, MAX_TAGS};
7use serde::{Deserialize, Deserializer, Serialize};
8use std::fmt;
9
10/// Logical VPS registry name (NFC, path-safe, non-empty).
11///
12/// Invariant: passes [`crate::paths::validate_name`] and is NFC-normalized.
13#[repr(transparent)]
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
15pub struct VpsName(String);
16
17impl VpsName {
18    /// Parses and normalizes a registry name.
19    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
20        let s = raw.as_ref();
21        crate::paths::validate_name(s).map_err(|e| DomainError::new("vps_name", e.to_string()))?;
22        let nfc = crate::paths::normalize_nfc(s);
23        if nfc.len() > MAX_FIELD_LEN {
24            return Err(DomainError::new(
25                "vps_name",
26                format!("name must be 1..={MAX_FIELD_LEN} chars"),
27            ));
28        }
29        Ok(Self(nfc))
30    }
31
32    /// Borrows the validated name.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37
38    /// Consumes into the inner `String`.
39    #[must_use]
40    pub fn into_inner(self) -> String {
41        self.0
42    }
43}
44
45impl AsRef<str> for VpsName {
46    fn as_ref(&self) -> &str {
47        self.as_str()
48    }
49}
50
51impl fmt::Display for VpsName {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        f.write_str(self.as_str())
54    }
55}
56
57impl<'de> Deserialize<'de> for VpsName {
58    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
59        let s = String::deserialize(d)?;
60        Self::try_new(s).map_err(serde::de::Error::custom)
61    }
62}
63
64/// SSH hostname or IP (non-empty after trim, max [`MAX_FIELD_LEN`]).
65#[repr(transparent)]
66#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
67pub struct SshHost(String);
68
69impl SshHost {
70    /// Parses a host reference.
71    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
72        let t = raw.as_ref().trim();
73        if t.is_empty() {
74            return Err(DomainError::new("ssh_host", "host must not be empty"));
75        }
76        if t.len() > MAX_FIELD_LEN {
77            return Err(DomainError::new(
78                "ssh_host",
79                format!("host must be 1..={MAX_FIELD_LEN} chars"),
80            ));
81        }
82        if t.chars().any(|c| c.is_control()) {
83            return Err(DomainError::new(
84                "ssh_host",
85                "host must not contain control characters",
86            ));
87        }
88        Ok(Self(t.to_owned()))
89    }
90
91    /// Borrows the host string.
92    #[must_use]
93    pub fn as_str(&self) -> &str {
94        &self.0
95    }
96
97    /// Consumes into inner `String`.
98    #[must_use]
99    pub fn into_inner(self) -> String {
100        self.0
101    }
102}
103
104impl AsRef<str> for SshHost {
105    fn as_ref(&self) -> &str {
106        self.as_str()
107    }
108}
109
110impl fmt::Display for SshHost {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116impl<'de> Deserialize<'de> for SshHost {
117    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
118        let s = String::deserialize(d)?;
119        Self::try_new(s).map_err(serde::de::Error::custom)
120    }
121}
122
123/// SSH username (non-empty after trim, max [`MAX_FIELD_LEN`]).
124#[repr(transparent)]
125#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
126pub struct SshUser(String);
127
128impl SshUser {
129    /// Parses a username.
130    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
131        let t = raw.as_ref().trim();
132        if t.is_empty() {
133            return Err(DomainError::new("ssh_user", "username must not be empty"));
134        }
135        if t.len() > MAX_FIELD_LEN {
136            return Err(DomainError::new(
137                "ssh_user",
138                format!("username must be 1..={MAX_FIELD_LEN} chars"),
139            ));
140        }
141        if t.chars().any(|c| c.is_control()) {
142            return Err(DomainError::new(
143                "ssh_user",
144                "username must not contain control characters",
145            ));
146        }
147        Ok(Self(t.to_owned()))
148    }
149
150    /// Borrows the username.
151    #[must_use]
152    pub fn as_str(&self) -> &str {
153        &self.0
154    }
155
156    /// Consumes into inner `String`.
157    #[must_use]
158    pub fn into_inner(self) -> String {
159        self.0
160    }
161}
162
163impl AsRef<str> for SshUser {
164    fn as_ref(&self) -> &str {
165        self.as_str()
166    }
167}
168
169impl fmt::Display for SshUser {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.write_str(self.as_str())
172    }
173}
174
175impl<'de> Deserialize<'de> for SshUser {
176    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
177        let s = String::deserialize(d)?;
178        Self::try_new(s).map_err(serde::de::Error::custom)
179    }
180}
181
182/// Single host tag (1..=MAX_TAG_LEN, no path separators/controls).
183#[repr(transparent)]
184#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
185pub struct HostTag(String);
186
187impl HostTag {
188    /// Parses one tag.
189    pub fn try_new(raw: impl AsRef<str>) -> Result<Self, DomainError> {
190        let t = raw.as_ref().trim();
191        if t.is_empty() || t.len() > MAX_TAG_LEN {
192            return Err(DomainError::new(
193                "host_tag",
194                format!("each tag must be 1..={MAX_TAG_LEN} chars"),
195            ));
196        }
197        if t.chars().any(|c| c.is_control() || c == '/' || c == '\\') {
198            return Err(DomainError::new(
199                "host_tag",
200                "tag must not contain control chars or path separators",
201            ));
202        }
203        Ok(Self(t.to_owned()))
204    }
205
206    /// Borrows the tag.
207    #[must_use]
208    pub fn as_str(&self) -> &str {
209        &self.0
210    }
211
212    /// Consumes into inner `String`.
213    #[must_use]
214    pub fn into_inner(self) -> String {
215        self.0
216    }
217}
218
219impl AsRef<str> for HostTag {
220    fn as_ref(&self) -> &str {
221        self.as_str()
222    }
223}
224
225impl fmt::Display for HostTag {
226    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227        f.write_str(self.as_str())
228    }
229}
230
231impl<'de> Deserialize<'de> for HostTag {
232    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
233        let s = String::deserialize(d)?;
234        Self::try_new(s).map_err(serde::de::Error::custom)
235    }
236}
237
238/// Parses a tag list with cardinality cap (G-TYPE-05 container).
239pub fn try_tags(
240    raw: impl IntoIterator<Item = impl AsRef<str>>,
241) -> Result<Vec<HostTag>, DomainError> {
242    let mut out = Vec::new();
243    for t in raw {
244        out.push(HostTag::try_new(t)?);
245        if out.len() > MAX_TAGS {
246            return Err(DomainError::new(
247                "tags_count",
248                format!("at most {MAX_TAGS} tags allowed"),
249            ));
250        }
251    }
252    Ok(out)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn host_user_name() {
261        assert!(SshHost::try_new("  ").is_err());
262        assert!(SshUser::try_new("").is_err());
263        assert_eq!(SshHost::try_new(" a.b ").unwrap().as_str(), "a.b");
264        assert!(VpsName::try_new("lab-01").is_ok());
265        assert!(VpsName::try_new("../x").is_err());
266    }
267
268    #[test]
269    fn tags() {
270        assert!(HostTag::try_new("prod").is_ok());
271        assert!(HostTag::try_new("a/b").is_err());
272    }
273}