1#![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#[repr(transparent)]
14#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
15pub struct VpsName(String);
16
17impl VpsName {
18 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 #[must_use]
34 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37
38 #[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#[repr(transparent)]
66#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
67pub struct SshHost(String);
68
69impl SshHost {
70 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 #[must_use]
93 pub fn as_str(&self) -> &str {
94 &self.0
95 }
96
97 #[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#[repr(transparent)]
125#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
126pub struct SshUser(String);
127
128impl SshUser {
129 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 #[must_use]
152 pub fn as_str(&self) -> &str {
153 &self.0
154 }
155
156 #[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#[repr(transparent)]
184#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
185pub struct HostTag(String);
186
187impl HostTag {
188 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 #[must_use]
208 pub fn as_str(&self) -> &str {
209 &self.0
210 }
211
212 #[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
238pub 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}