|
| 1 | +//! Validation functions for GitLab Trusted Publishing configuration fields. |
| 2 | +//! |
| 3 | +//! This module performs basic validation of user input for GitLab CI/CD trusted publishing |
| 4 | +//! configurations. The validation rules are intentionally permissive: they accept all valid |
| 5 | +//! GitLab values while rejecting obviously invalid input. This approach is enough for our |
| 6 | +//! purposes since GitLab's JWT claims will only contain valid values anyway. |
| 7 | +//! |
| 8 | +//! See <https://docs.gitlab.com/user/reserved_names/#rules-for-usernames-project-and-group-names-and-slugs> |
| 9 | +//! and <https://docs.gitlab.com/ci/yaml/#environment>. |
| 10 | +
|
| 11 | +use std::sync::LazyLock; |
| 12 | + |
| 13 | +const MAX_FIELD_LENGTH: usize = 255; |
| 14 | + |
| 15 | +#[derive(Debug, thiserror::Error)] |
| 16 | +pub enum ValidationError { |
| 17 | + #[error("GitLab namespace may not be empty")] |
| 18 | + NamespaceEmpty, |
| 19 | + #[error("GitLab namespace is too long (maximum is {MAX_FIELD_LENGTH} characters)")] |
| 20 | + NamespaceTooLong, |
| 21 | + #[error("Invalid GitLab namespace")] |
| 22 | + NamespaceInvalid, |
| 23 | + #[error("GitLab namespace cannot end with .atom or .git")] |
| 24 | + NamespaceInvalidSuffix, |
| 25 | + |
| 26 | + #[error("GitLab project name may not be empty")] |
| 27 | + ProjectEmpty, |
| 28 | + #[error("GitLab project name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] |
| 29 | + ProjectTooLong, |
| 30 | + #[error("Invalid GitLab project name")] |
| 31 | + ProjectInvalid, |
| 32 | + #[error("GitLab project name cannot end with .atom or .git")] |
| 33 | + ProjectInvalidSuffix, |
| 34 | + |
| 35 | + #[error("Workflow filepath may not be empty")] |
| 36 | + WorkflowFilepathEmpty, |
| 37 | + #[error("Workflow filepath is too long (maximum is {MAX_FIELD_LENGTH} characters)")] |
| 38 | + WorkflowFilepathTooLong, |
| 39 | + #[error("Workflow filepath must end with `.yml` or `.yaml`")] |
| 40 | + WorkflowFilepathMissingSuffix, |
| 41 | + #[error("Workflow filepath cannot start with /")] |
| 42 | + WorkflowFilepathStartsWithSlash, |
| 43 | + #[error("Workflow filepath cannot end with /")] |
| 44 | + WorkflowFilepathEndsWithSlash, |
| 45 | + |
| 46 | + #[error("Environment name may not be empty (use `null` to omit)")] |
| 47 | + EnvironmentEmptyString, |
| 48 | + #[error("Environment name is too long (maximum is {MAX_FIELD_LENGTH} characters)")] |
| 49 | + EnvironmentTooLong, |
| 50 | + #[error("Environment name contains invalid characters")] |
| 51 | + EnvironmentInvalidChars, |
| 52 | +} |
| 53 | + |
| 54 | +pub fn validate_namespace(namespace: &str) -> Result<(), ValidationError> { |
| 55 | + static RE_VALID_NAMESPACE: LazyLock<regex::Regex> = LazyLock::new(|| { |
| 56 | + regex::Regex::new(r"^[a-zA-Z0-9](?:[a-zA-Z0-9_.\-/]*[a-zA-Z0-9])?$").unwrap() |
| 57 | + }); |
| 58 | + |
| 59 | + if namespace.is_empty() { |
| 60 | + Err(ValidationError::NamespaceEmpty) |
| 61 | + } else if namespace.len() > MAX_FIELD_LENGTH { |
| 62 | + Err(ValidationError::NamespaceTooLong) |
| 63 | + } else if namespace.ends_with(".atom") || namespace.ends_with(".git") { |
| 64 | + Err(ValidationError::NamespaceInvalidSuffix) |
| 65 | + } else if !RE_VALID_NAMESPACE.is_match(namespace) { |
| 66 | + Err(ValidationError::NamespaceInvalid) |
| 67 | + } else { |
| 68 | + Ok(()) |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +pub fn validate_project(project: &str) -> Result<(), ValidationError> { |
| 73 | + static RE_VALID_PROJECT: LazyLock<regex::Regex> = LazyLock::new(|| { |
| 74 | + regex::Regex::new(r"^[a-zA-Z0-9](?:[a-zA-Z0-9_.\-]*[a-zA-Z0-9])?$").unwrap() |
| 75 | + }); |
| 76 | + |
| 77 | + if project.is_empty() { |
| 78 | + Err(ValidationError::ProjectEmpty) |
| 79 | + } else if project.len() > MAX_FIELD_LENGTH { |
| 80 | + Err(ValidationError::ProjectTooLong) |
| 81 | + } else if project.ends_with(".atom") || project.ends_with(".git") { |
| 82 | + Err(ValidationError::ProjectInvalidSuffix) |
| 83 | + } else if !RE_VALID_PROJECT.is_match(project) { |
| 84 | + Err(ValidationError::ProjectInvalid) |
| 85 | + } else { |
| 86 | + Ok(()) |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +pub fn validate_workflow_filepath(filepath: &str) -> Result<(), ValidationError> { |
| 91 | + if filepath.is_empty() { |
| 92 | + Err(ValidationError::WorkflowFilepathEmpty) |
| 93 | + } else if filepath.len() > MAX_FIELD_LENGTH { |
| 94 | + Err(ValidationError::WorkflowFilepathTooLong) |
| 95 | + } else if filepath.starts_with('/') { |
| 96 | + Err(ValidationError::WorkflowFilepathStartsWithSlash) |
| 97 | + } else if filepath.ends_with('/') { |
| 98 | + Err(ValidationError::WorkflowFilepathEndsWithSlash) |
| 99 | + } else if !filepath.ends_with(".yml") && !filepath.ends_with(".yaml") { |
| 100 | + Err(ValidationError::WorkflowFilepathMissingSuffix) |
| 101 | + } else { |
| 102 | + Ok(()) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +pub fn validate_environment(env: &str) -> Result<(), ValidationError> { |
| 107 | + // see https://docs.gitlab.com/ci/yaml/#environment |
| 108 | + |
| 109 | + static RE_VALID_ENVIRONMENT: LazyLock<regex::Regex> = |
| 110 | + LazyLock::new(|| regex::Regex::new(r"^[a-zA-Z0-9 \-_/${}]+$").unwrap()); |
| 111 | + |
| 112 | + if env.is_empty() { |
| 113 | + Err(ValidationError::EnvironmentEmptyString) |
| 114 | + } else if env.len() > MAX_FIELD_LENGTH { |
| 115 | + Err(ValidationError::EnvironmentTooLong) |
| 116 | + } else if !RE_VALID_ENVIRONMENT.is_match(env) { |
| 117 | + Err(ValidationError::EnvironmentInvalidChars) |
| 118 | + } else { |
| 119 | + Ok(()) |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +#[cfg(test)] |
| 124 | +mod tests { |
| 125 | + use super::*; |
| 126 | + use claims::{assert_err, assert_ok}; |
| 127 | + use insta::assert_snapshot; |
| 128 | + |
| 129 | + #[test] |
| 130 | + fn test_validate_namespace() { |
| 131 | + assert_snapshot!(assert_err!(validate_namespace("")), @"GitLab namespace may not be empty"); |
| 132 | + assert_snapshot!(assert_err!(validate_namespace(&"x".repeat(256))), @"GitLab namespace is too long (maximum is 255 characters)"); |
| 133 | + assert_snapshot!(assert_err!(validate_namespace("-")), @"Invalid GitLab namespace"); |
| 134 | + assert_snapshot!(assert_err!(validate_namespace("_")), @"Invalid GitLab namespace"); |
| 135 | + assert_snapshot!(assert_err!(validate_namespace("-start")), @"Invalid GitLab namespace"); |
| 136 | + assert_snapshot!(assert_err!(validate_namespace("end-")), @"Invalid GitLab namespace"); |
| 137 | + assert_snapshot!(assert_err!(validate_namespace("invalid@chars")), @"Invalid GitLab namespace"); |
| 138 | + assert_snapshot!(assert_err!(validate_namespace("foo+bar")), @"Invalid GitLab namespace"); |
| 139 | + assert_snapshot!(assert_err!(validate_namespace("foo.atom")), @"GitLab namespace cannot end with .atom or .git"); |
| 140 | + assert_snapshot!(assert_err!(validate_namespace("foo.git")), @"GitLab namespace cannot end with .atom or .git"); |
| 141 | + |
| 142 | + assert_ok!(validate_namespace("a")); |
| 143 | + assert_ok!(validate_namespace("foo")); |
| 144 | + assert_ok!(validate_namespace("foo-bar")); |
| 145 | + assert_ok!(validate_namespace("foo_bar")); |
| 146 | + assert_ok!(validate_namespace("foo.bar")); |
| 147 | + assert_ok!(validate_namespace("foo/bar")); |
| 148 | + assert_ok!(validate_namespace("foo/bar/baz")); |
| 149 | + } |
| 150 | + |
| 151 | + #[test] |
| 152 | + fn test_validate_project() { |
| 153 | + assert_snapshot!(assert_err!(validate_project("")), @"GitLab project name may not be empty"); |
| 154 | + assert_snapshot!(assert_err!(validate_project(&"x".repeat(256))), @"GitLab project name is too long (maximum is 255 characters)"); |
| 155 | + assert_snapshot!(assert_err!(validate_project("-")), @"Invalid GitLab project name"); |
| 156 | + assert_snapshot!(assert_err!(validate_project("_")), @"Invalid GitLab project name"); |
| 157 | + assert_snapshot!(assert_err!(validate_project("-start")), @"Invalid GitLab project name"); |
| 158 | + assert_snapshot!(assert_err!(validate_project("end-")), @"Invalid GitLab project name"); |
| 159 | + assert_snapshot!(assert_err!(validate_project("invalid/chars")), @"Invalid GitLab project name"); |
| 160 | + assert_snapshot!(assert_err!(validate_project("foo.atom")), @"GitLab project name cannot end with .atom or .git"); |
| 161 | + assert_snapshot!(assert_err!(validate_project("foo.git")), @"GitLab project name cannot end with .atom or .git"); |
| 162 | + |
| 163 | + assert_ok!(validate_project("a")); |
| 164 | + assert_ok!(validate_project("foo")); |
| 165 | + assert_ok!(validate_project("foo-bar")); |
| 166 | + assert_ok!(validate_project("foo_bar")); |
| 167 | + assert_ok!(validate_project("foo.bar")); |
| 168 | + } |
| 169 | + |
| 170 | + #[test] |
| 171 | + fn test_validate_workflow_filepath() { |
| 172 | + assert_snapshot!(assert_err!(validate_workflow_filepath("")), @"Workflow filepath may not be empty"); |
| 173 | + assert_snapshot!(assert_err!(validate_workflow_filepath(&"x".repeat(256))), @"Workflow filepath is too long (maximum is 255 characters)"); |
| 174 | + assert_snapshot!(assert_err!(validate_workflow_filepath("/starts-with-slash.yml")), @"Workflow filepath cannot start with /"); |
| 175 | + assert_snapshot!(assert_err!(validate_workflow_filepath("ends-with-slash/")), @"Workflow filepath cannot end with /"); |
| 176 | + assert_snapshot!(assert_err!(validate_workflow_filepath("no-suffix")), @"Workflow filepath must end with `.yml` or `.yaml`"); |
| 177 | + |
| 178 | + assert_ok!(validate_workflow_filepath(".gitlab-ci.yml")); |
| 179 | + assert_ok!(validate_workflow_filepath(".gitlab-ci.yaml")); |
| 180 | + assert_ok!(validate_workflow_filepath("publish.yml")); |
| 181 | + assert_ok!(validate_workflow_filepath(".gitlab/ci/publish.yml")); |
| 182 | + assert_ok!(validate_workflow_filepath("ci/publish.yaml")); |
| 183 | + } |
| 184 | + |
| 185 | + #[test] |
| 186 | + fn test_validate_environment() { |
| 187 | + assert_snapshot!(assert_err!(validate_environment("")), @"Environment name may not be empty (use `null` to omit)"); |
| 188 | + assert_snapshot!(assert_err!(validate_environment(&"x".repeat(256))), @"Environment name is too long (maximum is 255 characters)"); |
| 189 | + assert_snapshot!(assert_err!(validate_environment("invalid@chars")), @"Environment name contains invalid characters"); |
| 190 | + assert_snapshot!(assert_err!(validate_environment("invalid.dot")), @"Environment name contains invalid characters"); |
| 191 | + |
| 192 | + assert_ok!(validate_environment("production")); |
| 193 | + assert_ok!(validate_environment("staging")); |
| 194 | + assert_ok!(validate_environment("prod-us-east")); |
| 195 | + assert_ok!(validate_environment("env_name")); |
| 196 | + assert_ok!(validate_environment("path/to/env")); |
| 197 | + assert_ok!(validate_environment("with space")); |
| 198 | + } |
| 199 | +} |
0 commit comments