circus/crates/common/src/validate.rs
NotAShelf c306383d27
chore: format with updated rustfmt and taplo rules
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie9ef5fc421fa20071946cf1073f7920c6a6a6964
2026-02-05 22:45:06 +03:00

607 lines
15 KiB
Rust

//! Input validation helpers
use std::sync::LazyLock;
use regex::Regex;
/// Validate that a path is a valid nix store path.
/// Rejects path traversal, overly long paths, and non-store paths.
pub fn is_valid_store_path(path: &str) -> bool {
path.starts_with("/nix/store/") && !path.contains("..") && path.len() < 512
}
/// Validate that a string is a valid nix store hash (32 lowercase alphanumeric
/// chars).
pub fn is_valid_nix_hash(hash: &str) -> bool {
hash.len() == 32
&& hash
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
}
// --- Validation trait and helpers ---
static NAME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$").unwrap());
static COMMIT_HASH_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[0-9a-fA-F]{1,64}$").unwrap());
static SYSTEM_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\w+-\w+$").unwrap());
const VALID_REPO_PREFIXES: &[&str] =
&["https://", "http://", "git://", "ssh://"];
const VALID_FORGE_TYPES: &[&str] = &["github", "gitea", "forgejo", "gitlab"];
/// Trait for validating request DTOs before persisting.
pub trait Validate {
fn validate(&self) -> Result<(), String>;
}
fn validate_name(name: &str, field: &str) -> Result<(), String> {
if name.is_empty() || name.len() > 255 {
return Err(format!("{field} must be between 1 and 255 characters"));
}
if !NAME_RE.is_match(name) {
return Err(format!(
"{field} must start with alphanumeric and contain only [a-zA-Z0-9_-]"
));
}
Ok(())
}
fn validate_repository_url(url: &str) -> Result<(), String> {
if url.is_empty() {
return Err("repository_url cannot be empty".to_string());
}
if url.len() > 2048 {
return Err("repository_url must be at most 2048 characters".to_string());
}
if !VALID_REPO_PREFIXES.iter().any(|p| url.starts_with(p)) {
return Err(
"repository_url must start with https://, http://, git://, or ssh://"
.to_string(),
);
}
Ok(())
}
fn validate_description(desc: &str) -> Result<(), String> {
if desc.len() > 4096 {
return Err("description must be at most 4096 characters".to_string());
}
Ok(())
}
fn validate_nix_expression(expr: &str) -> Result<(), String> {
if expr.is_empty() {
return Err("nix_expression cannot be empty".to_string());
}
if expr.len() > 1024 {
return Err("nix_expression must be at most 1024 characters".to_string());
}
if expr.contains('\0') {
return Err("nix_expression must not contain null bytes".to_string());
}
Ok(())
}
fn validate_check_interval(interval: i32) -> Result<(), String> {
if !(10..=86400).contains(&interval) {
return Err("check_interval must be between 10 and 86400".to_string());
}
Ok(())
}
fn validate_commit_hash(hash: &str) -> Result<(), String> {
if !COMMIT_HASH_RE.is_match(hash) {
return Err("commit_hash must be 1-64 hex characters".to_string());
}
Ok(())
}
fn validate_drv_path(path: &str) -> Result<(), String> {
if !is_valid_store_path(path) {
return Err("drv_path must be a valid nix store path".to_string());
}
Ok(())
}
fn validate_system(system: &str) -> Result<(), String> {
if !SYSTEM_RE.is_match(system) {
return Err("system must match pattern like x86_64-linux".to_string());
}
Ok(())
}
fn validate_ssh_uri(uri: &str) -> Result<(), String> {
if uri.is_empty() {
return Err("ssh_uri cannot be empty".to_string());
}
if uri.len() > 2048 {
return Err("ssh_uri must be at most 2048 characters".to_string());
}
Ok(())
}
fn validate_positive_i32(val: i32, field: &str) -> Result<(), String> {
if val < 1 {
return Err(format!("{field} must be >= 1"));
}
Ok(())
}
fn validate_forge_type(forge_type: &str) -> Result<(), String> {
if !VALID_FORGE_TYPES.contains(&forge_type) {
return Err(format!(
"forge_type must be one of: {}",
VALID_FORGE_TYPES.join(", ")
));
}
Ok(())
}
// --- Implementations ---
use crate::models::*;
impl Validate for CreateProject {
fn validate(&self) -> Result<(), String> {
validate_name(&self.name, "name")?;
validate_repository_url(&self.repository_url)?;
if let Some(ref desc) = self.description {
validate_description(desc)?;
}
Ok(())
}
}
impl Validate for UpdateProject {
fn validate(&self) -> Result<(), String> {
if let Some(ref name) = self.name {
validate_name(name, "name")?;
}
if let Some(ref url) = self.repository_url {
validate_repository_url(url)?;
}
if let Some(ref desc) = self.description {
validate_description(desc)?;
}
Ok(())
}
}
impl Validate for CreateJobset {
fn validate(&self) -> Result<(), String> {
validate_name(&self.name, "name")?;
validate_nix_expression(&self.nix_expression)?;
if let Some(interval) = self.check_interval {
validate_check_interval(interval)?;
}
Ok(())
}
}
impl Validate for UpdateJobset {
fn validate(&self) -> Result<(), String> {
if let Some(ref name) = self.name {
validate_name(name, "name")?;
}
if let Some(ref expr) = self.nix_expression {
validate_nix_expression(expr)?;
}
if let Some(interval) = self.check_interval {
validate_check_interval(interval)?;
}
Ok(())
}
}
impl Validate for CreateEvaluation {
fn validate(&self) -> Result<(), String> {
validate_commit_hash(&self.commit_hash)?;
Ok(())
}
}
impl Validate for CreateBuild {
fn validate(&self) -> Result<(), String> {
validate_drv_path(&self.drv_path)?;
if let Some(ref system) = self.system {
validate_system(system)?;
}
Ok(())
}
}
impl Validate for CreateChannel {
fn validate(&self) -> Result<(), String> {
validate_name(&self.name, "name")?;
Ok(())
}
}
impl Validate for UpdateChannel {
fn validate(&self) -> Result<(), String> {
if let Some(ref name) = self.name {
validate_name(name, "name")?;
}
Ok(())
}
}
impl Validate for CreateRemoteBuilder {
fn validate(&self) -> Result<(), String> {
validate_name(&self.name, "name")?;
validate_ssh_uri(&self.ssh_uri)?;
if self.systems.is_empty() {
return Err("systems must not be empty".to_string());
}
for system in &self.systems {
validate_system(system)?;
}
if let Some(max_jobs) = self.max_jobs {
validate_positive_i32(max_jobs, "max_jobs")?;
}
if let Some(speed_factor) = self.speed_factor {
validate_positive_i32(speed_factor, "speed_factor")?;
}
Ok(())
}
}
impl Validate for UpdateRemoteBuilder {
fn validate(&self) -> Result<(), String> {
if let Some(ref name) = self.name {
validate_name(name, "name")?;
}
if let Some(ref uri) = self.ssh_uri {
validate_ssh_uri(uri)?;
}
if let Some(ref systems) = self.systems {
if systems.is_empty() {
return Err("systems must not be empty".to_string());
}
for system in systems {
validate_system(system)?;
}
}
if let Some(max_jobs) = self.max_jobs {
validate_positive_i32(max_jobs, "max_jobs")?;
}
if let Some(speed_factor) = self.speed_factor {
validate_positive_i32(speed_factor, "speed_factor")?;
}
Ok(())
}
}
impl Validate for CreateWebhookConfig {
fn validate(&self) -> Result<(), String> {
validate_forge_type(&self.forge_type)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
// --- is_valid_store_path ---
#[test]
fn valid_store_path() {
assert!(is_valid_store_path(
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-2.12"
));
}
#[test]
fn valid_store_path_nested() {
assert!(is_valid_store_path(
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello-2.12/bin/hello"
));
}
#[test]
fn store_path_rejects_path_traversal() {
assert!(!is_valid_store_path(
"/nix/store/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-hello/../../../etc/passwd"
));
}
#[test]
fn store_path_rejects_relative_path() {
assert!(!is_valid_store_path("nix/store/something"));
}
#[test]
fn store_path_rejects_wrong_prefix() {
assert!(!is_valid_store_path("/tmp/nix/store/something"));
assert!(!is_valid_store_path("/etc/passwd"));
assert!(!is_valid_store_path("/nix/var/something"));
}
#[test]
fn store_path_rejects_empty() {
assert!(!is_valid_store_path(""));
}
#[test]
fn store_path_rejects_just_prefix() {
// "/nix/store/" alone has no hash, but structurally starts_with and has no
// .., so it passes. This is fine — the DB lookup won't find anything
// for it.
assert!(is_valid_store_path("/nix/store/"));
}
#[test]
fn store_path_rejects_overly_long() {
let long_path = format!("/nix/store/{}", "a".repeat(512));
assert!(!is_valid_store_path(&long_path));
}
#[test]
fn store_path_rejects_double_dot_embedded() {
assert!(!is_valid_store_path("/nix/store/abc..def"));
}
// --- is_valid_nix_hash ---
#[test]
fn valid_nix_hash_lowercase_alpha() {
assert!(is_valid_nix_hash("abcdefghijklmnopqrstuvwxyzabcdef"));
}
#[test]
fn valid_nix_hash_digits() {
assert!(is_valid_nix_hash("01234567890123456789012345678901"));
}
#[test]
fn valid_nix_hash_mixed() {
assert!(is_valid_nix_hash("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"));
}
#[test]
fn nix_hash_rejects_uppercase() {
assert!(!is_valid_nix_hash("ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEF"));
}
#[test]
fn nix_hash_rejects_mixed_case() {
assert!(!is_valid_nix_hash("abcdefghijklmnopqrstuvwxyzAbcdeF"));
}
#[test]
fn nix_hash_rejects_too_short() {
assert!(!is_valid_nix_hash("abcdef1234567890"));
}
#[test]
fn nix_hash_rejects_too_long() {
assert!(!is_valid_nix_hash("abcdefghijklmnopqrstuvwxyzabcdefg"));
}
#[test]
fn nix_hash_rejects_empty() {
assert!(!is_valid_nix_hash(""));
}
#[test]
fn nix_hash_rejects_special_chars() {
assert!(!is_valid_nix_hash("abcdefghijklmnopqrstuvwxyz!@#$%^"));
}
#[test]
fn nix_hash_rejects_spaces() {
assert!(!is_valid_nix_hash("abcdefghijklmnop rstuvwxyzabcdef"));
}
#[test]
fn nix_hash_rejects_path_traversal_attempt() {
assert!(!is_valid_nix_hash("../../../../../../etc/passwd__"));
}
#[test]
fn nix_hash_rejects_sql_injection_attempt() {
assert!(!is_valid_nix_hash("' OR 1=1; DROP TABLE builds;--"));
}
// --- Validate trait tests ---
#[test]
fn test_create_project_valid() {
let p = CreateProject {
name: "my-project".to_string(),
description: Some("A test project".to_string()),
repository_url: "https://github.com/test/repo".to_string(),
};
assert!(p.validate().is_ok());
}
#[test]
fn test_create_project_invalid_name() {
let p = CreateProject {
name: "".to_string(),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
};
assert!(p.validate().is_err());
let p = CreateProject {
name: "-starts-with-dash".to_string(),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
};
assert!(p.validate().is_err());
let p = CreateProject {
name: "has spaces".to_string(),
description: None,
repository_url: "https://github.com/test/repo".to_string(),
};
assert!(p.validate().is_err());
}
#[test]
fn test_create_project_invalid_url() {
let p = CreateProject {
name: "valid-name".to_string(),
description: None,
repository_url: "ftp://example.com".to_string(),
};
assert!(p.validate().is_err());
}
#[test]
fn test_create_project_description_too_long() {
let p = CreateProject {
name: "valid-name".to_string(),
description: Some("a".repeat(4097)),
repository_url: "https://github.com/test/repo".to_string(),
};
assert!(p.validate().is_err());
}
#[test]
fn test_create_jobset_valid() {
let j = CreateJobset {
project_id: Uuid::new_v4(),
name: "main".to_string(),
nix_expression: "packages".to_string(),
enabled: None,
flake_mode: None,
check_interval: Some(300),
branch: None,
scheduling_shares: None,
};
assert!(j.validate().is_ok());
}
#[test]
fn test_create_jobset_interval_too_low() {
let j = CreateJobset {
project_id: Uuid::new_v4(),
name: "main".to_string(),
nix_expression: "packages".to_string(),
enabled: None,
flake_mode: None,
check_interval: Some(5),
branch: None,
scheduling_shares: None,
};
assert!(j.validate().is_err());
}
#[test]
fn test_create_evaluation_valid() {
let e = CreateEvaluation {
jobset_id: Uuid::new_v4(),
commit_hash: "abc123".to_string(),
};
assert!(e.validate().is_ok());
}
#[test]
fn test_create_evaluation_invalid_hash() {
let e = CreateEvaluation {
jobset_id: Uuid::new_v4(),
commit_hash: "not-hex!".to_string(),
};
assert!(e.validate().is_err());
}
#[test]
fn test_create_build_valid() {
let b = CreateBuild {
evaluation_id: Uuid::new_v4(),
job_name: "hello".to_string(),
drv_path: "/nix/store/abc123-hello.drv".to_string(),
system: Some("x86_64-linux".to_string()),
outputs: None,
is_aggregate: None,
constituents: None,
};
assert!(b.validate().is_ok());
}
#[test]
fn test_create_build_invalid_drv() {
let b = CreateBuild {
evaluation_id: Uuid::new_v4(),
job_name: "hello".to_string(),
drv_path: "/tmp/bad-path".to_string(),
system: None,
outputs: None,
is_aggregate: None,
constituents: None,
};
assert!(b.validate().is_err());
}
#[test]
fn test_create_remote_builder_valid() {
let rb = CreateRemoteBuilder {
name: "builder1".to_string(),
ssh_uri: "root@builder.example.com".to_string(),
systems: vec!["x86_64-linux".to_string()],
max_jobs: Some(4),
speed_factor: Some(1),
supported_features: None,
mandatory_features: None,
public_host_key: None,
ssh_key_file: None,
};
assert!(rb.validate().is_ok());
}
#[test]
fn test_create_remote_builder_invalid_max_jobs() {
let rb = CreateRemoteBuilder {
name: "builder1".to_string(),
ssh_uri: "root@builder.example.com".to_string(),
systems: vec!["x86_64-linux".to_string()],
max_jobs: Some(0),
speed_factor: None,
supported_features: None,
mandatory_features: None,
public_host_key: None,
ssh_key_file: None,
};
assert!(rb.validate().is_err());
}
#[test]
fn test_create_webhook_config_valid() {
let wh = CreateWebhookConfig {
project_id: Uuid::new_v4(),
forge_type: "github".to_string(),
secret: None,
};
assert!(wh.validate().is_ok());
}
#[test]
fn test_create_webhook_config_invalid_forge() {
let wh = CreateWebhookConfig {
project_id: Uuid::new_v4(),
forge_type: "bitbucket".to_string(),
secret: None,
};
assert!(wh.validate().is_err());
}
#[test]
fn test_create_channel_valid() {
let c = CreateChannel {
project_id: Uuid::new_v4(),
name: "stable".to_string(),
jobset_id: Uuid::new_v4(),
};
assert!(c.validate().is_ok());
}
}