diff --git a/crates/capsem-api/src/lifecycle.rs b/crates/capsem-api/src/lifecycle.rs index e7c2a9a9b..d80ab6e0e 100644 --- a/crates/capsem-api/src/lifecycle.rs +++ b/crates/capsem-api/src/lifecycle.rs @@ -15,6 +15,27 @@ pub struct StopResponse { pub persistent: bool, } +/// Validate a VM name or identifier (`1..=64` ASCII `[A-Za-z0-9_-]` characters, +/// starting with an ASCII letter or digit) so service paths and gateway routes +/// enforce one rule. +pub fn validate_name(kind: &str, value: &str) -> Result<(), String> { + let Some(first) = value.chars().next() else { + return Err(format!("{kind} cannot be empty")); + }; + if value.len() > 64 { + return Err(format!("{kind} too long (max 64 characters)")); + } + if !first.is_ascii_alphanumeric() { + return Err(format!("{kind} must start with a letter or digit")); + } + if !value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err(format!( + "{kind} must contain only letters, digits, hyphens, and underscores" + )); + } + Ok(()) +} + /// Unknown fields are refused rather than ignored. #[derive(Serialize, Deserialize, Debug, Clone, Default, ToSchema)] #[serde(deny_unknown_fields)] diff --git a/crates/capsem-api/src/tests.rs b/crates/capsem-api/src/tests.rs index 4a876ff35..cbccfa57c 100644 --- a/crates/capsem-api/src/tests.rs +++ b/crates/capsem-api/src/tests.rs @@ -422,3 +422,42 @@ fn exec_target_defaults_to_the_workload_only_where_there_is_one() { // An absent target stays absent on the wire, so older services decode it. assert_eq!(serde_json::to_value(&absent).unwrap(), json!({"command": "id -u"})); } + +#[test] +fn validate_name_enforces_shared_vm_identifier_rule() { + for valid in [ + "a", + "9lives", + "my-vm", + "project_alpha", + "vm-1712678400", + &"a".repeat(64), + ] { + assert_eq!(validate_name("VM name", valid), Ok(()), "{valid}"); + assert_eq!(validate_name("VM id", valid), Ok(()), "{valid}"); + } + + assert_eq!(validate_name("VM name", ""), Err("VM name cannot be empty".to_string())); + assert_eq!(validate_name("VM id", ""), Err("VM id cannot be empty".to_string())); + assert_eq!( + validate_name("VM name", &"a".repeat(65)), + Err("VM name too long (max 64 characters)".to_string()) + ); + for bad_start in ["-bad", "_bad", ".bad"] { + assert_eq!( + validate_name("VM id", bad_start), + Err("VM id must start with a letter or digit".to_string()), + "{bad_start}" + ); + } + for bad_body in ["my/vm", "../escape", "my vm", "my.vm", "vm\0id", "nai\u{00ef}ve"] { + assert!( + validate_name("VM name", bad_body).is_err(), + "{bad_body} should be rejected" + ); + } + assert_eq!( + validate_name("VM name", "my.vm"), + Err("VM name must contain only letters, digits, hyphens, and underscores".to_string()) + ); +} diff --git a/crates/capsem-gateway/src/stream.rs b/crates/capsem-gateway/src/stream.rs index 76c6eacfc..40de0c1ed 100644 --- a/crates/capsem-gateway/src/stream.rs +++ b/crates/capsem-gateway/src/stream.rs @@ -22,21 +22,9 @@ const REQUEST_HEADERS: [http::HeaderName; 5] = [ const RESPONSE_HEADERS: [http::HeaderName; 4] = [CONNECTION, UPGRADE, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_PROTOCOL]; /// Validate VM ID: alphanumeric, hyphens, underscores. Must start with -/// alphanumeric, length 1-64. Matches capsem-service's `validate_vm_name`. -pub(crate) fn validate_vm_id(id: &str) -> Result<(), &'static str> { - if id.is_empty() { - return Err("VM id cannot be empty"); - } - if id.len() > 64 { - return Err("VM id too long (max 64 characters)"); - } - if !id.chars().next().unwrap().is_ascii_alphanumeric() { - return Err("VM id must start with a letter or digit"); - } - if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { - return Err("VM id must contain only letters, digits, hyphens, and underscores"); - } - Ok(()) +/// alphanumeric, length 1-64. +pub(crate) fn validate_vm_id(id: &str) -> Result<(), String> { + capsem_api::validate_name("VM id", id) } fn refuse(status: http::StatusCode, message: &str) -> Response { @@ -49,7 +37,7 @@ pub async fn handle_stream_tunnel( mut request: Request, ) -> Response { if let Err(message) = validate_vm_id(&id) { - return refuse(http::StatusCode::BAD_REQUEST, message); + return refuse(http::StatusCode::BAD_REQUEST, &message); } let is_upgrade = request .headers() diff --git a/crates/capsem-service/src/naming.rs b/crates/capsem-service/src/naming.rs index 62873ba86..47f682a3e 100644 --- a/crates/capsem-service/src/naming.rs +++ b/crates/capsem-service/src/naming.rs @@ -1,6 +1,6 @@ //! VM-name helpers: generated session names and persistent-name validation. -use anyhow::{anyhow, Result}; +use anyhow::Result; use rand::Rng; /// The first free `vm-N` among `existing` names. @@ -31,21 +31,7 @@ where /// - starts with an ASCII letter or digit (no leading hyphen/underscore) /// - consists only of ASCII alphanumerics, `-`, or `_` pub fn validate_vm_name(name: &str) -> Result<()> { - if name.is_empty() { - return Err(anyhow!("VM name cannot be empty")); - } - if name.len() > 64 { - return Err(anyhow!("VM name too long (max 64 characters)")); - } - if !name.chars().next().unwrap().is_ascii_alphanumeric() { - return Err(anyhow!("VM name must start with a letter or digit")); - } - if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { - return Err(anyhow!( - "VM name must contain only letters, digits, hyphens, and underscores" - )); - } - Ok(()) + capsem_api::validate_name("VM name", name).map_err(anyhow::Error::msg) } #[cfg(test)]