seaplane_cli/cli/
validator.rs

1//! Utility functions used to validate various argument types on the CLI. The arguments and return
2//! type are what the `clap::Arg::validator` expects.
3
4use std::{path::Path, result::Result as StdResult};
5
6use crate::{
7    context::{FlightCtx, DEFAULT_IMAGE_REGISTRY_URL},
8    error::CliErrorKind,
9    ops::formation::Endpoint,
10};
11
12pub fn validate_u64(s: &str) -> StdResult<u64, String> {
13    match s.parse::<u64>() {
14        Err(_) => Err("value must be a valid 64-bit integer".into()),
15        Ok(n) => Ok(n),
16    }
17}
18
19/// Ensures a valid Endpoint
20pub fn validate_endpoint(s: &str) -> StdResult<Endpoint, String> {
21    match s.parse::<Endpoint>() {
22        Err(e) => Err(format!("invalid endpoint SPEC: {e}")),
23        Ok(ep) => Ok(ep),
24    }
25}
26
27/// Ensures a valid Public Endpoint, we must special case Public Endpoints because it only supports
28/// the 'http' and 'https' protocol field.
29pub fn validate_public_endpoint(s: &str) -> StdResult<Endpoint, String> {
30    match s.parse::<Endpoint>() {
31        Ok(ep) => Ok(ep),
32        Err(mut details) => {
33            if details.starts_with("invalid protocol") {
34                details = "invalid protocol (valid options: http, https)".into();
35            }
36            Err(format!("invalid endpoint SPEC: {details}"))
37        }
38    }
39}
40
41/// The arg can be any of:
42///
43/// - name
44/// - Local ID (32byte hex encoded string)
45/// - @- (means STDIN)
46/// - @path
47pub fn validate_name_id_path<F>(name_validator: F, s: &str) -> StdResult<String, String>
48where
49    F: Fn(&str) -> StdResult<String, &'static str>,
50{
51    name_validator(s)
52        .or_else(|_| validate_id(s))
53        .or_else(|_| validate_at_path(s))
54        .or_else(|_| validate_at_stdin(s))
55        .map_err(|_| {
56            "the value must be a NAME|ID|@PATH|@- where @PATH is a valid path or @- opens STDIN"
57                .to_owned()
58        })
59}
60
61/// The arg can be any of:
62///
63/// - name
64/// - Local ID (32byte hex encoded string)
65/// - @- (means STDIN)
66/// - @path
67/// - INLINE-SPEC for Flights
68pub fn validate_name_id_path_inline(s: &str) -> StdResult<String, String> {
69    validate_flight_name(s)
70        .or_else(|_| validate_id(s))
71        .or_else(|_| validate_at_path(s))
72        .or_else(|_| validate_at_stdin(s))
73        .or_else(|_| validate_inline_flight_spec(s))
74        .map_err(|s| {
75            format!("the value must be a NAME|ID|@PATH|@-|INLINE-SPEC where @PATH is a valid path or @- opens STDIN\n\ncaused by: {s}")
76        })
77}
78
79/// The arg can be any of:
80///
81/// - name
82/// - Local ID (32byte hex encoded string)
83pub fn validate_name_id<F>(name_validator: F, s: &str) -> StdResult<String, &'static str>
84where
85    F: Fn(&str) -> StdResult<String, &'static str>,
86{
87    name_validator(s).or_else(|_| validate_id(s))
88}
89
90/// Rules:
91///
92/// - 1-63 alphanumeric characters (only ASCII lowercase) and hyphens ( 0-9, a-z, A-Z, and '-' )
93/// (aka, one DNS segment)
94pub fn validate_flight_name(name: &str) -> StdResult<String, &'static str> {
95    if name.is_empty() {
96        return Err("Flight name cannot be empty");
97    }
98    if name.len() > 63 {
99        return Err("Flight name too long, must be <= 63 in length");
100    }
101    if !name
102        .chars()
103        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
104    {
105        return Err(
106            "illegal character in Flight name; must only contain ASCII lowercase, digit, or hyphen ('-')",
107        );
108    }
109    if name.contains("--") {
110        return Err("repeated hyphens ('--') not allowed in Flight name");
111    }
112
113    Ok(name.into())
114}
115
116/// Current Rules:
117///
118///  - 1-30 alphanumeric (only ASCII lowercase) characters or hyphen (0-9, a-z, A-Z, and '-' )
119///  - hyphens ('-') may not be repeated (i.e. '--')
120///  - no more than three (3) total hyphens
121///  - no consecutive hyphens
122///  - no trailing hyphen
123pub fn validate_formation_name(name: &str) -> StdResult<String, &'static str> {
124    if name.is_empty() {
125        return Err("Formation name cannot be empty");
126    }
127    if name.len() > 30 {
128        return Err("Formation name too long, must be <= 30 in length");
129    }
130    if !name
131        .chars()
132        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
133    {
134        return Err(
135            "illegal character in Formation name; must only contain ASCII lowercase, digit, or hyphen ('-')",
136        );
137    }
138    if name.chars().filter(|c| *c == '-').count() > 3 {
139        return Err("no more than three hyphens ('-') allowed in Formation name");
140    }
141    if name.contains("--") {
142        return Err("repeated hyphens ('--') not allowed in Formation name");
143    }
144    if name.ends_with('-') {
145        return Err("Formation names may not end with a hyphen ('-')");
146    }
147
148    Ok(name.into())
149}
150
151/// The value may be `@path` where `path` is some path that exists
152pub fn validate_at_path(s: &str) -> StdResult<String, String> {
153    if let Some(path) = s.strip_prefix('@') {
154        if !Path::exists(path.as_ref()) {
155            #[cfg(not(any(feature = "semantic_ui_tests", feature = "ui_tests")))]
156            return Err(format!("path '{path}' does not exist"));
157        }
158    } else {
159        return Err("the '@<path>'  was not provided".to_owned());
160    }
161
162    Ok(s.into())
163}
164
165/// The value may be `@-`
166pub fn validate_at_stdin(s: &str) -> StdResult<String, &'static str> {
167    if s != "@-" {
168        return Err("the value '@-' was not provided");
169    }
170
171    Ok(s.into())
172}
173
174/// The value may be:
175///
176/// name=NAME,image=IMG,maximum=NUM,minimum=NUM,api-permission[=true|false],architecture=ARCH
177///
178/// where only image is required, and architecture can be passed multiple times.
179pub fn validate_inline_flight_spec(s: &str) -> StdResult<String, String> {
180    // We use the default image registry URL regardless of what the user has set because we're only
181    // checking validity, not actually using this data
182    if let Err(e) = FlightCtx::from_inline_flight(s, DEFAULT_IMAGE_REGISTRY_URL) {
183        return Err(format!("invalid INLINE-SPEC: {}", {
184            match e.kind() {
185                CliErrorKind::InlineFlightUnknownItem(s) => format!("unknown item {s}"),
186                CliErrorKind::InlineFlightMissingValue(s) => {
187                    format!("key '{s}' is missing the value")
188                }
189                CliErrorKind::InlineFlightHasSpace => {
190                    String::from("inline flight contains a space (' ')")
191                }
192                CliErrorKind::InlineFlightMissingImage => {
193                    String::from("Missing required image=IMAGE-SPEC")
194                }
195                CliErrorKind::InlineFlightInvalidName(s) => {
196                    format!("Flight Plan name '{s}' isn't valid")
197                }
198                CliErrorKind::ParseInt(e) => format!("failed to parse minimum or maximum: {e}"),
199                CliErrorKind::StrumParse(e) => format!("failed to parse architecture: {e}"),
200                CliErrorKind::ImageReference(e) => format!("invalid IMAGE-SPEC: {e}"),
201                _ => unreachable!(),
202            }
203        }));
204    }
205    Ok(s.into())
206}
207
208/// The value may be a *up to* a 32byte hex encoded string
209pub fn validate_id(s: &str) -> StdResult<String, &'static str> {
210    if s.is_empty() {
211        return Err("ID cannot be empty");
212    }
213    if !s.chars().all(is_hex_char) {
214        return Err("found non-hex character");
215    }
216    if s.chars().count() > 64 {
217        return Err("ID provided is too long");
218    }
219
220    Ok(s.into())
221}
222
223fn is_hex_char(c: char) -> bool { matches!(c, 'a'..='f' | 'A'..='F' | '0'..='9') }
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn is_hex_char_valid() {
231        for c in ('a'..'g').chain(('A'..'G').chain('0'..='9')) {
232            assert!(is_hex_char(c));
233        }
234    }
235
236    #[test]
237    fn is_hex_char_invalid() {
238        for c in ('g'..='z').chain('G'..'Z') {
239            assert!(!is_hex_char(c));
240        }
241    }
242
243    #[test]
244    fn invalid_flight_names() {
245        assert!(validate_flight_name("").is_err());
246        assert!(validate_flight_name("no-special-chars!").is_err());
247        assert!(validate_flight_name("imwaaaaaytoolongforanythingthatshouldbeanameimwaaaaaytoolongforanythingthatshouldbeaname").is_err());
248        assert!(validate_flight_name("noUperCase").is_err());
249    }
250
251    #[test]
252    fn invalid_formation_names() {
253        assert!(validate_formation_name("").is_err());
254        assert!(validate_formation_name("no-special-chars!").is_err());
255        assert!(validate_formation_name("imwaaaaaytoolongforanythingthatshouldbeaname").is_err());
256        assert!(validate_formation_name("too-many-hyphens-in-here").is_err());
257        assert!(validate_formation_name("no-ending-hyphen-").is_err());
258        assert!(validate_formation_name("noUperCase").is_err());
259    }
260
261    #[test]
262    fn invalid_id() {
263        assert!(validate_id("").is_err());
264        assert!(validate_id("imnotahexvalue").is_err());
265        // Too long
266        assert!(validate_id("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890").is_err());
267    }
268
269    #[test]
270    fn invalid_at_path() {
271        assert!(validate_id("").is_err());
272        assert!(validate_id("@").is_err());
273        assert!(validate_id("@-").is_err());
274        assert!(validate_id("@foo").is_err());
275        assert!(validate_id("foo").is_err());
276    }
277
278    #[test]
279    fn invalid_at_stdin() {
280        assert!(validate_id("").is_err());
281        assert!(validate_id("@").is_err());
282        assert!(validate_id("@foo").is_err());
283        assert!(validate_id("foo").is_err());
284    }
285}