use serde::Serialize;
use outrig::config::Config;
use outrig::container::embedded::parse_standalone_image_toml;
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ValidationMessage {
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DockerfileValidation {
pub warnings: Vec<ValidationMessage>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConfigValidation {
pub valid: bool,
pub errors: Vec<ValidationMessage>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BaseFamily {
Debian,
Alpine,
}
pub fn validate_dockerfile(dockerfile: &str) -> DockerfileValidation {
let mut warnings = Vec::new();
let mut last_cmd: Option<(usize, String)> = None;
let mut base_family = None;
for (idx, raw) in dockerfile.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if starts_with_instruction(line, "FROM") {
base_family = from_image(line).and_then(known_family);
} else if starts_with_instruction(line, "USER") {
warnings.push(ValidationMessage {
code: "user_ignored",
message: "OutRig bootstraps and runs containers as the host UID/GID, so Dockerfile USER directives are not respected.".to_string(),
line: Some(line_no),
});
} else if starts_with_instruction(line, "CMD") {
last_cmd = Some((line_no, line.to_string()));
}
}
match last_cmd {
Some((line, cmd)) if !is_sleep_infinity_cmd(&cmd) => {
warnings.push(ValidationMessage {
code: "cmd_may_exit",
message: "A CMD that does not run forever can let the container exit before the agent has finished using it; OutRig templates use CMD [\"sleep\", \"infinity\"].".to_string(),
line: Some(line),
});
}
None => warnings.push(ValidationMessage {
code: "cmd_missing",
message: "Without a long-running CMD, the container may exit before the agent has finished using it; OutRig templates use CMD [\"sleep\", \"infinity\"].".to_string(),
line: None,
}),
_ => {}
}
match base_family {
Some(BaseFamily::Debian) if !contains_token(dockerfile, "passwd") => {
warnings.push(ValidationMessage {
code: "user_bootstrap_package_missing",
message: "Known Debian-family images usually need the passwd package so useradd/groupadd are available for OutRig's host-UID bootstrap.".to_string(),
line: None,
});
}
Some(BaseFamily::Alpine) if !contains_token(dockerfile, "shadow") => {
warnings.push(ValidationMessage {
code: "user_bootstrap_package_missing",
message: "Known Alpine-family images usually need the shadow package so useradd/groupadd are available for OutRig's host-UID bootstrap.".to_string(),
line: None,
});
}
_ => {}
}
DockerfileValidation { warnings }
}
pub fn validate_config(toml: &str) -> ConfigValidation {
match Config::load_from_str(toml).and_then(|cfg| cfg.validate(None).map(|()| cfg)) {
Ok(_) => ConfigValidation {
valid: true,
errors: Vec::new(),
},
Err(err) => ConfigValidation {
valid: false,
errors: vec![ValidationMessage {
code: "config_invalid",
message: err.to_string(),
line: None,
}],
},
}
}
pub fn validate_image_toml(toml: &str) -> ConfigValidation {
match parse_standalone_image_toml(toml) {
Ok(_) => ConfigValidation {
valid: true,
errors: Vec::new(),
},
Err(err) => ConfigValidation {
valid: false,
errors: vec![ValidationMessage {
code: "image_toml_invalid",
message: err.to_string(),
line: None,
}],
},
}
}
fn starts_with_instruction(line: &str, instruction: &str) -> bool {
let Some(head) = line.split_ascii_whitespace().next() else {
return false;
};
head.eq_ignore_ascii_case(instruction)
}
fn from_image(line: &str) -> Option<&str> {
let mut words = line.split_ascii_whitespace();
let from = words.next()?;
if !from.eq_ignore_ascii_case("FROM") {
return None;
}
words.find(|word| !word.starts_with("--"))
}
fn known_family(image: &str) -> Option<BaseFamily> {
let lower = image.to_ascii_lowercase();
let leaf = lower.rsplit('/').next().unwrap_or(&lower);
if leaf.starts_with("alpine:") || leaf == "alpine" || leaf.contains("-alpine") {
Some(BaseFamily::Alpine)
} else if leaf.starts_with("debian:")
|| leaf == "debian"
|| leaf.starts_with("ubuntu:")
|| leaf == "ubuntu"
|| leaf.contains("bookworm")
|| (leaf.starts_with("python:") && leaf.contains("slim"))
{
Some(BaseFamily::Debian)
} else {
None
}
}
fn is_sleep_infinity_cmd(line: &str) -> bool {
let Some(cmd) = line.get(3..) else {
return false;
};
let normalized: String = cmd.chars().filter(|c| !c.is_ascii_whitespace()).collect();
normalized == "[\"sleep\",\"infinity\"]"
}
fn contains_token(text: &str, needle: &str) -> bool {
text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
.any(|token| token == needle)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_template_has_no_warnings() {
let out = validate_dockerfile(
r#"
FROM docker.io/library/debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends passwd
WORKDIR /workspace
CMD ["sleep", "infinity"]
"#,
);
assert_eq!(out.warnings, Vec::new());
}
#[test]
fn user_directive_is_advisory_warning() {
let out = validate_dockerfile(
r#"
FROM debian:bookworm-slim
RUN apt-get install -y passwd
USER app
CMD ["sleep", "infinity"]
"#,
);
assert_eq!(out.warnings[0].code, "user_ignored");
assert_eq!(out.warnings[0].line, Some(4));
}
#[test]
fn non_forever_cmd_is_warning() {
let out = validate_dockerfile(
r#"
FROM debian:bookworm-slim
RUN apt-get install -y passwd
CMD ["bash"]
"#,
);
assert!(out.warnings.iter().any(|w| w.code == "cmd_may_exit"));
}
#[test]
fn known_base_missing_user_package_warns() {
let out = validate_dockerfile(
r#"
FROM alpine:latest
CMD ["sleep", "infinity"]
"#,
);
assert!(
out.warnings
.iter()
.any(|w| w.code == "user_bootstrap_package_missing"),
"expected user bootstrap warning: {:?}",
out.warnings,
);
}
#[test]
fn unknown_base_does_not_warn_about_user_package() {
let out = validate_dockerfile(
r#"
FROM registry.example.com/team/custom-dev:latest
CMD ["sleep", "infinity"]
"#,
);
assert_eq!(out.warnings, Vec::new());
}
#[test]
fn config_validator_reports_invalid_mcp_name() {
let out = validate_config(
r#"
[images.c]
dockerfile = "Dockerfile"
context = "."
[images.c.mcp]
"bad.name" = ["mcp"]
"#,
);
assert!(!out.valid);
assert!(out.errors[0].message.contains("invalid mcp server name"));
}
#[test]
fn image_toml_validator_accepts_explicit_build() {
let out = validate_image_toml(
r#"
[image]
ref = "rust-dev"
description = "Rust tools"
version = "0.1.0"
tags = ["rust"]
[build]
dockerfile = "Dockerfile"
context = "."
[mcp]
fs = { command = ["mcp-server-filesystem", "/workspace"] }
"#,
);
assert!(out.valid, "expected valid image.toml: {out:?}");
assert_eq!(out.errors, Vec::new());
}
#[test]
fn image_toml_validator_accepts_default_build() {
let out = validate_image_toml(
r#"
[image]
ref = "rust-dev"
[mcp]
fs = ["mcp-server-filesystem", "/workspace"]
"#,
);
assert!(
out.valid,
"expected valid default build image.toml: {out:?}"
);
}
#[test]
fn image_toml_validator_rejects_missing_image_ref() {
let out = validate_image_toml(
r#"
[image]
description = "missing ref"
[mcp]
fs = ["mcp-server-filesystem", "/workspace"]
"#,
);
assert!(!out.valid);
assert!(out.errors[0].message.contains("image.ref is required"));
}
#[test]
fn image_toml_validator_rejects_partial_build_fields() {
let out = validate_image_toml(
r#"
[image]
ref = "rust-dev"
[build]
context = "."
[mcp]
fs = ["mcp-server-filesystem", "/workspace"]
"#,
);
assert!(!out.valid);
assert!(out.errors[0].message.contains("build.dockerfile"));
}
#[test]
fn image_toml_validator_rejects_empty_mcp() {
let out = validate_image_toml(
r#"
[image]
ref = "rust-dev"
[mcp]
"#,
);
assert!(!out.valid);
assert!(out.errors[0].message.contains("mcp table"));
}
#[test]
fn image_toml_validator_rejects_invalid_mcp_name() {
let out = validate_image_toml(
r#"
[image]
ref = "rust-dev"
[mcp]
"bad.name" = ["mcp"]
"#,
);
assert!(!out.valid);
assert!(out.errors[0].message.contains("invalid mcp server name"));
}
#[test]
fn image_toml_validator_rejects_empty_mcp_command() {
let out = validate_image_toml(
r#"
[image]
ref = "rust-dev"
[mcp]
fs = []
"#,
);
assert!(!out.valid);
assert!(out.errors[0].message.contains("empty command"));
}
}