use std::collections::HashMap;
use std::path::Path;
use crate::compose::types::{ComposeFile, Service};
use crate::env_file;
use crate::error::{ComposeError, Result};
pub(super) fn resolve_volume_name(reference: &str, project: &str, file: &ComposeFile) -> String {
match file.volumes.get(reference) {
Some(cfg) => {
if let Some(name) = cfg.as_ref().and_then(|c| c.name.as_deref()) {
name.to_string()
} else if cfg.as_ref().and_then(|c| c.external).unwrap_or(false) {
reference.to_string()
} else {
format!("{project}_{reference}")
}
}
None => reference.to_string(),
}
}
pub(crate) fn resolve_bind_source(src: &str, base_dir: &Path) -> String {
if src.is_empty() {
return src.to_string();
}
let expanded = if let Some(rest) = src.strip_prefix("~/") {
match home_dir() {
Some(home) => home.join(rest).to_string_lossy().into_owned(),
None => src.to_string(),
}
} else if src == "~" {
home_dir()
.map(|h| h.to_string_lossy().into_owned())
.unwrap_or_else(|| src.to_string())
} else {
src.to_string()
};
if Path::new(&expanded).is_absolute() {
expanded
} else {
base_dir.join(&expanded).to_string_lossy().into_owned()
}
}
fn home_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.filter(|v| !v.is_empty())
.map(std::path::PathBuf::from)
}
pub(super) fn resolve_links(service: &Service, file: &ComposeFile, project: &str) -> Vec<String> {
let mut links: Vec<String> = service
.links
.iter()
.map(|link| {
let (target, alias) = link.split_once(':').unwrap_or((link, link));
let container = file
.services
.get(target)
.map(|svc| {
svc.container_name
.clone()
.unwrap_or_else(|| format!("{project}-{target}"))
})
.unwrap_or_else(|| target.to_string());
format!("{container}:{alias}")
})
.collect();
links.extend(service.external_links.iter().cloned());
links
}
pub(crate) fn config_hash(service: &Service, file: &ComposeFile) -> Result<String> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let serialized = serde_json::to_value(service)
.and_then(|v| serde_json::to_vec(&v))
.map_err(|e| ComposeError::Unsupported(format!("cannot hash service config: {e}")))?;
hasher.update(&serialized);
for secret_ref in &service.secrets {
if let Some(def) = file.secrets.get(secret_ref.source()) {
hash_inline_payload(
&mut hasher,
b"secret",
secret_ref.source(),
def.content.as_deref(),
def.environment.as_deref(),
);
}
}
for config_ref in &service.configs {
if let Some(def) = file.configs.get(config_ref.source()) {
hash_inline_payload(
&mut hasher,
b"config",
config_ref.source(),
def.content.as_deref(),
def.environment.as_deref(),
);
}
}
Ok(hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect())
}
fn hash_inline_payload(
hasher: &mut sha2::Sha256,
kind: &[u8],
name: &str,
content: Option<&str>,
environment: Option<&str>,
) {
use sha2::Digest;
let payload = match (content, environment) {
(Some(c), _) => Some(c.as_bytes().to_vec()),
(None, Some(var)) => Some(std::env::var(var).unwrap_or_default().into_bytes()),
(None, None) => None,
};
if let Some(payload) = payload {
hasher.update(kind);
hasher.update(name.as_bytes());
hasher.update((payload.len() as u64).to_le_bytes());
hasher.update(&payload);
}
}
pub(super) fn build_env(service: &Service, base_dir: &Path) -> Result<Vec<String>> {
let entries = service.env_file.to_entries();
let env_file_vars = if !entries.is_empty() {
env_file::load_env_file_entries(&entries, base_dir)?
} else {
HashMap::new()
};
Ok(env_file::merge_env(
service.environment.to_map(),
env_file_vars,
))
}
pub(super) fn resolve_stop_signal(signal: &str) -> Result<i64> {
let trimmed = signal.trim();
if let Ok(num) = trimmed.parse::<i64>() {
return Ok(num);
}
let upper = trimmed.to_ascii_uppercase();
let name = upper.strip_prefix("SIG").unwrap_or(&upper);
signal_number(name)
.ok_or_else(|| ComposeError::Unsupported(format!("unknown stop_signal '{signal}'")))
}
fn signal_number(name: &str) -> Option<i64> {
let n = match name {
"HUP" => 1,
"INT" => 2,
"QUIT" => 3,
"ILL" => 4,
"TRAP" => 5,
"ABRT" | "IOT" => 6,
"BUS" => 7,
"FPE" => 8,
"KILL" => 9,
"USR1" => 10,
"SEGV" => 11,
"USR2" => 12,
"PIPE" => 13,
"ALRM" => 14,
"TERM" => 15,
"STKFLT" => 16,
"CHLD" | "CLD" => 17,
"CONT" => 18,
"STOP" => 19,
"TSTP" => 20,
"TTIN" => 21,
"TTOU" => 22,
"URG" => 23,
"XCPU" => 24,
"XFSZ" => 25,
"VTALRM" => 26,
"PROF" => 27,
"WINCH" => 28,
"IO" | "POLL" => 29,
"PWR" => 30,
"SYS" => 31,
_ => return None,
};
Some(n)
}
#[cfg(test)]
mod tests {
use super::{config_hash, resolve_links, resolve_stop_signal, resolve_volume_name};
use crate::parse_str;
#[test]
fn stop_signal_resolves_names_numbers_and_prefixes() {
assert_eq!(resolve_stop_signal("SIGTERM").unwrap(), 15);
assert_eq!(resolve_stop_signal("TERM").unwrap(), 15);
assert_eq!(resolve_stop_signal("sigterm").unwrap(), 15);
assert_eq!(resolve_stop_signal("SIGKILL").unwrap(), 9);
assert_eq!(resolve_stop_signal("hup").unwrap(), 1);
assert_eq!(resolve_stop_signal("SIGUSR1").unwrap(), 10);
assert_eq!(resolve_stop_signal("SIGUSR2").unwrap(), 12);
assert_eq!(resolve_stop_signal("15").unwrap(), 15);
assert_eq!(resolve_stop_signal(" 9 ").unwrap(), 9);
}
#[test]
fn stop_signal_unknown_name_errors() {
let err = resolve_stop_signal("SIGNOPE").unwrap_err();
assert!(err.to_string().contains("SIGNOPE"), "got: {err}");
}
#[test]
fn links_resolve_to_container_names_external_links_verbatim() {
let file = parse_str(
"services:\n db:\n image: x\n web:\n image: x\n links:\n - db\n - db:primary\n external_links:\n - legacy_db:db\n",
)
.unwrap();
let links = resolve_links(&file.services["web"], &file, "proj");
assert!(links.contains(&"proj-db:db".to_string()));
assert!(links.contains(&"proj-db:primary".to_string()));
assert!(links.contains(&"legacy_db:db".to_string()));
}
#[test]
fn links_honour_custom_container_name() {
let file = parse_str(
"services:\n db:\n image: x\n container_name: my-db\n web:\n image: x\n links:\n - db\n",
)
.unwrap();
let links = resolve_links(&file.services["web"], &file, "proj");
assert_eq!(links, vec!["my-db:db".to_string()]);
}
#[test]
#[cfg(unix)]
fn bind_source_resolution() {
use super::resolve_bind_source;
use std::path::Path;
let base = Path::new("/srv/app");
assert_eq!(resolve_bind_source("/abs/path", base), "/abs/path");
assert_eq!(resolve_bind_source("./data", base), "/srv/app/./data");
assert_eq!(resolve_bind_source("data", base), "/srv/app/data");
temp_env::with_var("HOME", Some("/home/u"), || {
assert_eq!(resolve_bind_source("~/x", base), "/home/u/x");
assert_eq!(resolve_bind_source("~", base), "/home/u");
});
}
#[test]
fn volume_name_resolution() {
let f = parse_str(
"services:\n s:\n image: x\nvolumes:\n data:\n ext:\n external: true\n custom:\n name: my-vol\n",
)
.unwrap();
assert_eq!(resolve_volume_name("data", "proj", &f), "proj_data");
assert_eq!(resolve_volume_name("ext", "proj", &f), "ext");
assert_eq!(resolve_volume_name("custom", "proj", &f), "my-vol");
assert_eq!(resolve_volume_name("anon", "proj", &f), "anon");
}
#[test]
fn config_hash_is_stable_and_sensitive() {
let a = parse_str("services:\n web:\n image: nginx:1.27\n").unwrap();
let b = parse_str("services:\n web:\n image: nginx:1.27\n").unwrap();
let c = parse_str("services:\n web:\n image: nginx:1.28\n").unwrap();
let ha = config_hash(&a.services["web"], &a).unwrap();
let hb = config_hash(&b.services["web"], &b).unwrap();
let hc = config_hash(&c.services["web"], &c).unwrap();
assert_eq!(ha, hb, "same config produces the same hash");
assert_ne!(ha, hc, "a changed image produces a different hash");
assert_eq!(ha.len(), 64, "sha-256 hex is 64 chars");
}
#[test]
fn config_hash_tracks_inline_secret_content() {
let a = parse_str(
"services:\n web:\n image: x\n secrets: [tok]\nsecrets:\n tok:\n content: v1\n",
)
.unwrap();
let b = parse_str(
"services:\n web:\n image: x\n secrets: [tok]\nsecrets:\n tok:\n content: v2\n",
)
.unwrap();
assert_ne!(
config_hash(&a.services["web"], &a).unwrap(),
config_hash(&b.services["web"], &b).unwrap(),
"changed inline secret content must change the hash",
);
}
#[test]
fn config_hash_ignores_external_secret_identity() {
let a = parse_str(
"services:\n web:\n image: x\n secrets: [tok]\nsecrets:\n tok:\n external: true\n",
)
.unwrap();
let b = parse_str(
"services:\n web:\n image: x\n secrets: [tok]\nsecrets:\n tok:\n external: true\n name: other\n",
)
.unwrap();
assert_eq!(
config_hash(&a.services["web"], &a).unwrap(),
config_hash(&b.services["web"], &b).unwrap(),
);
}
#[test]
fn config_hash_stable_despite_map_field_order() {
let a = parse_str(
"services:\n web:\n image: x\n storage_opt:\n size: \"10G\"\n foo: bar\n baz: qux\n",
)
.unwrap();
let b = parse_str(
"services:\n web:\n image: x\n storage_opt:\n baz: qux\n size: \"10G\"\n foo: bar\n",
)
.unwrap();
assert_eq!(
config_hash(&a.services["web"], &a).unwrap(),
config_hash(&b.services["web"], &b).unwrap(),
"hash must be independent of storage_opt key order",
);
}
}