Skip to content

Commit d0ecc79

Browse files
authored
[2/n] add newtype wrappers to ensure config identifier validity (#69)
I'm going to add some more identifiers soon, and I wanted to make sure at deserialize time that they were somewhat well-formed. We restrict ourselves to ASCII at the moment, which is fine. Add three newtype wrappers -- one each for package, service and preset names.
1 parent fccfc45 commit d0ecc79

File tree

6 files changed

+460
-61
lines changed

6 files changed

+460
-61
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ tokio = { version = "1.26", features = [ "full" ] }
3333
toml = "0.7.3"
3434
topological-sort = "0.2.2"
3535
walkdir = "2.3"
36+
37+
[dev-dependencies]
38+
proptest = "1.6.0"
39+
test-strategy = "0.4.0"

src/config/identifier.rs

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
use std::{borrow::Cow, fmt, str::FromStr};
6+
7+
use serde::{Deserialize, Serialize};
8+
use thiserror::Error;
9+
10+
macro_rules! ident_newtype {
11+
($id:ident) => {
12+
impl $id {
13+
/// Creates a new identifier at runtime.
14+
pub fn new<S: Into<String>>(s: S) -> Result<Self, InvalidConfigIdent> {
15+
ConfigIdent::new(s).map(Self)
16+
}
17+
18+
/// Creates a new identifier from a static string.
19+
pub fn new_static(s: &'static str) -> Result<Self, InvalidConfigIdent> {
20+
ConfigIdent::new_static(s).map(Self)
21+
}
22+
23+
/// Creates a new identifier at compile time, panicking if it is
24+
/// invalid.
25+
pub const fn new_const(s: &'static str) -> Self {
26+
Self(ConfigIdent::new_const(s))
27+
}
28+
29+
/// Returns the identifier as a string.
30+
#[inline]
31+
pub fn as_str(&self) -> &str {
32+
self.0.as_str()
33+
}
34+
35+
#[inline]
36+
#[allow(dead_code)]
37+
pub(crate) fn as_ident(&self) -> &ConfigIdent {
38+
&self.0
39+
}
40+
}
41+
42+
impl AsRef<str> for $id {
43+
#[inline]
44+
fn as_ref(&self) -> &str {
45+
self.0.as_ref()
46+
}
47+
}
48+
49+
impl std::fmt::Display for $id {
50+
#[inline]
51+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
52+
self.0.fmt(f)
53+
}
54+
}
55+
56+
impl FromStr for $id {
57+
type Err = InvalidConfigIdent;
58+
59+
fn from_str(s: &str) -> Result<Self, Self::Err> {
60+
ConfigIdent::new(s).map(Self)
61+
}
62+
}
63+
};
64+
}
65+
66+
/// A unique identifier for a package name.
67+
///
68+
/// Package names must be:
69+
///
70+
/// * non-empty
71+
/// * ASCII printable
72+
/// * first character must be a letter
73+
/// * contain only letters, numbers, underscores, and hyphens
74+
///
75+
/// These generally match the rules of Rust package names.
76+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
77+
#[serde(transparent)]
78+
pub struct PackageName(ConfigIdent);
79+
ident_newtype!(PackageName);
80+
81+
/// A unique identifier for a service name.
82+
///
83+
/// Package names must be:
84+
///
85+
/// * non-empty
86+
/// * ASCII printable
87+
/// * first character must be a letter
88+
/// * contain only letters, numbers, underscores, and hyphens
89+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
90+
#[serde(transparent)]
91+
pub struct ServiceName(ConfigIdent);
92+
ident_newtype!(ServiceName);
93+
94+
/// A unique identifier for a target preset.
95+
///
96+
/// Package names must be:
97+
///
98+
/// * non-empty
99+
/// * ASCII printable
100+
/// * first character must be a letter
101+
/// * contain only letters, numbers, underscores, and hyphens
102+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
103+
#[serde(transparent)]
104+
pub struct PresetName(ConfigIdent);
105+
ident_newtype!(PresetName);
106+
107+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
108+
#[serde(transparent)]
109+
pub(crate) struct ConfigIdent(Cow<'static, str>);
110+
111+
impl ConfigIdent {
112+
/// Creates a new config identifier at runtime.
113+
pub fn new<S: Into<String>>(s: S) -> Result<Self, InvalidConfigIdent> {
114+
let s = s.into();
115+
Self::validate(&s)?;
116+
Ok(Self(Cow::Owned(s)))
117+
}
118+
119+
/// Creates a new config identifier from a static string.
120+
pub fn new_static(s: &'static str) -> Result<Self, InvalidConfigIdent> {
121+
Self::validate(s)?;
122+
Ok(Self(Cow::Borrowed(s)))
123+
}
124+
125+
/// Creates a new config identifier at compile time, panicking if the
126+
/// identifier is invalid.
127+
pub const fn new_const(s: &'static str) -> Self {
128+
match Self::validate(s) {
129+
Ok(_) => Self(Cow::Borrowed(s)),
130+
Err(error) => panic!("{}", error.as_static_str()),
131+
}
132+
}
133+
134+
const fn validate(id: &str) -> Result<(), InvalidConfigIdent> {
135+
if id.is_empty() {
136+
return Err(InvalidConfigIdent::Empty);
137+
}
138+
139+
let bytes = id.as_bytes();
140+
if !bytes[0].is_ascii_alphabetic() {
141+
return Err(InvalidConfigIdent::StartsWithNonLetter);
142+
}
143+
144+
let mut bytes = match bytes {
145+
[_, rest @ ..] => rest,
146+
[] => panic!("already checked that it's non-empty"),
147+
};
148+
while let [next, rest @ ..] = &bytes {
149+
if !(next.is_ascii_alphanumeric() || *next == b'_' || *next == b'-') {
150+
break;
151+
}
152+
bytes = rest;
153+
}
154+
155+
if !bytes.is_empty() {
156+
return Err(InvalidConfigIdent::ContainsInvalidCharacters);
157+
}
158+
159+
Ok(())
160+
}
161+
162+
/// Returns the identifier as a string.
163+
#[inline]
164+
pub fn as_str(&self) -> &str {
165+
&self.0
166+
}
167+
}
168+
169+
impl FromStr for ConfigIdent {
170+
type Err = InvalidConfigIdent;
171+
172+
fn from_str(s: &str) -> Result<Self, Self::Err> {
173+
Self::new(s)
174+
}
175+
}
176+
177+
impl<'de> Deserialize<'de> for ConfigIdent {
178+
fn deserialize<D>(deserializer: D) -> Result<ConfigIdent, D::Error>
179+
where
180+
D: serde::Deserializer<'de>,
181+
{
182+
let s = String::deserialize(deserializer)?;
183+
Self::new(s).map_err(serde::de::Error::custom)
184+
}
185+
}
186+
187+
impl AsRef<str> for ConfigIdent {
188+
#[inline]
189+
fn as_ref(&self) -> &str {
190+
&self.0
191+
}
192+
}
193+
194+
impl std::fmt::Display for ConfigIdent {
195+
#[inline]
196+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
197+
self.0.fmt(f)
198+
}
199+
}
200+
201+
/// Errors that can occur when creating a `ConfigIdent`.
202+
#[derive(Clone, Debug, Error)]
203+
pub enum InvalidConfigIdent {
204+
Empty,
205+
NonAsciiPrintable,
206+
StartsWithNonLetter,
207+
ContainsInvalidCharacters,
208+
}
209+
210+
impl InvalidConfigIdent {
211+
pub const fn as_static_str(&self) -> &'static str {
212+
match self {
213+
Self::Empty => "config identifier must be non-empty",
214+
Self::NonAsciiPrintable => "config identifier must be ASCII printable",
215+
Self::StartsWithNonLetter => "config identifier must start with a letter",
216+
Self::ContainsInvalidCharacters => {
217+
"config identifier must contain only letters, numbers, underscores, and hyphens"
218+
}
219+
}
220+
}
221+
}
222+
223+
impl fmt::Display for InvalidConfigIdent {
224+
#[inline]
225+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
226+
self.as_static_str().fmt(f)
227+
}
228+
}
229+
230+
#[cfg(test)]
231+
mod tests {
232+
use super::*;
233+
use serde_json::json;
234+
use test_strategy::proptest;
235+
236+
static IDENT_REGEX: &str = r"[a-zA-Z][a-zA-Z0-9_-]*";
237+
238+
#[test]
239+
fn valid_identifiers() {
240+
let valid = [
241+
"a", "ab", "a1", "a_", "a-", "a_b", "a-b", "a1_", "a1-", "a1_b", "a1-b",
242+
];
243+
for &id in &valid {
244+
ConfigIdent::new(id).unwrap_or_else(|error| {
245+
panic!(
246+
"ConfigIdent::new for {} should have succeeded, but failed with: {:?}",
247+
id, error
248+
);
249+
});
250+
PackageName::new(id).unwrap_or_else(|error| {
251+
panic!(
252+
"PackageName::new for {} should have succeeded, but failed with: {:?}",
253+
id, error
254+
);
255+
});
256+
ServiceName::new(id).unwrap_or_else(|error| {
257+
panic!(
258+
"ServiceName::new for {} should have succeeded, but failed with: {:?}",
259+
id, error
260+
);
261+
});
262+
PresetName::new(id).unwrap_or_else(|error| {
263+
panic!(
264+
"PresetName::new for {} should have succeeded, but failed with: {:?}",
265+
id, error
266+
);
267+
});
268+
}
269+
}
270+
271+
#[test]
272+
fn invalid_identifiers() {
273+
let invalid = [
274+
"", "1", "_", "-", "1_", "-a", "_a", "a!", "a ", "a\n", "a\t", "a\r", "a\x7F", "aɑ",
275+
];
276+
for &id in &invalid {
277+
ConfigIdent::new(id)
278+
.expect_err(&format!("ConfigIdent::new for {} should have failed", id));
279+
PackageName::new(id)
280+
.expect_err(&format!("PackageName::new for {} should have failed", id));
281+
ServiceName::new(id)
282+
.expect_err(&format!("ServiceName::new for {} should have failed", id));
283+
PresetName::new(id)
284+
.expect_err(&format!("PresetName::new for {} should have failed", id));
285+
286+
// Also ensure that deserialization fails.
287+
let json = json!(id);
288+
serde_json::from_value::<ConfigIdent>(json.clone()).expect_err(&format!(
289+
"ConfigIdent deserialization for {} should have failed",
290+
id
291+
));
292+
serde_json::from_value::<PackageName>(json.clone()).expect_err(&format!(
293+
"PackageName deserialization for {} should have failed",
294+
id
295+
));
296+
serde_json::from_value::<ServiceName>(json.clone()).expect_err(&format!(
297+
"ServiceName deserialization for {} should have failed",
298+
id
299+
));
300+
serde_json::from_value::<PresetName>(json.clone()).expect_err(&format!(
301+
"PresetName deserialization for {} should have failed",
302+
id
303+
));
304+
}
305+
}
306+
307+
#[proptest]
308+
fn valid_identifiers_proptest(#[strategy(IDENT_REGEX)] id: String) {
309+
ConfigIdent::new(&id).unwrap_or_else(|error| {
310+
panic!(
311+
"ConfigIdent::new for {} should have succeeded, but failed with: {:?}",
312+
id, error
313+
);
314+
});
315+
PackageName::new(&id).unwrap_or_else(|error| {
316+
panic!(
317+
"PackageName::new for {} should have succeeded, but failed with: {:?}",
318+
id, error
319+
);
320+
});
321+
ServiceName::new(&id).unwrap_or_else(|error| {
322+
panic!(
323+
"ServiceName::new for {} should have succeeded, but failed with: {:?}",
324+
id, error
325+
);
326+
});
327+
PresetName::new(&id).unwrap_or_else(|error| {
328+
panic!(
329+
"PresetName::new for {} should have succeeded, but failed with: {:?}",
330+
id, error
331+
);
332+
});
333+
}
334+
335+
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
336+
struct AllIdentifiers {
337+
config: ConfigIdent,
338+
package: PackageName,
339+
service: ServiceName,
340+
preset: PresetName,
341+
}
342+
343+
#[proptest]
344+
fn valid_identifiers_proptest_serde(#[strategy(IDENT_REGEX)] id: String) {
345+
let all = AllIdentifiers {
346+
config: ConfigIdent::new(&id).unwrap(),
347+
package: PackageName::new(&id).unwrap(),
348+
service: ServiceName::new(&id).unwrap(),
349+
preset: PresetName::new(&id).unwrap(),
350+
};
351+
352+
let json = serde_json::to_value(&all).unwrap();
353+
let deserialized: AllIdentifiers = serde_json::from_value(json).unwrap();
354+
assert_eq!(all, deserialized);
355+
}
356+
}

0 commit comments

Comments
 (0)