Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/capsem-api/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
39 changes: 39 additions & 0 deletions crates/capsem-api/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
);
}
20 changes: 4 additions & 16 deletions crates/capsem-gateway/src/stream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand Down
18 changes: 2 additions & 16 deletions crates/capsem-service/src/naming.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)]
Expand Down
Loading