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) => s.clone(),
Command::Exec(parts) => parts.join(" "),
}
}
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");
}
}