Skip to main content

isoloom_spec/syntax/step/
words.rs

1//! The names and paths a step gives: units, users, groups, packages, repositories, paths in the
2//! machine and in the project, URLs and checksums. Each is a newtype that only holds a value
3//! matching its rule (the JSON Schema carries the same pattern), so a parsed step holds nothing a
4//! renderer would have to check again. Every rule keeps out the characters no name of its kind
5//! has (whitespace, quotes, `;`, `$`), and the renderers quote every value all the same.
6
7use std::{borrow::Cow, fmt, str::FromStr};
8
9use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
12use super::super::names::{Text, name_type};
13use crate::Error;
14
15name_type!(
16    /// A service's unit name (`nginx`, `php8.2-fpm`, `W3SVC`): a systemd or OpenRC unit, or a
17    /// Windows service, or a key of the daemon table.
18    UnitName,
19    "unit name",
20    "^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,127}$",
21    "a unit is letters, digits and `:_.@-`, starting with a letter or digit, at most 128 characters (`nginx`, `php8.2-fpm`)",
22    |s| s.len() <= 128 && s.starts_with(|c: char| c.is_ascii_alphanumeric()) && s.chars().all(|c| c.is_ascii_alphanumeric() || ":_.@-".contains(c))
23);
24
25name_type!(
26    /// A user's name (`www-data`, `svc_backup`, `Administrator`).
27    UserName,
28    "user name",
29    "^[A-Za-z_][A-Za-z0-9_.-]{0,31}$",
30    "a user is letters, digits and `_.-`, starting with a letter or `_`, at most 32 characters (`www-data`)",
31    |s| s.len() <= 32 && s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
32);
33
34name_type!(
35    /// A group's name (`www-data`, or a Windows group such as `Remote Desktop Users`).
36    GroupName,
37    "group name",
38    "^[A-Za-z_]([A-Za-z0-9_. -]{0,62}[A-Za-z0-9_.-])?$",
39    "a group is letters, digits, `_.-` and inner spaces, starting with a letter or `_`, at most 64 characters (`www-data`, `Remote Desktop Users`)",
40    |s| s.len() <= 64
41        && s.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
42        && !s.ends_with(' ')
43        && s.chars().all(|c| c.is_ascii_alphanumeric() || "_.- ".contains(c))
44);
45
46name_type!(
47    /// A package's name, without its version (`nginx`, `libapache2-mod-php`), or a Windows
48    /// feature's (`Web-Server`).
49    PackageName,
50    "package name",
51    "^[A-Za-z0-9][A-Za-z0-9+._:-]{0,127}$",
52    "a package is letters, digits and `+._:-`, starting with a letter or digit, at most 128 characters (`nginx`); its version goes in `version`",
53    |s| s.len() <= 128 && s.starts_with(|c: char| c.is_ascii_alphanumeric()) && s.chars().all(|c| c.is_ascii_alphanumeric() || "+._:-".contains(c))
54);
55
56name_type!(
57    /// A package repository's name, in kebab case (`nginx`, `pgdg`): it names its source and key
58    /// files.
59    RepositoryName,
60    "repository name",
61    "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
62    "a repository's name is kebab-case, at most 63 characters (`nginx`, `pgdg`)",
63    |s| s.len() <= 63
64        && s.starts_with(|c: char| c.is_ascii_lowercase())
65        && !s.ends_with('-')
66        && !s.contains("--")
67        && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
68);
69
70name_type!(
71    /// An absolute path in the machine (`/etc/app.toml`), or a drive path on Windows
72    /// (`C:\inetpub\wwwroot`).
73    AbsPath,
74    "path in the machine",
75    "^(/|[A-Za-z]:[\\\\/])[^\\n]*$",
76    "a path in the machine is absolute (`/etc/app.toml`), or a drive path on Windows (`C:\\app`)",
77    |s| (s.starts_with('/') || drive(s)) && !s.contains(['\n', '\0'])
78);
79
80name_type!(
81    /// A file or folder of the project, relative to its folder, without `..` (`provision/web.sh`;
82    /// a folder ends with `/`, `site/`).
83    ProjectPath,
84    "project path",
85    "^(?![/\\\\]|[A-Za-z]:[/\\\\])(?!(.*[/\\\\])?\\.\\.([/\\\\]|$))[^\\n]+$",
86    "a project path is relative to the project folder and stays inside it: no leading `/`, no `..` (`provision/web.sh`)",
87    |s| !s.is_empty() && !s.starts_with(['/', '\\']) && !drive(s) && !s.split(['/', '\\']).any(|part| part == "..") && !s.contains(['\n', '\0'])
88);
89
90name_type!(
91    /// An `https` URL (`https://nginx.org/keys/nginx_signing.key`): every fetch goes over TLS.
92    HttpsUrl,
93    "URL",
94    "^https://[^\\s/]+\\S*$",
95    "a URL is `https://<host>/<path>`, with no spaces: every fetch goes over TLS",
96    |s| s
97        .strip_prefix("https://")
98        .is_some_and(|rest| !rest.starts_with('/') && !rest.is_empty() && !rest.contains(char::is_whitespace))
99);
100
101name_type!(
102    /// A SHA-256, as `sha256sum` prints it: 64 lowercase hex digits.
103    Sha256,
104    "SHA-256",
105    "^[0-9a-f]{64}$",
106    "a SHA-256 is 64 lowercase hex digits, as `sha256sum` prints it",
107    |s| s.len() == 64 && s.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
108);
109
110name_type!(
111    /// An apt suite or component (`bookworm`, `main`, `nginx`).
112    AptWord,
113    "apt suite or component",
114    "^[A-Za-z0-9][A-Za-z0-9._/-]{0,63}$",
115    "a suite or component is letters, digits and `._/-`, starting with a letter or digit (`bookworm`, `main`)",
116    |s| s.len() <= 64 && s.starts_with(|c: char| c.is_ascii_alphanumeric()) && s.chars().all(|c| c.is_ascii_alphanumeric() || "._/-".contains(c))
117);
118
119/// Whether `s` starts with a Windows drive (`C:\`, `C:/`).
120fn drive(s: &str) -> bool {
121    matches!(s.as_bytes(), [d, b':', b'\\' | b'/', ..] if d.is_ascii_alphabetic())
122}
123
124/// A package's version, as its family's package manager writes it (`1.30.6-1~bookworm`,
125/// `1:2.4.62-1`, `1.30.6-r1`).
126#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
127pub struct PackageVersion(String);
128
129impl PackageVersion {
130    /// The version as written.
131    #[must_use]
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135}
136
137impl FromStr for PackageVersion {
138    type Err = Error;
139
140    /// The version, when it follows the rule.
141    ///
142    /// # Errors
143    ///
144    /// [`Error::Name`] with the rule.
145    fn from_str(value: &str) -> Result<PackageVersion, Error> {
146        let ok = value.len() <= 128
147            && value.starts_with(|c: char| c.is_ascii_alphanumeric())
148            && value.chars().all(|c| c.is_ascii_alphanumeric() || ".+~:_-".contains(c));
149        if ok {
150            Ok(PackageVersion(value.to_owned()))
151        } else {
152            Err(Error::Name {
153                what: "package version",
154                value: value.to_owned(),
155                rule: "a version is letters, digits and `.+~:_-`, as the package manager writes it (`1.30.6-1~bookworm`)",
156            })
157        }
158    }
159}
160
161impl fmt::Display for PackageVersion {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        f.write_str(&self.0)
164    }
165}
166
167impl AsRef<str> for PackageVersion {
168    fn as_ref(&self) -> &str {
169        &self.0
170    }
171}
172
173impl Serialize for PackageVersion {
174    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
175        s.serialize_str(&self.0)
176    }
177}
178
179impl<'de> Deserialize<'de> for PackageVersion {
180    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<PackageVersion, D::Error> {
181        d.deserialize_any(VersionText)?.parse().map_err(serde::de::Error::custom)
182    }
183}
184
185/// A version as written: a text, or a whole number; a decimal number would lose its zeros.
186struct VersionText;
187
188impl serde::de::Visitor<'_> for VersionText {
189    type Value = String;
190
191    fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
192        f.write_str("a version, quoted (`\"1.30.6-1~bookworm\"`)")
193    }
194
195    fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<String, E> {
196        Ok(v.to_owned())
197    }
198
199    fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<String, E> {
200        Ok(v.to_string())
201    }
202
203    fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<String, E> {
204        Err(E::custom(format!("quote the version (`\"{v}\"`): read as a number, it loses its zeros")))
205    }
206}
207
208impl JsonSchema for PackageVersion {
209    fn schema_name() -> Cow<'static, str> {
210        "PackageVersion".into()
211    }
212
213    fn inline_schema() -> bool {
214        true
215    }
216
217    fn json_schema(_: &mut SchemaGenerator) -> Schema {
218        json_schema!({ "type": ["string", "integer"], "pattern": "^[A-Za-z0-9][A-Za-z0-9.+~:_-]{0,127}$" })
219    }
220}
221
222#[cfg(test)]
223mod tests;