use std::collections::BTreeMap;
use crate::compose::types::{Command, RestartPolicy, VolumeMount};
use crate::ports::ParsedPort;
pub(super) fn render_publish_port(p: &ParsedPort) -> String {
let mut s = String::new();
if !p.host_ip.is_empty() {
s.push_str(&p.host_ip);
s.push(':');
}
if let Some(host) = p.host_port.filter(|n| *n != 0) {
s.push_str(&host.to_string());
s.push(':');
}
s.push_str(&p.container_port.to_string());
if p.protocol != "tcp" {
s.push('/');
s.push_str(&p.protocol);
}
s
}
pub(super) fn render_volume(vol: &VolumeMount, declared_volumes: &[&str]) -> String {
match vol {
VolumeMount::Short(s) => {
let parts: Vec<&str> = s.splitn(3, ':').collect();
if parts.len() >= 2 && declared_volumes.contains(&parts[0]) {
let mut out = format!("{}.volume:{}", parts[0], parts[1]);
if let Some(opts) = parts.get(2) {
out.push(':');
out.push_str(opts);
}
out
} else {
s.clone()
}
}
VolumeMount::Long {
source,
target,
read_only,
bind,
volume,
..
} => {
let src = source.clone().unwrap_or_default();
let src = if declared_volumes.contains(&src.as_str()) {
format!("{src}.volume")
} else {
src
};
let mut opts: Vec<String> = Vec::new();
if *read_only == Some(true) {
opts.push("ro".to_string());
}
if let Some(b) = bind {
if let Some(selinux) = &b.selinux {
opts.push(selinux.clone());
}
if let Some(propagation) = &b.propagation {
opts.push(propagation.clone());
}
}
if let Some(v) = volume {
if v.nocopy == Some(true) {
opts.push("nocopy".to_string());
}
}
let mut out = if src.is_empty() {
target.clone()
} else {
format!("{src}:{target}")
};
if !opts.is_empty() {
out.push(':');
out.push_str(&opts.join(","));
}
out
}
}
}
pub(super) fn render_command(command: &Command) -> String {
match command {
Command::Shell(s) => escape_exec_control(s),
Command::Exec(parts) => parts
.iter()
.map(|p| quote_exec_arg(p))
.collect::<Vec<_>>()
.join(" "),
}
}
fn escape_exec_control(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
out
}
fn quote_exec_arg(arg: &str) -> String {
let needs_quoting = arg.is_empty()
|| arg
.chars()
.any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '\\' | '$' | '%' | ';'));
if needs_quoting {
format!("\"{}\"", escape_exec_control(arg).replace('"', "\\\""))
} else {
arg.to_string()
}
}
pub(super) fn render_restart(restart: &RestartPolicy) -> String {
match restart {
RestartPolicy::No => "no".to_string(),
RestartPolicy::Always => "always".to_string(),
RestartPolicy::UnlessStopped => "always".to_string(),
RestartPolicy::OnFailure { .. } => "on-failure".to_string(),
}
}
pub(super) fn sorted_pairs(
map: std::collections::HashMap<String, Option<String>>,
) -> Vec<(String, Option<String>)> {
let sorted: BTreeMap<_, _> = map.into_iter().collect();
sorted.into_iter().collect()
}
pub(super) fn sorted_label_pairs(
map: std::collections::HashMap<String, String>,
) -> Vec<(String, String)> {
let sorted: BTreeMap<_, _> = map.into_iter().collect();
sorted.into_iter().collect()
}
pub(super) fn sanitize_value(value: &str) -> String {
value.chars().filter(|c| !c.is_control()).collect()
}
pub(super) fn safe_unit_stem(name: &str) -> String {
let mut stem: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
if stem.is_empty() || stem.starts_with('.') {
stem.insert(0, '_');
}
stem
}
pub(super) struct Section {
name: &'static str,
lines: Vec<String>,
}
impl Section {
pub(super) fn new(name: &'static str) -> Self {
Section {
name,
lines: Vec::new(),
}
}
pub(super) fn add(&mut self, key: &str, value: String) {
self.lines.push(format!("{key}={}", sanitize_value(&value)));
}
pub(super) fn is_empty(&self) -> bool {
self.lines.is_empty()
}
pub(super) fn render(&self) -> String {
let mut s = format!("[{}]\n", self.name);
for line in &self.lines {
s.push_str(line);
s.push('\n');
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_unit_stem_neutralizes_traversal_and_control_chars() {
assert_eq!(safe_unit_stem("web"), "web");
assert_eq!(safe_unit_stem("db-data_1.x"), "db-data_1.x");
assert_eq!(safe_unit_stem("../../etc/passwd"), "_.._.._etc_passwd");
assert_eq!(safe_unit_stem("/abs"), "_abs");
assert_eq!(safe_unit_stem(".hidden"), "_.hidden");
assert_eq!(safe_unit_stem(""), "_");
assert!(!safe_unit_stem("a\nb").contains('\n'));
}
#[test]
fn sanitize_value_strips_control_characters() {
assert_eq!(sanitize_value("plain"), "plain");
assert_eq!(sanitize_value("a\nb\tc\r"), "abc");
}
#[test]
fn render_command_exec_quotes_args_with_whitespace() {
let cmd = Command::Exec(vec![
"server".to_string(),
"--port".to_string(),
"9000".to_string(),
]);
assert_eq!(render_command(&cmd), "server --port 9000");
let cmd = Command::Exec(vec!["echo".to_string(), "hello world".to_string()]);
assert_eq!(render_command(&cmd), "echo \"hello world\"");
}
#[test]
fn render_command_exec_multiline_arg_stays_one_line() {
let cmd = Command::Exec(vec![
"sh".to_string(),
"-c".to_string(),
"mkdir -p /www\necho hi > /www/index.html\nexec httpd".to_string(),
]);
let out = render_command(&cmd);
assert!(
!out.contains('\n'),
"rendered Exec must be a single line: {out}"
);
assert_eq!(
out,
"sh -c \"mkdir -p /www\\necho hi > /www/index.html\\nexec httpd\""
);
}
#[test]
fn render_command_exec_escapes_quotes_and_backslashes() {
let cmd = Command::Exec(vec![
"sh".to_string(),
"-c".to_string(),
"printf '%s' \"a\\b\"".to_string(),
]);
let out = render_command(&cmd);
assert!(!out.contains('\n'));
assert!(out.starts_with("sh -c \""));
assert!(out.contains("\\\""));
assert!(out.contains("\\\\"));
}
#[test]
fn render_command_shell_neutralizes_newlines() {
let cmd = Command::Shell("echo a\necho b".to_string());
let out = render_command(&cmd);
assert!(!out.contains('\n'));
assert_eq!(out, "echo a\\necho b");
assert_eq!(
render_command(&Command::Shell("echo hi".to_string())),
"echo hi"
);
}
}