1use std::collections::HashMap;
11
12use serde::{Deserialize, Serialize};
13
14use super::{input_default, input_number, input_validation, invalid, InputValue};
15use crate::error::Result;
16use crate::{
17 Platform, StackInputCondition, StackInputDefinition, StackInputEnvironmentMapping,
18 StackInputEnvironmentVariableType, StackInputGenerate, StackInputKind, StackInputProvider,
19 StackInputValidation, STACK_INPUT_GENERATE_MAX_LENGTH, STACK_INPUT_GENERATE_MIN_LENGTH,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct AuthoredEnvMapping {
27 pub name: String,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub target_resources: Option<Vec<String>>,
32 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
34 pub var_type: Option<StackInputEnvironmentVariableType>,
35}
36
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct AuthoredInput {
42 pub kind: StackInputKind,
44 #[serde(default = "default_provided_by")]
46 pub provided_by: Vec<StackInputProvider>,
47 #[serde(default = "default_required")]
49 pub required: bool,
50 pub label: String,
52 #[serde(default)]
54 pub description: String,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub placeholder: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub default: Option<InputValue>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub when: Option<StackInputCondition>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub platforms: Option<Vec<Platform>>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub env: Vec<AuthoredEnvMapping>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub generate: Option<StackInputGenerate>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub min_length: Option<u32>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub max_length: Option<u32>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub pattern: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub format: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub min: Option<InputValue>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub max: Option<InputValue>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub values: Option<Vec<String>>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub min_items: Option<u32>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub max_items: Option<u32>,
100}
101
102fn default_provided_by() -> Vec<StackInputProvider> {
103 vec![StackInputProvider::Developer]
104}
105
106fn default_required() -> bool {
107 true
108}
109
110const TEXT: &[StackInputKind] = &[StackInputKind::String, StackInputKind::Secret];
111const NUMERIC: &[StackInputKind] = &[StackInputKind::Number, StackInputKind::Integer];
112
113pub fn normalize_inputs(
117 inputs: impl IntoIterator<Item = (String, AuthoredInput)>,
118) -> Result<Vec<StackInputDefinition>> {
119 let definitions = inputs
120 .into_iter()
121 .map(|(id, input)| input.into_definition(&id))
122 .collect::<Result<Vec<_>>>()?;
123 validate_conditions(&definitions)?;
124 Ok(definitions)
125}
126
127pub fn is_input_env_name(name: &str) -> bool {
129 let mut chars = name.chars();
130 chars
131 .next()
132 .is_some_and(|first| first.is_ascii_uppercase() || first == '_')
133 && chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
134}
135
136pub fn validate_input_env_name(input_id: &str, name: &str) -> Result<()> {
138 if is_input_env_name(name) {
139 return Ok(());
140 }
141 Err(invalid(
142 &format!("input.{input_id}.env"),
143 format!("env name '{name}' must use uppercase letters, digits, and underscores"),
144 ))
145}
146
147impl AuthoredInput {
148 pub fn into_definition(self, id: &str) -> Result<StackInputDefinition> {
150 let field = |key: &str| format!("input.{id}.{key}");
151 validate_input_id(id)?;
152
153 if self.provided_by.is_empty() {
154 return Err(invalid(
155 &field("providedBy"),
156 "must include at least one provider",
157 ));
158 }
159 if self.label.trim().is_empty() {
160 return Err(invalid(&field("label"), "must include a label"));
161 }
162 if self.required && self.description.trim().is_empty() {
163 return Err(invalid(
164 &field("description"),
165 "must include a description when the input is required",
166 ));
167 }
168
169 let validation = self.validation(id)?;
170 validate_validation(id, &self.kind, &validation)?;
171
172 if let Some(generate) = &self.generate {
173 self.validate_generate(id, generate, &validation)?;
174 }
175
176 for mapping in &self.env {
177 validate_input_env_name(id, &mapping.name)?;
178 if mapping.target_resources.as_ref().is_some_and(Vec::is_empty) {
179 return Err(invalid(
180 &field("env"),
181 format!(
182 "env mapping '{}' cannot use an empty target-resources list",
183 mapping.name
184 ),
185 ));
186 }
187 }
188
189 let default = self
190 .default
191 .as_ref()
192 .map(|value| {
193 input_default(&self.kind, value).map_err(|error| relocate(error, &field("default")))
194 })
195 .transpose()?;
196
197 Ok(StackInputDefinition {
198 id: id.to_string(),
199 kind: self.kind.clone(),
200 provided_by: self.provided_by,
201 required: self.required,
202 label: self.label,
203 description: self.description,
204 placeholder: self.placeholder,
205 default,
206 platforms: self.platforms,
207 when: self.when,
208 validation: input_validation(&self.kind, validation),
209 generate: self.generate,
210 env: self
211 .env
212 .into_iter()
213 .map(|mapping| StackInputEnvironmentMapping {
214 name: mapping.name,
215 target_resources: mapping.target_resources,
216 var_type: mapping.var_type,
217 })
218 .collect(),
219 })
220 }
221
222 fn validation(&self, id: &str) -> Result<StackInputValidation> {
224 let only = |key: &str, present: bool, kinds: &[StackInputKind], what: &str| {
225 if present && !kinds.contains(&self.kind) {
226 return Err(invalid(
227 &format!("input.{id}.{key}"),
228 format!("applies to {what} inputs only"),
229 ));
230 }
231 Ok(())
232 };
233 only(
234 "minLength",
235 self.min_length.is_some(),
236 TEXT,
237 "string and secret",
238 )?;
239 only(
240 "maxLength",
241 self.max_length.is_some(),
242 TEXT,
243 "string and secret",
244 )?;
245 only("pattern", self.pattern.is_some(), TEXT, "string and secret")?;
246 only("format", self.format.is_some(), TEXT, "string and secret")?;
247 only("min", self.min.is_some(), NUMERIC, "number and integer")?;
248 only("max", self.max.is_some(), NUMERIC, "number and integer")?;
249 only(
250 "values",
251 self.values.is_some(),
252 &[StackInputKind::Enum],
253 "enum",
254 )?;
255 only(
256 "minItems",
257 self.min_items.is_some(),
258 &[StackInputKind::StringList],
259 "string-list",
260 )?;
261 only(
262 "maxItems",
263 self.max_items.is_some(),
264 &[StackInputKind::StringList],
265 "string-list",
266 )?;
267
268 let number = |key: &str, value: &Option<InputValue>| {
269 value
270 .as_ref()
271 .map(|value| {
272 input_number(key, &self.kind, value)
273 .map_err(|error| relocate(error, &format!("input.{id}.{key}")))
274 })
275 .transpose()
276 };
277 Ok(StackInputValidation {
278 min_length: self.min_length,
279 max_length: self.max_length,
280 pattern: self.pattern.clone(),
281 format: self.format.clone(),
282 min: number("min", &self.min)?,
283 max: number("max", &self.max)?,
284 values: self.values.clone(),
285 min_items: self.min_items,
286 max_items: self.max_items,
287 })
288 }
289
290 fn validate_generate(
291 &self,
292 id: &str,
293 generate: &StackInputGenerate,
294 validation: &StackInputValidation,
295 ) -> Result<()> {
296 let field = format!("input.{id}.generate");
297 if self.kind != StackInputKind::Secret {
298 return Err(invalid(&field, "is only supported on secret inputs"));
299 }
300 if self.default.is_some() {
301 return Err(invalid(&field, "cannot be combined with a default"));
302 }
303 if self.provided_by != [StackInputProvider::Developer] {
304 return Err(invalid(
305 &field,
306 "a generated input must be provided by the developer only; Alien supplies \
307 the value, so the deployer is never asked for it",
308 ));
309 }
310 let length = generate.length;
311 if !(STACK_INPUT_GENERATE_MIN_LENGTH..=STACK_INPUT_GENERATE_MAX_LENGTH).contains(&length) {
312 return Err(invalid(
313 &field,
314 format!(
315 "length must be an integer between {STACK_INPUT_GENERATE_MIN_LENGTH} and \
316 {STACK_INPUT_GENERATE_MAX_LENGTH}"
317 ),
318 ));
319 }
320 if validation.pattern.is_some() || validation.format.is_some() {
321 return Err(invalid(
322 &field,
323 "cannot be combined with pattern or format; generated values are alphanumeric",
324 ));
325 }
326 if validation.min_length.is_some_and(|min| length < min)
327 || validation.max_length.is_some_and(|max| length > max)
328 {
329 return Err(invalid(
330 &field,
331 format!("length {length} is outside the input's min-length and max-length"),
332 ));
333 }
334 Ok(())
335 }
336}
337
338fn relocate(
340 error: alien_error::AlienError<crate::ErrorData>,
341 field: &str,
342) -> alien_error::AlienError<crate::ErrorData> {
343 match error.error {
344 Some(crate::ErrorData::StackDefinitionInvalid { reason, .. }) => invalid(field, reason),
345 _ => error,
346 }
347}
348
349fn validate_input_id(id: &str) -> Result<()> {
350 let field = format!("input.{id}");
351 let mut chars = id.chars();
352 let valid = chars
353 .next()
354 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
355 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
356 if !valid {
357 return Err(invalid(
358 &field,
359 "the ID must start with a letter or underscore and contain only letters, digits, \
360 and underscores",
361 ));
362 }
363 if !id.chars().any(|c| c.is_ascii_lowercase()) {
364 return Err(invalid(
365 &field,
366 "the ID must not be all caps; that style is for environment variables",
367 ));
368 }
369 Ok(())
370}
371
372fn validate_validation(
373 id: &str,
374 kind: &StackInputKind,
375 validation: &StackInputValidation,
376) -> Result<()> {
377 let field = |key: &str| format!("input.{id}.{key}");
378 if let (Some(min), Some(max)) = (validation.min_length, validation.max_length) {
379 if min > max {
380 return Err(invalid(
381 &field("minLength"),
382 "must be less than or equal to max-length",
383 ));
384 }
385 }
386 if let (Some(min), Some(max)) = (validation.min_items, validation.max_items) {
387 if min > max {
388 return Err(invalid(
389 &field("minItems"),
390 "must be less than or equal to max-items",
391 ));
392 }
393 }
394 if *kind == StackInputKind::Enum
395 && validation
396 .values
397 .as_ref()
398 .is_none_or(|values| values.is_empty())
399 {
400 return Err(invalid(
401 &field("values"),
402 "an enum input needs at least one value",
403 ));
404 }
405 if let Some(pattern) = &validation.pattern {
406 validate_portable_pattern(&field("pattern"), pattern)?;
407 }
408 Ok(())
409}
410
411fn validate_portable_pattern(field: &str, pattern: &str) -> Result<()> {
412 if pattern.is_empty() {
413 return Err(invalid(field, "must not be empty"));
414 }
415 if pattern.len() > 1 && pattern.starts_with('/') && pattern.ends_with('/') {
416 return Err(invalid(field, "must not use regex delimiters"));
417 }
418 let bytes = pattern.as_bytes();
419 let backreference_or_property = bytes
420 .windows(2)
421 .any(|pair| pair[0] == b'\\' && matches!(pair[1], b'1'..=b'9' | b'p' | b'P' | b'k'));
422 let nested_class = pattern.match_indices('[').any(|(start, _)| {
423 pattern[start + 1..]
424 .chars()
425 .take_while(|c| *c != ']')
426 .any(|c| c == '[')
427 });
428 if backreference_or_property || pattern.contains("(?") || nested_class {
429 return Err(invalid(
430 field,
431 "is not portable across TypeScript, CloudFormation, and Terraform",
432 ));
433 }
434 Ok(())
435}
436
437fn validate_conditions(definitions: &[StackInputDefinition]) -> Result<()> {
440 let by_id: HashMap<&str, &StackInputDefinition> = definitions
441 .iter()
442 .map(|definition| (definition.id.as_str(), definition))
443 .collect();
444 for definition in definitions {
445 let Some(condition) = &definition.when else {
446 continue;
447 };
448 let reference = by_id.get(condition.input.as_str());
449 let reason = if condition.input == definition.id {
450 Some("cannot reference itself")
451 } else if reference.is_none() {
452 Some("references an unknown input")
453 } else if reference.is_some_and(|reference| reference.kind != StackInputKind::Boolean) {
454 Some("must reference a boolean input")
455 } else if reference.is_some_and(|reference| reference.when.is_some()) {
456 Some("must reference an unconditional input; chains and cycles are unsupported")
457 } else if definition.generate.is_some() {
458 Some("cannot be combined with generate")
459 } else if definition
460 .provided_by
461 .contains(&StackInputProvider::Deployer)
462 && reference.is_some_and(|reference| {
463 !reference
464 .provided_by
465 .contains(&StackInputProvider::Deployer)
466 })
467 {
468 Some("on a deployer input must reference a deployer-provided boolean")
469 } else {
470 None
471 };
472 if let Some(reason) = reason {
473 return Err(invalid(
474 &format!("input.{}.when", definition.id),
475 format!("'{}' {reason}", condition.input),
476 ));
477 }
478 }
479 Ok(())
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use crate::{ErrorData, StackInputDefaultValue};
486 use alien_error::AlienError;
487
488 fn input(kind: StackInputKind) -> AuthoredInput {
489 AuthoredInput {
490 kind,
491 provided_by: vec![StackInputProvider::Deployer],
492 required: true,
493 label: "Input".to_string(),
494 description: "Input value".to_string(),
495 placeholder: None,
496 default: None,
497 when: None,
498 platforms: None,
499 env: Vec::new(),
500 generate: None,
501 min_length: None,
502 max_length: None,
503 pattern: None,
504 format: None,
505 min: None,
506 max: None,
507 values: None,
508 min_items: None,
509 max_items: None,
510 }
511 }
512
513 fn flag(provided_by: StackInputProvider) -> AuthoredInput {
514 AuthoredInput {
515 provided_by: vec![provided_by],
516 ..input(StackInputKind::Boolean)
517 }
518 }
519
520 fn when(input: &str) -> Option<StackInputCondition> {
521 Some(StackInputCondition {
522 input: input.to_string(),
523 equals: true,
524 })
525 }
526
527 fn normalize(
528 inputs: Vec<(&str, AuthoredInput)>,
529 ) -> std::result::Result<Vec<StackInputDefinition>, AlienError<ErrorData>> {
530 normalize_inputs(
531 inputs
532 .into_iter()
533 .map(|(id, input)| (id.to_string(), input)),
534 )
535 }
536
537 fn rejected(inputs: Vec<(&str, AuthoredInput)>) -> (String, String) {
538 match normalize(inputs).expect_err("should be rejected").error {
539 Some(ErrorData::StackDefinitionInvalid { field, reason }) => (field, reason),
540 other => panic!("unexpected error {other:?}"),
541 }
542 }
543
544 #[test]
545 fn a_boolean_gates_a_string_and_keeps_its_condition() {
546 let definitions = normalize(vec![
547 ("enabled", flag(StackInputProvider::Deployer)),
548 (
549 "bucket",
550 AuthoredInput {
551 when: when("enabled"),
552 env: vec![AuthoredEnvMapping {
553 name: "BUCKET".to_string(),
554 target_resources: None,
555 var_type: None,
556 }],
557 ..input(StackInputKind::String)
558 },
559 ),
560 ])
561 .unwrap();
562 assert_eq!(definitions.len(), 2);
563 assert_eq!(definitions[0].id, "enabled");
564 assert_eq!(
565 definitions[0].validation, None,
566 "a boolean has no validation block"
567 );
568 assert_eq!(definitions[1].when, when("enabled"));
569 assert_eq!(
570 definitions[1].validation,
571 Some(StackInputValidation::default()),
572 "other kinds always carry one, even when empty"
573 );
574 assert_eq!(definitions[1].env[0].name, "BUCKET");
575 }
576
577 #[test]
578 fn a_developer_controller_is_rejected_for_a_deployer_input_only() {
579 let (field, reason) = rejected(vec![
580 ("enabled", flag(StackInputProvider::Developer)),
581 (
582 "bucket",
583 AuthoredInput {
584 when: when("enabled"),
585 ..input(StackInputKind::String)
586 },
587 ),
588 ]);
589 assert_eq!(field, "input.bucket.when");
590 assert!(reason.contains("deployer-provided"), "{reason}");
591
592 normalize(vec![
593 ("enabled", flag(StackInputProvider::Developer)),
594 (
595 "bucket",
596 AuthoredInput {
597 provided_by: vec![StackInputProvider::Developer],
598 when: when("enabled"),
599 ..input(StackInputKind::String)
600 },
601 ),
602 ])
603 .expect("a developer input may follow a developer boolean");
604 }
605
606 #[test]
607 fn a_condition_cannot_be_combined_with_generate() {
608 let (field, reason) = rejected(vec![
609 ("enabled", flag(StackInputProvider::Developer)),
610 (
611 "token",
612 AuthoredInput {
613 provided_by: vec![StackInputProvider::Developer],
614 when: when("enabled"),
615 generate: Some(StackInputGenerate { length: 64 }),
616 ..input(StackInputKind::Secret)
617 },
618 ),
619 ]);
620 assert_eq!(field, "input.token.when");
621 assert!(reason.contains("generate"), "{reason}");
622 }
623
624 #[test]
625 fn a_condition_must_reference_an_existing_other_input() {
626 for reference in ["missing", "bucket"] {
627 let (field, reason) = rejected(vec![(
628 "bucket",
629 AuthoredInput {
630 when: when(reference),
631 ..input(StackInputKind::String)
632 },
633 )]);
634 assert_eq!(field, "input.bucket.when");
635 assert!(
636 reason.contains("itself") || reason.contains("unknown"),
637 "{reason}"
638 );
639 }
640 }
641
642 #[test]
643 fn a_condition_must_reference_an_unconditional_boolean() {
644 let (_, reason) = rejected(vec![
645 ("enabled", input(StackInputKind::String)),
646 (
647 "bucket",
648 AuthoredInput {
649 when: when("enabled"),
650 ..input(StackInputKind::String)
651 },
652 ),
653 ]);
654 assert!(reason.contains("boolean"), "{reason}");
655
656 let (_, reason) = rejected(vec![
657 ("outer", flag(StackInputProvider::Deployer)),
658 (
659 "enabled",
660 AuthoredInput {
661 when: when("outer"),
662 ..flag(StackInputProvider::Deployer)
663 },
664 ),
665 (
666 "bucket",
667 AuthoredInput {
668 when: when("enabled"),
669 ..input(StackInputKind::String)
670 },
671 ),
672 ]);
673 assert!(reason.contains("unconditional"), "{reason}");
674 }
675
676 #[test]
677 fn ids_are_identifiers_with_a_lowercase_letter() {
678 for id in ["1st", "has-dash", "API_KEY", ""] {
679 let (field, _) = rejected(vec![(id, input(StackInputKind::String))]);
680 assert_eq!(field, format!("input.{id}"));
681 }
682 normalize(vec![
683 ("_x", input(StackInputKind::String)),
684 ("apiKey2", input(StackInputKind::String)),
685 ])
686 .unwrap();
687 }
688
689 #[test]
690 fn labels_descriptions_and_providers_are_required() {
691 let (field, _) = rejected(vec![(
692 "a",
693 AuthoredInput {
694 label: " ".to_string(),
695 ..input(StackInputKind::String)
696 },
697 )]);
698 assert_eq!(field, "input.a.label");
699 let (field, _) = rejected(vec![(
700 "a",
701 AuthoredInput {
702 description: String::new(),
703 ..input(StackInputKind::String)
704 },
705 )]);
706 assert_eq!(field, "input.a.description");
707 let optional = normalize(vec![(
708 "a",
709 AuthoredInput {
710 required: false,
711 description: String::new(),
712 ..input(StackInputKind::String)
713 },
714 )])
715 .unwrap();
716 assert_eq!(optional[0].description, "");
717 let (field, _) = rejected(vec![(
718 "a",
719 AuthoredInput {
720 provided_by: vec![],
721 ..input(StackInputKind::String)
722 },
723 )]);
724 assert_eq!(field, "input.a.providedBy");
725 }
726
727 #[test]
728 fn constraints_belong_to_their_kind() {
729 let cases: Vec<(&str, AuthoredInput)> = vec![
730 (
731 "input.a.min",
732 AuthoredInput {
733 min: Some(InputValue::Integer(1)),
734 ..input(StackInputKind::String)
735 },
736 ),
737 (
738 "input.a.minLength",
739 AuthoredInput {
740 min_length: Some(1),
741 ..input(StackInputKind::Integer)
742 },
743 ),
744 (
745 "input.a.values",
746 AuthoredInput {
747 values: Some(vec!["x".into()]),
748 ..input(StackInputKind::String)
749 },
750 ),
751 (
752 "input.a.maxItems",
753 AuthoredInput {
754 max_items: Some(2),
755 ..input(StackInputKind::String)
756 },
757 ),
758 (
759 "input.a.values",
760 AuthoredInput {
761 values: Some(vec![]),
762 ..input(StackInputKind::Enum)
763 },
764 ),
765 ("input.a.values", input(StackInputKind::Enum)),
766 (
767 "input.a.minLength",
768 AuthoredInput {
769 min_length: Some(5),
770 max_length: Some(4),
771 ..input(StackInputKind::String)
772 },
773 ),
774 (
775 "input.a.minItems",
776 AuthoredInput {
777 min_items: Some(3),
778 max_items: Some(2),
779 ..input(StackInputKind::StringList)
780 },
781 ),
782 ];
783 for (expected, case) in cases {
784 let (field, reason) = rejected(vec![("a", case)]);
785 assert_eq!(field, expected, "{reason}");
786 }
787 }
788
789 #[test]
790 fn numbers_print_the_way_javascript_prints_them() {
791 let definitions = normalize(vec![
792 (
793 "rate",
794 AuthoredInput {
795 min: Some(InputValue::Integer(0)),
796 max: Some(InputValue::Float(1.0)),
797 default: Some(InputValue::Float(0.25)),
798 ..input(StackInputKind::Number)
799 },
800 ),
801 (
802 "size",
803 AuthoredInput {
804 min: Some(InputValue::Float(1.0)),
805 default: Some(InputValue::Integer(100)),
806 ..input(StackInputKind::Integer)
807 },
808 ),
809 ])
810 .unwrap();
811 let rate = definitions[0].validation.as_ref().unwrap();
812 assert_eq!(rate.min.as_deref(), Some("0"));
813 assert_eq!(rate.max.as_deref(), Some("1"));
814 assert_eq!(
815 definitions[0].default,
816 Some(StackInputDefaultValue::Number("0.25".to_string()))
817 );
818 assert_eq!(
819 definitions[1].validation.as_ref().unwrap().min.as_deref(),
820 Some("1")
821 );
822
823 let (field, reason) = rejected(vec![(
824 "size",
825 AuthoredInput {
826 max: Some(InputValue::Float(1.5)),
827 ..input(StackInputKind::Integer)
828 },
829 )]);
830 assert_eq!(field, "input.size.max");
831 assert!(reason.contains("whole number"), "{reason}");
832 }
833
834 #[test]
835 fn defaults_are_typed_by_kind_and_secrets_have_none() {
836 let (field, _) = rejected(vec![(
837 "token",
838 AuthoredInput {
839 default: Some(InputValue::String("x".into())),
840 ..input(StackInputKind::Secret)
841 },
842 )]);
843 assert_eq!(field, "input.token.default");
844 let (field, reason) = rejected(vec![(
845 "flag",
846 AuthoredInput {
847 default: Some(InputValue::String("yes".into())),
848 ..input(StackInputKind::Boolean)
849 },
850 )]);
851 assert_eq!(field, "input.flag.default");
852 assert!(reason.contains("a boolean"), "{reason}");
853 }
854
855 #[test]
856 fn generated_secrets_follow_their_rules() {
857 let generated = || AuthoredInput {
858 provided_by: vec![StackInputProvider::Developer],
859 generate: Some(StackInputGenerate { length: 32 }),
860 ..input(StackInputKind::Secret)
861 };
862 let definitions = normalize(vec![("token", generated())]).unwrap();
863 assert_eq!(
864 definitions[0].generate,
865 Some(StackInputGenerate { length: 32 })
866 );
867
868 let cases: Vec<AuthoredInput> = vec![
869 AuthoredInput {
870 kind: StackInputKind::String,
871 ..generated()
872 },
873 AuthoredInput {
874 provided_by: vec![StackInputProvider::Developer, StackInputProvider::Deployer],
875 ..generated()
876 },
877 AuthoredInput {
878 generate: Some(StackInputGenerate { length: 15 }),
879 ..generated()
880 },
881 AuthoredInput {
882 generate: Some(StackInputGenerate { length: 257 }),
883 ..generated()
884 },
885 AuthoredInput {
886 pattern: Some("^[a-z]+$".into()),
887 ..generated()
888 },
889 AuthoredInput {
890 format: Some("url".into()),
891 ..generated()
892 },
893 AuthoredInput {
894 min_length: Some(33),
895 ..generated()
896 },
897 AuthoredInput {
898 max_length: Some(31),
899 ..generated()
900 },
901 ];
902 for case in cases {
903 let (field, reason) = rejected(vec![("token", case)]);
904 assert_eq!(field, "input.token.generate", "{reason}");
905 }
906 }
907
908 #[test]
909 fn env_names_and_targets_are_checked() {
910 let with_env = |env: Vec<AuthoredEnvMapping>| {
911 vec![(
912 "a",
913 AuthoredInput {
914 env,
915 ..input(StackInputKind::String)
916 },
917 )]
918 };
919 let mapping = |name: &str, targets: Option<Vec<String>>| AuthoredEnvMapping {
920 name: name.to_string(),
921 target_resources: targets,
922 var_type: None,
923 };
924 let (field, reason) = rejected(with_env(vec![mapping("lower", None)]));
925 assert_eq!(field, "input.a.env");
926 assert!(reason.contains("'lower'"), "{reason}");
927 let (field, _) = rejected(with_env(vec![mapping("OK", Some(vec![]))]));
928 assert_eq!(field, "input.a.env");
929 normalize(with_env(vec![
930 mapping("_OK_1", None),
931 mapping("ALSO", Some(vec!["work*".into()])),
932 ]))
933 .unwrap();
934 }
935
936 #[test]
937 fn patterns_must_be_portable() {
938 for pattern in ["", "/abc/", r"(a)\1", r"\p{L}", "(?i)a", "[a[b]]", r"\k<n>"] {
939 let (field, _) = rejected(vec![(
940 "a",
941 AuthoredInput {
942 pattern: Some(pattern.to_string()),
943 ..input(StackInputKind::String)
944 },
945 )]);
946 assert_eq!(field, "input.a.pattern", "{pattern}");
947 }
948 for pattern in ["^[a-z0-9-]+$", r"^\d{3}$", "/", "[a-z][0-9]"] {
949 normalize(vec![(
950 "a",
951 AuthoredInput {
952 pattern: Some(pattern.to_string()),
953 ..input(StackInputKind::String)
954 },
955 )])
956 .unwrap_or_else(|error| panic!("{pattern}: {error}"));
957 }
958 }
959
960 #[test]
961 fn an_input_deserializes_from_its_canonical_json() {
962 let input: AuthoredInput = serde_json::from_value(serde_json::json!({
963 "kind": "stringList",
964 "label": "Images",
965 "required": false,
966 "default": ["a", "b"],
967 "maxItems": 8,
968 "platforms": ["machines"],
969 "env": [{ "name": "IMAGES", "targetResources": ["w*"], "type": "plain" }]
970 }))
971 .unwrap();
972 assert_eq!(input.provided_by, [StackInputProvider::Developer]);
973 let definition = input.into_definition("images").unwrap();
974 assert_eq!(
975 definition.default,
976 Some(StackInputDefaultValue::StringList(vec![
977 "a".into(),
978 "b".into()
979 ]))
980 );
981 assert_eq!(definition.platforms, Some(vec![Platform::Machines]));
982
983 let error = serde_json::from_value::<AuthoredInput>(serde_json::json!({
984 "kind": "string", "label": "x", "minlength": 1
985 }))
986 .expect_err("unknown key");
987 assert!(error.to_string().contains("minlength"), "{error}");
988 }
989}