1use thiserror::Error;
2
3pub const CODEX_WS_APT_PACKAGES_ENV: &str = "CODEX_WS_APT_PACKAGES";
5
6pub const CODEX_WS_SETUP_COMMANDS_ENV: &str = "CODEX_WS_SETUP_COMMANDS";
8
9pub const CODEX_WS_PYTHON_VERSION_ENV: &str = "CODEX_WS_PYTHON_VERSION";
11
12pub const CODEX_WS_NODE_VERSION_ENV: &str = "CODEX_WS_NODE_VERSION";
14
15pub const CODEX_WS_GO_VERSION_ENV: &str = "CODEX_WS_GO_VERSION";
17
18pub const CODEX_WS_RUST_VERSION_ENV: &str = "CODEX_WS_RUST_VERSION";
20
21pub const CODEX_WS_JAVA_VERSION_ENV: &str = "CODEX_WS_JAVA_VERSION";
23
24pub const CODEX_WS_CLANG_VERSION_ENV: &str = "CODEX_WS_CLANG_VERSION";
26
27pub const CODEX_WS_C_VERSION_ENV: &str = "CODEX_WS_C_VERSION";
29
30pub const CODEX_WS_CPP_VERSION_ENV: &str = "CODEX_WS_CPP_VERSION";
32
33pub const CODEX_WS_RUBY_VERSION_ENV: &str = "CODEX_WS_RUBY_VERSION";
35
36pub const CODEX_WS_PHP_VERSION_ENV: &str = "CODEX_WS_PHP_VERSION";
38
39pub const CODEX_WS_DENO_VERSION_ENV: &str = "CODEX_WS_DENO_VERSION";
41
42pub const CODEX_WS_BUN_VERSION_ENV: &str = "CODEX_WS_BUN_VERSION";
44
45pub const CODEX_WS_ZIG_VERSION_ENV: &str = "CODEX_WS_ZIG_VERSION";
47
48pub const CODEX_WS_DOTNET_VERSION_ENV: &str = "CODEX_WS_DOTNET_VERSION";
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct RuntimeEnvironmentVariable {
54 name: &'static str,
55 value: String,
56}
57
58impl RuntimeEnvironmentVariable {
59 #[must_use]
70 pub fn new(name: &'static str, value: String) -> Self {
71 Self { name, value }
72 }
73
74 #[must_use]
80 pub const fn name(&self) -> &'static str {
81 self.name
82 }
83
84 #[must_use]
90 pub fn value(&self) -> &str {
91 &self.value
92 }
93
94 #[must_use]
100 pub fn docker_assignment(&self) -> String {
101 format!("{}={}", self.name, self.value)
102 }
103}
104
105pub fn validate_apt_packages(packages: Vec<String>) -> Result<Vec<String>, RuntimeSpecError> {
120 let mut validated_packages = Vec::with_capacity(packages.len());
121 for package in packages {
122 let package = package.trim().to_owned();
123 if package.is_empty() {
124 return Err(RuntimeSpecError::EmptyAptPackage);
125 }
126 if !is_valid_apt_package(&package) {
127 return Err(RuntimeSpecError::InvalidAptPackage { package });
128 }
129 validated_packages.push(package);
130 }
131
132 Ok(validated_packages)
133}
134
135pub fn validate_setup_commands(commands: Vec<String>) -> Result<Vec<String>, RuntimeSpecError> {
149 let mut validated_commands = Vec::with_capacity(commands.len());
150 for command in commands {
151 let command = command.trim().to_owned();
152 if command.is_empty() {
153 return Err(RuntimeSpecError::EmptySetupCommand);
154 }
155 validated_commands.push(command);
156 }
157
158 Ok(validated_commands)
159}
160
161pub fn validate_tool_version(
177 tool: RuntimeTool,
178 version: Option<String>,
179) -> Result<Option<RuntimeToolVersion>, RuntimeSpecError> {
180 let Some(version) = version else {
181 return Ok(None);
182 };
183 let version = version.trim().to_owned();
184 if version.is_empty() {
185 return Err(RuntimeSpecError::EmptyToolVersion { tool });
186 }
187 if !is_valid_tool_version(&version) {
188 return Err(RuntimeSpecError::InvalidToolVersion { tool, version });
189 }
190
191 Ok(Some(RuntimeToolVersion::new(tool, version)))
192}
193
194pub fn validate_runtime_tool_versions(
209 versions: Vec<RuntimeToolVersion>,
210) -> Result<Vec<RuntimeToolVersion>, RuntimeSpecError> {
211 let mut clang_version: Option<&str> = None;
212
213 for version in &versions {
214 if !version.tool().uses_clang() {
215 continue;
216 }
217 if let Some(existing_version) = clang_version
218 && existing_version != version.version()
219 {
220 return Err(RuntimeSpecError::ConflictingCompilerVersions {
221 first: existing_version.to_owned(),
222 second: version.version().to_owned(),
223 });
224 }
225 clang_version = Some(version.version());
226 }
227
228 Ok(versions)
229}
230
231fn is_valid_apt_package(package: &str) -> bool {
232 package.bytes().all(|byte| {
233 byte.is_ascii_alphanumeric()
234 || matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'=' | b'~')
235 })
236}
237
238fn is_valid_tool_version(version: &str) -> bool {
239 version.bytes().all(|byte| {
240 byte.is_ascii_alphanumeric()
241 || matches!(byte, b'+' | b'-' | b'.' | b'_' | b':' | b'/' | b'@')
242 })
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum RuntimeTool {
248 Python,
250 Node,
252 Go,
254 Rust,
256 Java,
258 Clang,
260 C,
262 Cpp,
264 Ruby,
266 Php,
268 Deno,
270 Bun,
272 Zig,
274 Dotnet,
276}
277
278impl RuntimeTool {
279 #[must_use]
285 pub const fn name(self) -> &'static str {
286 match self {
287 Self::Python => "python",
288 Self::Node => "node",
289 Self::Go => "go",
290 Self::Rust => "rust",
291 Self::Java => "java",
292 Self::Clang => "clang",
293 Self::C => "c",
294 Self::Cpp => "cpp",
295 Self::Ruby => "ruby",
296 Self::Php => "php",
297 Self::Deno => "deno",
298 Self::Bun => "bun",
299 Self::Zig => "zig",
300 Self::Dotnet => "dotnet",
301 }
302 }
303
304 #[must_use]
310 pub const fn environment_variable(self) -> &'static str {
311 match self {
312 Self::Python => CODEX_WS_PYTHON_VERSION_ENV,
313 Self::Node => CODEX_WS_NODE_VERSION_ENV,
314 Self::Go => CODEX_WS_GO_VERSION_ENV,
315 Self::Rust => CODEX_WS_RUST_VERSION_ENV,
316 Self::Java => CODEX_WS_JAVA_VERSION_ENV,
317 Self::Clang => CODEX_WS_CLANG_VERSION_ENV,
318 Self::C => CODEX_WS_C_VERSION_ENV,
319 Self::Cpp => CODEX_WS_CPP_VERSION_ENV,
320 Self::Ruby => CODEX_WS_RUBY_VERSION_ENV,
321 Self::Php => CODEX_WS_PHP_VERSION_ENV,
322 Self::Deno => CODEX_WS_DENO_VERSION_ENV,
323 Self::Bun => CODEX_WS_BUN_VERSION_ENV,
324 Self::Zig => CODEX_WS_ZIG_VERSION_ENV,
325 Self::Dotnet => CODEX_WS_DOTNET_VERSION_ENV,
326 }
327 }
328
329 const fn uses_clang(self) -> bool {
330 matches!(self, Self::Clang | Self::C | Self::Cpp)
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct RuntimeToolVersion {
337 tool: RuntimeTool,
338 version: String,
339}
340
341impl RuntimeToolVersion {
342 #[must_use]
353 pub fn new(tool: RuntimeTool, version: String) -> Self {
354 Self { tool, version }
355 }
356
357 #[must_use]
363 pub const fn tool(&self) -> RuntimeTool {
364 self.tool
365 }
366
367 #[must_use]
373 pub fn version(&self) -> &str {
374 &self.version
375 }
376
377 #[must_use]
383 pub fn environment_variable(&self) -> RuntimeEnvironmentVariable {
384 RuntimeEnvironmentVariable::new(self.tool.environment_variable(), self.version.clone())
385 }
386}
387
388#[derive(Debug, Error, PartialEq, Eq)]
390pub enum RuntimeSpecError {
391 #[error("runtime apt package cannot be empty")]
393 EmptyAptPackage,
394
395 #[error("invalid runtime apt package '{package}'")]
397 InvalidAptPackage {
398 package: String,
400 },
401
402 #[error("runtime setup command cannot be empty")]
404 EmptySetupCommand,
405
406 #[error("runtime {tool} version cannot be empty", tool = .tool.name())]
408 EmptyToolVersion {
409 tool: RuntimeTool,
411 },
412
413 #[error("invalid runtime {tool} version '{version}'", tool = .tool.name())]
415 InvalidToolVersion {
416 tool: RuntimeTool,
418 version: String,
420 },
421
422 #[error("conflicting C/C++ runtime versions '{first}' and '{second}'")]
424 ConflictingCompilerVersions {
425 first: String,
427 second: String,
429 },
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn validate_apt_packages_accepts_common_package_syntax() {
438 let packages = validate_apt_packages(vec![
439 " python3 ".to_owned(),
440 "libssl-dev:amd64".to_owned(),
441 "nodejs=22.0.0-1nodesource1".to_owned(),
442 ])
443 .expect("apt packages should validate");
444
445 assert_eq!(
446 packages,
447 vec![
448 "python3".to_owned(),
449 "libssl-dev:amd64".to_owned(),
450 "nodejs=22.0.0-1nodesource1".to_owned()
451 ]
452 );
453 }
454
455 #[test]
456 fn validate_apt_packages_rejects_shell_metacharacters() {
457 let error = validate_apt_packages(vec!["python3;curl".to_owned()])
458 .expect_err("shell metacharacters should fail");
459
460 assert!(matches!(
461 error,
462 RuntimeSpecError::InvalidAptPackage { package } if package == "python3;curl"
463 ));
464 }
465
466 #[test]
467 fn validate_setup_commands_rejects_empty_commands() {
468 let error =
469 validate_setup_commands(vec![" ".to_owned()]).expect_err("blank command should fail");
470
471 assert_eq!(error, RuntimeSpecError::EmptySetupCommand);
472 }
473
474 #[test]
475 fn validate_tool_version_trims_versions() {
476 let version = validate_tool_version(RuntimeTool::Python, Some(" 3.13 ".to_owned()))
477 .expect("version should validate");
478
479 assert_eq!(
480 version,
481 Some(RuntimeToolVersion::new(
482 RuntimeTool::Python,
483 "3.13".to_owned()
484 ))
485 );
486 }
487
488 #[test]
489 fn validate_tool_version_rejects_shell_metacharacters() {
490 let error = validate_tool_version(RuntimeTool::Go, Some("1.24;curl".to_owned()))
491 .expect_err("shell metacharacters should fail");
492
493 assert!(matches!(
494 error,
495 RuntimeSpecError::InvalidToolVersion {
496 tool: RuntimeTool::Go,
497 version
498 } if version == "1.24;curl"
499 ));
500 }
501
502 #[test]
503 fn validate_runtime_tool_versions_accepts_matching_compiler_aliases() {
504 let versions = validate_runtime_tool_versions(vec![
505 RuntimeToolVersion::new(RuntimeTool::C, "20".to_owned()),
506 RuntimeToolVersion::new(RuntimeTool::Cpp, "20".to_owned()),
507 ])
508 .expect("matching compiler aliases should validate");
509
510 assert_eq!(versions.len(), 2);
511 }
512
513 #[test]
514 fn validate_runtime_tool_versions_rejects_conflicting_compiler_aliases() {
515 let error = validate_runtime_tool_versions(vec![
516 RuntimeToolVersion::new(RuntimeTool::C, "20".to_owned()),
517 RuntimeToolVersion::new(RuntimeTool::Cpp, "21".to_owned()),
518 ])
519 .expect_err("conflicting compiler aliases should fail");
520
521 assert!(matches!(
522 error,
523 RuntimeSpecError::ConflictingCompilerVersions { first, second }
524 if first == "20" && second == "21"
525 ));
526 }
527}