use thiserror::Error;
use crate::dockerfile::{Dockerfile, ImageRef, Instruction, ShellOrExec};
#[derive(Debug, Error)]
pub enum DepsError {
#[error(
"`{package_manager}` requires a Windows base image with PowerShell \
(e.g. mcr.microsoft.com/windows/servercore:ltsc2022). The nanoserver \
base image has no package manager. Change the FROM line to servercore, \
or install dependencies in a separate `servercore`-based build stage \
and COPY them into the final nanoserver stage. Offending RUN \
instruction #{instruction_index}."
)]
ChocoOnNanoserver {
instruction_index: usize,
package_manager: String,
},
}
const WINDOWS_PACKAGE_MANAGERS: &[&str] = &["choco", "winget"];
pub fn validate_dockerfile(dockerfile: &Dockerfile) -> Result<(), DepsError> {
for stage in &dockerfile.stages {
let base_kind = classify_base_image(&stage.base_image);
if base_kind != WindowsBase::Nanoserver {
continue;
}
for (idx, instr) in stage.instructions.iter().enumerate() {
if let Instruction::Run(run) = instr {
if let Some(pm) = detect_package_manager(&run.command) {
return Err(DepsError::ChocoOnNanoserver {
instruction_index: idx,
package_manager: pm.to_string(),
});
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WindowsBase {
Nanoserver,
ServerCoreOrOtherWindows,
NotWindows,
}
fn classify_base_image(base: &ImageRef) -> WindowsBase {
let image_str = match base {
ImageRef::Registry { image, .. } => image.to_ascii_lowercase(),
ImageRef::Stage(_) | ImageRef::Scratch => return WindowsBase::NotWindows,
};
if image_str.contains("nanoserver") {
WindowsBase::Nanoserver
} else if image_str.contains("servercore") || image_str.contains("windows/") {
WindowsBase::ServerCoreOrOtherWindows
} else {
WindowsBase::NotWindows
}
}
fn detect_package_manager(cmd: &ShellOrExec) -> Option<&'static str> {
match cmd {
ShellOrExec::Exec(args) => {
detect_in_tokens(args)
}
ShellOrExec::Shell(s) => {
let tokens = tokenize_shell(s);
detect_in_tokens(&tokens)
}
}
}
fn detect_in_tokens<S: AsRef<str>>(tokens: &[S]) -> Option<&'static str> {
let stripped: Vec<String> = strip_wrapper(tokens);
let first: &str = stripped.first()?.as_str();
let lower = first.to_ascii_lowercase();
let normalised = lower.strip_suffix(".exe").unwrap_or(&lower);
WINDOWS_PACKAGE_MANAGERS
.iter()
.find(|pm| normalised == **pm)
.copied()
}
fn strip_wrapper<S: AsRef<str>>(tokens: &[S]) -> Vec<String> {
if tokens.is_empty() {
return Vec::new();
}
let head = tokens[0].as_ref().to_ascii_lowercase();
let head = head.strip_suffix(".exe").unwrap_or(&head).to_string();
if head == "cmd" {
let mut i = 1;
while i < tokens.len() {
let t = tokens[i].as_ref().to_ascii_lowercase();
if t == "/c" || t == "/k" {
i += 1;
break;
}
if t.starts_with('/') {
i += 1;
continue;
}
break;
}
if i >= tokens.len() {
return Vec::new();
}
let rest: Vec<String> = tokens[i..].iter().map(|s| s.as_ref().to_string()).collect();
if rest.len() == 1 && rest[0].contains(char::is_whitespace) {
return tokenize_shell(&rest[0]);
}
return rest;
}
if head == "powershell" || head == "pwsh" {
let mut i = 1;
while i < tokens.len() {
let t = tokens[i].as_ref().to_ascii_lowercase();
if t == "-command" || t == "-c" {
i += 1;
break;
}
if t.starts_with('-') {
if t == "-executionpolicy" || t == "-file" {
i += 2;
} else {
i += 1;
}
continue;
}
break;
}
if i >= tokens.len() {
return Vec::new();
}
let rest: Vec<String> = tokens[i..].iter().map(|s| s.as_ref().to_string()).collect();
if rest.len() == 1 && rest[0].contains(char::is_whitespace) {
return tokenize_shell(&rest[0]);
}
return rest;
}
tokens.iter().map(|s| s.as_ref().to_string()).collect()
}
fn tokenize_shell(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\'' if !in_double => {
in_single = !in_single;
}
'"' if !in_single => {
in_double = !in_double;
}
'\\' if in_double => {
if let Some(&next) = chars.peek() {
current.push(next);
chars.next();
} else {
current.push('\\');
}
}
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if !current.is_empty() {
out.push(current);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dockerfile::Dockerfile;
fn parse(s: &str) -> Dockerfile {
Dockerfile::parse(s).expect("test Dockerfile should parse")
}
#[test]
fn nanoserver_plus_choco_errors() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN choco install nginx -y
",
);
let err = validate_dockerfile(&df).expect_err("should flag choco on nanoserver");
match err {
DepsError::ChocoOnNanoserver {
instruction_index,
package_manager,
} => {
assert_eq!(instruction_index, 0);
assert_eq!(package_manager, "choco");
}
}
}
#[test]
fn nanoserver_plus_winget_errors() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN winget install --id Git.Git
",
);
let err = validate_dockerfile(&df).expect_err("should flag winget on nanoserver");
assert!(matches!(
err,
DepsError::ChocoOnNanoserver { ref package_manager, .. }
if package_manager == "winget"
));
}
#[test]
fn nanoserver_without_package_manager_is_ok() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN cmd /c echo hello
COPY . C:\\app
",
);
validate_dockerfile(&df).expect("plain cmd /c echo should pass");
}
#[test]
fn servercore_plus_choco_is_ok() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/servercore:ltsc2022
RUN choco install nginx -y
",
);
validate_dockerfile(&df).expect("choco on servercore should pass");
}
#[test]
fn servercore_plus_powershell_choco_is_ok() {
let df = parse(
r#"
FROM mcr.microsoft.com/windows/servercore:ltsc2022
RUN powershell -Command "choco install nginx -y"
"#,
);
validate_dockerfile(&df).expect("powershell-wrapped choco on servercore should pass");
}
#[test]
fn nanoserver_plus_powershell_choco_errors() {
let df = parse(
r#"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN powershell -Command "choco install nginx -y"
"#,
);
let err = validate_dockerfile(&df)
.expect_err("powershell-wrapped choco on nanoserver should still be flagged");
assert!(matches!(
err,
DepsError::ChocoOnNanoserver { ref package_manager, .. }
if package_manager == "choco"
));
}
#[test]
fn linux_base_is_skipped() {
let df = parse(
r"
FROM alpine:3.19
RUN apk add --no-cache nginx
",
);
validate_dockerfile(&df).expect("alpine + apk has nothing to do with this validator");
}
#[test]
fn multi_stage_servercore_then_nanoserver_is_ok() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/servercore:ltsc2022 AS builder
RUN choco install nginx -y
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
COPY --from=builder C:\\nginx C:\\nginx
",
);
validate_dockerfile(&df).expect("multi-stage canonical pattern should pass");
}
#[test]
fn nanoserver_cmd_c_choco_errors() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN cmd /c choco install nginx -y
",
);
let err = validate_dockerfile(&df).expect_err("cmd /c wrapping choco should still trip");
assert!(matches!(
err,
DepsError::ChocoOnNanoserver { ref package_manager, .. }
if package_manager == "choco"
));
}
#[test]
fn nanoserver_exec_form_winget_errors() {
let df = parse(
r#"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN ["winget", "install", "--id", "Git.Git"]
"#,
);
let err = validate_dockerfile(&df).expect_err("exec-form winget on nanoserver should trip");
assert!(matches!(
err,
DepsError::ChocoOnNanoserver { ref package_manager, .. }
if package_manager == "winget"
));
}
#[test]
fn nanoserver_choco_exe_errors() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN choco.exe install nginx -y
",
);
let err = validate_dockerfile(&df).expect_err("choco.exe should normalise to choco");
assert!(matches!(
err,
DepsError::ChocoOnNanoserver { ref package_manager, .. }
if package_manager == "choco"
));
}
#[test]
fn nanoserver_reports_correct_instruction_index() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
COPY . C:\\app
RUN cmd /c echo build step
RUN choco install nginx -y
",
);
let err = validate_dockerfile(&df).expect_err("should flag third instruction");
match err {
DepsError::ChocoOnNanoserver {
instruction_index, ..
} => {
assert_eq!(instruction_index, 2);
}
}
}
#[test]
fn error_message_points_at_servercore() {
let df = parse(
r"
FROM mcr.microsoft.com/windows/nanoserver:ltsc2022
RUN choco install nginx -y
",
);
let err = validate_dockerfile(&df).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("servercore"),
"error message should steer users at servercore, got: {msg}"
);
assert!(
msg.contains("COPY them into the final nanoserver stage"),
"error message should mention the multi-stage remediation, got: {msg}"
);
}
#[test]
fn tokenize_handles_double_quoted_payload() {
let toks = tokenize_shell(r#"powershell -Command "choco install nginx -y""#);
assert_eq!(toks.len(), 3);
assert_eq!(toks[0], "powershell");
assert_eq!(toks[1], "-Command");
assert_eq!(toks[2], "choco install nginx -y");
}
#[test]
fn tokenize_handles_single_quoted_payload() {
let toks = tokenize_shell(r"cmd /c 'choco install nginx'");
assert_eq!(toks, vec!["cmd", "/c", "choco install nginx"]);
}
#[test]
fn strip_wrapper_peels_cmd_c() {
let toks = vec!["cmd", "/c", "choco", "install", "nginx"];
let stripped = strip_wrapper(&toks);
assert_eq!(stripped, vec!["choco", "install", "nginx"]);
}
#[test]
fn strip_wrapper_peels_powershell_joined_payload() {
let toks = vec!["powershell", "-Command", "choco install nginx"];
let stripped = strip_wrapper(&toks);
assert_eq!(stripped, vec!["choco", "install", "nginx"]);
}
#[test]
fn strip_wrapper_leaves_non_wrappers_alone() {
let toks = vec!["apt-get", "install", "-y", "nginx"];
let stripped = strip_wrapper(&toks);
assert_eq!(stripped, vec!["apt-get", "install", "-y", "nginx"]);
}
}