-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathversion.rs
More file actions
254 lines (219 loc) · 7.7 KB
/
version.rs
File metadata and controls
254 lines (219 loc) · 7.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use std::{borrow::Cow, fmt, str::FromStr};
use daft::Diffable;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// An artifact version.
///
/// This is a freeform identifier with some basic validation. It may be the
/// serialized form of a semver version, or a custom identifier that uses the
/// same character set as a semver, plus `_`.
///
/// The exact pattern accepted is `^[a-zA-Z0-9._+-]{1,63}$`.
///
/// # Ord implementation
///
/// `ArtifactVersion`s are not intended to be sorted, just compared for
/// equality. `ArtifactVersion` implements `Ord` only for storage within sorted
/// collections.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Diffable)]
#[cfg_attr(any(test, feature = "proptest"), derive(test_strategy::Arbitrary))]
#[cfg_attr(any(test, feature = "schemars"), derive(schemars::JsonSchema))]
#[cfg_attr(any(test, feature = "schemars"), schemars(regex = Self::REGEX))]
#[daft(leaf)]
pub struct ArtifactVersion(
#[cfg_attr(any(test, feature = "proptest"), strategy(PROPTEST_REGEX))]
#[cfg_attr(any(test, feature = "proptest"), map(Cow::Owned))]
#[cfg_attr(
any(test, feature = "schemars"),
schemars(regex = "Self::REGEX")
)]
Cow<'static, str>,
);
impl ArtifactVersion {
/// The maximum length of a version string.
///
/// This matches the length allowed in Omicron database storage.
pub const MAX_LEN: usize = 63;
/// A regular expression that matches a valid version string.
///
/// This is the set of characters allowed in a semver plus `_`, though
/// without any additional structure. We expect non-semver identifiers to
/// only use these characters as well.
//
// NOTE: if you update this, also update the documentation at the top of
// `ArtifactVersion`! The regex is inlined there so it shows up correctly in
// the JSON schema.
pub const REGEX: &str = r"^[a-zA-Z0-9._+-]{1,63}$";
/// Creates a new `ArtifactVersion` from a string.
pub fn new<S: Into<String>>(
version: S,
) -> Result<Self, ArtifactVersionError> {
let version = version.into();
validate_version(&version)?;
Ok(Self(Cow::Owned(version)))
}
/// Creates a new `ArtifactVersion` from a static string.
pub fn new_static(
version: &'static str,
) -> Result<Self, ArtifactVersionError> {
// Can't use `?` in const functions.
match validate_version(version) {
Ok(()) => Ok(Self(Cow::Borrowed(version))),
Err(err) => Err(err),
}
}
/// Creates a new `ArtifactVersion` at compile time, panicking if it is
/// invalid.
pub const fn new_const(s: &'static str) -> Self {
match validate_version(s) {
Ok(()) => Self(Cow::Borrowed(s)),
Err(err) => panic!("{}", err.as_static_str()),
}
}
/// Returns the version as a string.
pub fn as_str(&self) -> &str {
self.0.as_ref()
}
/// Consumes self, returning the version as a string.
pub fn into_inner(self) -> Cow<'static, str> {
self.0
}
}
impl FromStr for ArtifactVersion {
type Err = ArtifactVersionError;
#[inline]
fn from_str(version: &str) -> Result<Self, Self::Err> {
Self::new(version)
}
}
impl fmt::Display for ArtifactVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.as_str().fmt(f)
}
}
impl<'de> Deserialize<'de> for ArtifactVersion {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let version = String::deserialize(deserializer)?;
validate_version(&version).map_err(serde::de::Error::custom)?;
Ok(Self(Cow::Owned(version)))
}
}
impl Serialize for ArtifactVersion {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
const fn validate_version(version: &str) -> Result<(), ArtifactVersionError> {
let len = version.len();
if len == 0 {
return Err(ArtifactVersionError::Empty);
} else if len > ArtifactVersion::MAX_LEN {
return Err(ArtifactVersionError::TooLong { len });
}
// Check that the version string matches the regex.
let mut b = version.as_bytes();
while let [first, rest @ ..] = b {
if !first.is_ascii_alphanumeric()
&& !matches!(first, b'.' | b'_' | b'+' | b'-')
{
return Err(ArtifactVersionError::InvalidByte { b: *first });
}
b = rest;
}
Ok(())
}
// Proptest wants regexes without anchors, so drop the first and last
// character.
#[cfg(any(test, feature = "proptest"))]
static PROPTEST_REGEX: &str = {
let regex = ArtifactVersion::REGEX.as_bytes();
let [_, mid @ .., _] = regex else { unreachable!() };
let Ok(r) = std::str::from_utf8(mid) else { unreachable!() };
r
};
/// An error that occurred while creating an `ArtifactVersion`.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum ArtifactVersionError {
#[error("version is empty")]
Empty,
#[error(
"version is too long ({len} bytes, max {})",
ArtifactVersion::MAX_LEN
)]
TooLong { len: usize },
#[error(
"version contains invalid byte `{}` (allowed: {})",
b.escape_ascii(),
ArtifactVersion::REGEX
)]
InvalidByte { b: u8 },
}
impl ArtifactVersionError {
/// Returns the error as a static string.
pub const fn as_static_str(&self) -> &'static str {
match self {
ArtifactVersionError::Empty => "version is empty",
ArtifactVersionError::TooLong { .. } => {
"version is too long (max 63 bytes)"
}
ArtifactVersionError::InvalidByte { .. } => {
"version contains invalid character (allowed: [a-zA-Z0-9_.+-])"
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
use super::*;
use regex::Regex;
use schemars::schema_for;
use test_strategy::proptest;
#[test]
fn schema() {
let schema = schema_for!(ArtifactVersion);
expectorate::assert_contents(
"output/artifact_version_schema.json",
&serde_json::to_string_pretty(&schema).unwrap(),
);
}
#[test]
fn display_respects_padding() {
let v = ArtifactVersion::new_const("5.4.3");
assert_eq!(format!("{v:x>10}"), "xxxxx5.4.3");
}
#[proptest]
fn proptest_valid_version(#[strategy(PROPTEST_REGEX)] version: String) {
validate_version(&version).unwrap();
}
#[proptest]
fn proptest_version_serde_roundtrip(version: ArtifactVersion) {
let json = serde_json::to_string(&version).unwrap();
// Try deserializing as a string -- this should always work (and ensures
// that version looks like a string in JSON).
serde_json::from_str::<String>(&json)
.expect("deserialized version as a string");
let deserialized = serde_json::from_str(&json).unwrap();
assert_eq!(version, deserialized);
}
#[proptest]
fn proptest_invalid_version(#[filter(is_invalid_regex)] version: String) {
validate_version(&version).unwrap_err();
}
// expect(clippy::ptr_arg) is because `filter` doesn't accept &str, just &String.
fn is_invalid_regex(#[expect(clippy::ptr_arg)] version: &String) -> bool {
static REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(ArtifactVersion::REGEX).unwrap());
!REGEX.is_match(version)
}
}