use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use tracing::debug;
use super::ssl::{SSL_CERT_PATHS, SSL_ENV_VARS};
pub(crate) const SSL_CERT_BUILD_CONTEXT: &str = "rise-internal-ssl-cert";
pub(crate) struct SslCertContext {
_temp_dir: TempDir,
pub context_path: PathBuf,
}
impl SslCertContext {
pub fn new(ssl_cert_path: &Path) -> Result<Self> {
let temp_dir =
TempDir::new().context("Failed to create temp directory for SSL certificate")?;
let cert_dest = temp_dir.path().join("ca-certificates.crt");
std::fs::copy(ssl_cert_path, &cert_dest).with_context(|| {
format!(
"Failed to copy SSL certificate to temp directory: {}",
cert_dest.display()
)
})?;
debug!(
"Created SSL cert build context in temp directory: {}",
temp_dir.path().display()
);
Ok(Self {
context_path: temp_dir.path().to_path_buf(),
_temp_dir: temp_dir,
})
}
}
fn generate_ssl_mount_spec() -> String {
SSL_CERT_PATHS
.iter()
.map(|path| {
format!(
"--mount=type=bind,from={},source=ca-certificates.crt,target={},readonly",
SSL_CERT_BUILD_CONTEXT, path
)
})
.collect::<Vec<_>>()
.join(" ")
}
fn generate_ssl_exports(ssl_cert_path: &str) -> String {
SSL_ENV_VARS
.iter()
.map(|var| format!("export {}={}", var, ssl_cert_path))
.collect::<Vec<_>>()
.join(" && ")
}
fn is_run_instruction(line: &str) -> bool {
let trimmed = line.trim();
let upper = trimmed.to_uppercase();
upper.starts_with("RUN ")
|| upper.starts_with("RUN\t")
|| upper == "RUN"
|| (upper.starts_with("RUN")
&& trimmed.chars().nth(3).is_some_and(|c| !c.is_alphanumeric()))
}
fn inject_mount_into_run(line: &str, mount_spec: &str) -> String {
let trimmed = line.trim_start();
let run_upper = trimmed.to_uppercase();
let run_pos = run_upper.find("RUN").unwrap();
let after_run = run_pos + 3;
let leading_ws_len = line.len() - trimmed.len();
let leading_ws = &line[..leading_ws_len];
let rest = &trimmed[after_run..];
if rest.is_empty() {
format!("{}{} {}", leading_ws, &trimmed[..after_run], mount_spec)
} else if rest.starts_with(char::is_whitespace) {
let ws_end = rest
.find(|c: char| !c.is_whitespace())
.unwrap_or(rest.len());
let ws = &rest[..ws_end];
let command = &rest[ws_end..];
let (flags, actual_command) = extract_run_flags(command);
let ssl_cert_path = SSL_CERT_PATHS[0];
let wrapped_command = if actual_command.is_empty() {
String::new()
} else {
format!(
"{} && {}",
generate_ssl_exports(ssl_cert_path),
actual_command
)
};
format!(
"{}{}{}{} {}",
leading_ws,
&trimmed[..after_run],
ws,
mount_spec,
if flags.is_empty() {
wrapped_command
} else {
format!("{} {}", flags, wrapped_command)
}
)
} else {
let (flags, actual_command) = extract_run_flags(rest);
let ssl_cert_path = SSL_CERT_PATHS[0];
let wrapped_command = if actual_command.is_empty() {
String::new()
} else {
format!(
"{} && {}",
generate_ssl_exports(ssl_cert_path),
actual_command
)
};
format!(
"{}{} {} {}",
leading_ws,
&trimmed[..after_run],
mount_spec,
if flags.is_empty() {
wrapped_command
} else {
format!("{} {}", flags, wrapped_command)
}
)
}
}
fn extract_run_flags(command: &str) -> (String, String) {
let mut flags = Vec::new();
let mut parts = command.split_whitespace().peekable();
let mut actual_command_parts = Vec::new();
while let Some(part) = parts.peek() {
if part.starts_with("--") {
let flag = parts.next().unwrap();
flags.push(flag.to_string());
if !flag.contains('=') && parts.peek().is_some() {
let next = parts.peek().unwrap();
if !next.starts_with("--") {
flags.push(parts.next().unwrap().to_string());
}
}
} else {
break;
}
}
actual_command_parts.extend(parts);
(flags.join(" "), actual_command_parts.join(" "))
}
fn inject_ssl_mounts(dockerfile_content: &str) -> String {
let mount_spec = generate_ssl_mount_spec();
let mut result = String::new();
let lines: Vec<&str> = dockerfile_content.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if is_run_instruction(line) {
let mut run_lines = vec![line];
let mut j = i;
while j < lines.len() && lines[j].trim_end().ends_with('\\') {
j += 1;
if j < lines.len() {
run_lines.push(lines[j]);
}
}
let modified = inject_mount_into_multiline_run(&run_lines, &mount_spec);
result.push_str(&modified);
i = j + 1;
} else {
result.push_str(line);
result.push('\n');
i += 1;
}
}
result
}
fn inject_mount_into_multiline_run(run_lines: &[&str], mount_spec: &str) -> String {
if run_lines.is_empty() {
return String::new();
}
if run_lines.len() == 1 {
return format!("{}\n", inject_mount_into_run(run_lines[0], mount_spec));
}
let first_line = run_lines[0];
let trimmed = first_line.trim_start();
let leading_ws_len = first_line.len() - trimmed.len();
let leading_ws = &first_line[..leading_ws_len];
let run_upper = trimmed.to_uppercase();
let run_pos = run_upper.find("RUN").unwrap();
let after_run = run_pos + 3;
let mut all_flags = Vec::new();
let mut command_lines = Vec::new();
let mut found_command = false;
for (idx, &line) in run_lines.iter().enumerate() {
let content = if idx == 0 {
let rest = &trimmed[after_run..];
rest.trim_start()
} else {
line.trim_start()
};
let has_continuation = content.trim_end().ends_with('\\');
let content_no_backslash = if has_continuation {
content.trim_end().strip_suffix('\\').unwrap().trim_end()
} else {
content
};
let (flags, command) = extract_run_flags(content_no_backslash);
if !flags.is_empty() {
all_flags.push(flags);
}
if !command.is_empty() {
found_command = true;
if has_continuation {
command_lines.push(format!("{} \\", command));
} else {
command_lines.push(command.to_string());
}
} else if found_command {
if has_continuation {
command_lines.push("\\".to_string());
}
}
}
let ssl_cert_path = SSL_CERT_PATHS[0];
let all_flags_str = all_flags.join(" ");
let mut result = Vec::new();
if command_lines.is_empty() {
result.push(format!("{}RUN {}", leading_ws, mount_spec));
} else if command_lines.len() == 1 {
if all_flags_str.is_empty() {
result.push(format!(
"{}RUN {} {} && {}",
leading_ws,
mount_spec,
generate_ssl_exports(ssl_cert_path),
command_lines[0]
));
} else {
result.push(format!(
"{}RUN {} {} {} && {}",
leading_ws,
mount_spec,
all_flags_str,
generate_ssl_exports(ssl_cert_path),
command_lines[0]
));
}
} else {
if all_flags_str.is_empty() {
result.push(format!(
"{}RUN {} {} && \\",
leading_ws,
mount_spec,
generate_ssl_exports(ssl_cert_path)
));
} else {
result.push(format!(
"{}RUN {} {} {} && \\",
leading_ws,
mount_spec,
all_flags_str,
generate_ssl_exports(ssl_cert_path)
));
}
for cmd_line in &command_lines {
let original_line_idx = result.len();
let original_indent = if original_line_idx < run_lines.len() {
run_lines[original_line_idx]
.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>()
} else {
" ".to_string()
};
result.push(format!("{}{}", original_indent, cmd_line));
}
}
result.join("\n") + "\n"
}
pub(crate) fn preprocess_dockerfile_for_ssl(
original_dockerfile: &Path,
) -> Result<(TempDir, PathBuf)> {
let content = std::fs::read_to_string(original_dockerfile).with_context(|| {
format!(
"Failed to read Dockerfile: {}",
original_dockerfile.display()
)
})?;
let processed = inject_ssl_mounts(&content);
debug!("Processed Dockerfile with SSL mounts:\n{}", processed);
let temp_dir = TempDir::new().context("Failed to create temp directory")?;
let filename = original_dockerfile
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("Dockerfile"));
let temp_dockerfile = temp_dir.path().join(filename);
std::fs::write(&temp_dockerfile, processed).context("Failed to write processed Dockerfile")?;
Ok((temp_dir, temp_dockerfile))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_run_instruction() {
assert!(is_run_instruction("RUN apt-get update"));
assert!(is_run_instruction("RUN\tapt-get update"));
assert!(is_run_instruction(" RUN apt-get update"));
assert!(is_run_instruction("RUN"));
assert!(is_run_instruction("run apt-get update")); assert!(!is_run_instruction("RUNNER something"));
assert!(!is_run_instruction("# RUN apt-get update"));
assert!(!is_run_instruction("FROM ubuntu"));
}
#[test]
fn test_inject_mount_into_run() {
let mount_spec = generate_ssl_mount_spec();
let result = inject_mount_into_run("RUN apt-get update", &mount_spec);
assert!(result.contains(&mount_spec));
assert!(result.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("&& apt-get update"));
assert!(!result.contains("("));
let line_with_mount =
"RUN --mount=type=bind,source=some-file,target=/app/file apt-get update";
let result = inject_mount_into_run(line_with_mount, &mount_spec);
assert!(result.contains(&mount_spec));
assert!(result.contains("--mount=type=bind,source=some-file,target=/app/file"));
assert!(result.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("&& apt-get update"));
let result = inject_mount_into_run(" RUN apt-get update", &mount_spec);
assert!(result.starts_with(" RUN"));
assert!(result.contains(&mount_spec));
assert!(result.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("&& apt-get update"));
let result = inject_mount_into_run("RUN --network=host apt-get update", &mount_spec);
assert!(result.contains(&mount_spec));
assert!(result.contains("--network=host"));
assert!(result.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("&& apt-get update"));
}
#[test]
fn test_extract_run_flags() {
let (flags, command) = extract_run_flags("apt-get update");
assert_eq!(flags, "");
assert_eq!(command, "apt-get update");
let (flags, command) = extract_run_flags("--network=host apt-get update");
assert_eq!(flags, "--network=host");
assert_eq!(command, "apt-get update");
let (flags, command) =
extract_run_flags("--network=host --mount=type=cache,target=/cache apt-get update");
assert_eq!(flags, "--network=host --mount=type=cache,target=/cache");
assert_eq!(command, "apt-get update");
let (flags, command) =
extract_run_flags("--network=host apt-get update && apt-get install curl");
assert_eq!(flags, "--network=host");
assert_eq!(command, "apt-get update && apt-get install curl");
}
#[test]
fn test_inject_ssl_mounts() {
let dockerfile = r#"FROM ubuntu:22.04
RUN apt-get update && apt-get install -y curl
COPY . /app
RUN pip install -r requirements.txt
CMD ["python", "app.py"]
"#;
let result = inject_ssl_mounts(dockerfile);
assert!(result.contains("--mount=type=bind,from=rise-internal-ssl-cert"));
assert!(result.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(!result.contains("("));
assert!(result.contains("&& apt-get update && apt-get install -y curl"));
assert!(result.contains("&& pip install -r requirements.txt"));
assert!(result.contains("FROM ubuntu:22.04"));
assert!(result.contains("COPY . /app"));
let mount_count = result
.lines()
.filter(|line| {
line.contains("RUN")
&& line.contains("--mount=type=bind,from=rise-internal-ssl-cert")
})
.count();
assert_eq!(mount_count, 2);
}
#[test]
fn test_multiline_run() {
let dockerfile = r#"FROM ubuntu:22.04
RUN apt-get update -y && \
apt-get install -y gzip zip jq git less && \
apt-get clean
"#;
let result = inject_ssl_mounts(dockerfile);
println!("Result:\n{}", result);
let lines: Vec<&str> = result.lines().collect();
assert!(lines[1].contains("--mount=type=bind,from=rise-internal-ssl-cert"));
assert!(lines[1].contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(lines[1].contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(lines[1].contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(lines[1].contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(lines[1].contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(
lines[1].ends_with(" \\"),
"First line should end with backslash continuation"
);
assert!(lines[2].trim().starts_with("apt-get update"));
assert!(lines[3].trim().starts_with("apt-get install"));
assert!(lines[4].trim().starts_with("apt-get clean"));
assert!(!lines[2].contains("--mount=type=bind,from=rise-internal-ssl-cert"));
assert!(!lines[2].contains("export"));
assert!(!result.contains("("));
assert!(!result.contains(")"));
}
#[test]
fn test_run_with_existing_mount_flag() {
let dockerfile = r#"FROM python:3.12
RUN --mount=type=bind,source=uv.lock,target=uv.lock uv sync --locked
"#;
let result = inject_ssl_mounts(dockerfile);
assert!(result.contains("--mount=type=bind,from=rise-internal-ssl-cert"));
assert!(result.contains("--mount=type=bind,source=uv.lock,target=uv.lock"));
assert!(result.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(result.contains("&& uv sync --locked"));
let run_line = result.lines().nth(1).unwrap();
let export_pos = run_line.find("export").expect("Should contain export");
let bind_mount_pos = run_line
.find("--mount=type=bind")
.expect("Should contain bind mount");
assert!(
bind_mount_pos < export_pos,
"Bind mount should come before export. Line: {}",
run_line
);
let command_pos = run_line.find("uv sync").expect("Should contain uv sync");
assert!(
export_pos < command_pos,
"export should come before the command"
);
println!("Generated line: {}", run_line);
}
#[test]
fn test_run_with_multiple_mount_flags_across_lines() {
let dockerfile = r#"FROM python:3.12
RUN --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=uv.lock,target=uv.lock \
uv sync --locked
"#;
let result = inject_ssl_mounts(dockerfile);
println!("Result:\n{}", result);
let lines: Vec<&str> = result.lines().collect();
let run_line = lines[1];
assert!(run_line.contains("--mount=type=bind,from=rise-internal-ssl-cert"));
assert!(run_line.contains("--mount=type=bind,source=pyproject.toml,target=pyproject.toml"));
assert!(run_line.contains("--mount=type=bind,source=uv.lock,target=uv.lock"));
assert!(run_line.contains("export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(run_line.contains("export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"));
assert!(run_line.contains("export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt"));
assert!(run_line.contains("export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(run_line.contains("export AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt"));
assert!(run_line.contains("uv sync --locked"));
let export_pos = run_line.find("export").expect("Should contain export");
let pyproject_mount_pos = run_line
.find("--mount=type=bind,source=pyproject.toml")
.expect("Should contain pyproject mount");
let uvlock_mount_pos = run_line
.find("--mount=type=bind,source=uv.lock")
.expect("Should contain uv.lock mount");
let command_pos = run_line.find("uv sync").expect("Should contain uv sync");
assert!(
pyproject_mount_pos < export_pos,
"pyproject mount should come before export"
);
assert!(
uvlock_mount_pos < export_pos,
"uv.lock mount should come before export"
);
assert!(
export_pos < command_pos,
"export should come before command"
);
println!("Generated line: {}", run_line);
}
}