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,
) -> Result<String> {
if reference.is_empty() {
return Ok(String::new());
}
match file.volumes.get(reference) {
Some(cfg) => Ok(
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 => Err(ComposeError::Unsupported(format!(
"service refers to undefined volume \"{reference}\"; declare it under the \
top-level `volumes:` key, or use a bind mount or anonymous volume instead"
))),
}
}
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}-1"))
})
.unwrap_or_else(|| target.to_string());
format!("{container}:{alias}")
})
.collect();
links.extend(service.external_links.iter().cloned());
links
}
pub(super) fn resolve_volumes_from(
service: &Service,
file: &ComposeFile,
project: &str,
) -> Vec<String> {
service
.volumes_from
.iter()
.map(|entry| {
let (reference, mode) = match entry.rsplit_once(':') {
Some((head, tail @ ("ro" | "rw"))) => (head, Some(tail)),
_ => (entry.as_str(), None),
};
let resolved = if let Some(name) = reference.strip_prefix("container:") {
name.to_string()
} else {
let target = reference.strip_prefix("service:").unwrap_or(reference);
file.services
.get(target)
.map(|svc| {
svc.container_name
.clone()
.unwrap_or_else(|| format!("{project}-{target}-1"))
})
.unwrap_or_else(|| target.to_string())
};
match mode {
Some(mode) => format!("{resolved}:{mode}"),
None => resolved,
}
})
.collect()
}
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}'")))
}
const SIGRTMIN: i64 = 34;
const SIGRTMAX: i64 = 64;
fn signal_number(name: &str) -> Option<i64> {
if let Some(n) = realtime_signal_number(name) {
return Some(n);
}
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)
}
fn realtime_signal_number(name: &str) -> Option<i64> {
let (base, rest) = if let Some(rest) = name.strip_prefix("RTMIN") {
(SIGRTMIN, rest)
} else if let Some(rest) = name.strip_prefix("RTMAX") {
(SIGRTMAX, rest)
} else {
return None;
};
let number = if rest.is_empty() {
base
} else {
let (sign, digits) = rest.split_at(1);
let offset: i64 = digits.parse().ok()?;
match (base == SIGRTMIN, sign) {
(true, "+") => base + offset,
(false, "-") => base - offset,
_ => return None,
}
};
(SIGRTMIN..=SIGRTMAX).contains(&number).then_some(number)
}
#[cfg(test)]
mod tests {
use super::{
config_hash, resolve_links, resolve_stop_signal, resolve_volume_name, resolve_volumes_from,
};
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_resolves_realtime_signals() {
assert_eq!(resolve_stop_signal("SIGRTMIN").unwrap(), 34);
assert_eq!(resolve_stop_signal("SIGRTMIN+3").unwrap(), 37);
assert_eq!(resolve_stop_signal("SIGRTMAX").unwrap(), 64);
assert_eq!(resolve_stop_signal("SIGRTMAX-1").unwrap(), 63);
assert_eq!(resolve_stop_signal("RTMIN+3").unwrap(), 37);
}
#[test]
fn stop_signal_rejects_out_of_range_realtime_offset() {
let err = resolve_stop_signal("SIGRTMIN+40").unwrap_err();
assert!(err.to_string().contains("SIGRTMIN+40"), "got: {err}");
assert!(resolve_stop_signal("SIGRTMIN-1").is_err());
assert!(resolve_stop_signal("SIGRTMAX+1").is_err());
}
#[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-1:db".to_string()));
assert!(links.contains(&"proj-db-1: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]
fn volumes_from_resolves_to_container_names() {
let file = parse_str(
"services:\n db:\n image: x\n cache:\n image: x\n container_name: my-cache\n web:\n image: x\n volumes_from:\n - db\n - cache\n",
)
.unwrap();
let resolved = resolve_volumes_from(&file.services["web"], &file, "proj");
assert!(resolved.contains(&"proj-db-1".to_string()));
assert!(resolved.contains(&"my-cache".to_string()));
}
#[test]
fn volumes_from_preserves_access_mode() {
let file = parse_str(
"services:\n db:\n image: x\n web:\n image: x\n volumes_from:\n - db:ro\n - service:db:rw\n",
)
.unwrap();
let resolved = resolve_volumes_from(&file.services["web"], &file, "proj");
assert!(resolved.contains(&"proj-db-1:ro".to_string()));
assert!(resolved.contains(&"proj-db-1:rw".to_string()));
}
#[test]
fn volumes_from_passes_through_container_form_and_unknown() {
let file = parse_str(
"services:\n web:\n image: x\n volumes_from:\n - container:legacy-data\n - container:legacy-data:ro\n - missing\n",
)
.unwrap();
let resolved = resolve_volumes_from(&file.services["web"], &file, "proj");
assert!(resolved.contains(&"legacy-data".to_string()));
assert!(resolved.contains(&"legacy-data:ro".to_string()));
assert!(resolved.contains(&"missing".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");
});
assert_eq!(resolve_bind_source("", base), "");
}
#[test]
#[cfg(unix)]
fn bind_source_tilde_without_home_stays_literal() {
use super::resolve_bind_source;
use std::path::Path;
let base = Path::new("/srv/app");
temp_env::with_vars(
[("HOME", None::<&str>), ("USERPROFILE", None::<&str>)],
|| {
assert_eq!(resolve_bind_source("~/x", base), "/srv/app/~/x");
assert_eq!(resolve_bind_source("~", base), "/srv/app/~");
},
);
}
#[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).unwrap(),
"proj_data"
);
assert_eq!(resolve_volume_name("ext", "proj", &f).unwrap(), "ext");
assert_eq!(resolve_volume_name("custom", "proj", &f).unwrap(), "my-vol");
assert_eq!(resolve_volume_name("", "proj", &f).unwrap(), "");
let err = resolve_volume_name("missing", "proj", &f).unwrap_err();
assert!(err.to_string().contains("missing"), "got: {err}");
}
#[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_tracks_inline_config_content() {
let a = parse_str(
"services:\n web:\n image: x\n configs: [cfg]\nconfigs:\n cfg:\n content: a\n",
)
.unwrap();
let b = parse_str(
"services:\n web:\n image: x\n configs: [cfg]\nconfigs:\n cfg:\n content: b\n",
)
.unwrap();
assert_ne!(
config_hash(&a.services["web"], &a).unwrap(),
config_hash(&b.services["web"], &b).unwrap(),
"changed inline config content must change the hash",
);
}
#[test]
fn config_hash_tracks_environment_sourced_secret() {
let file = parse_str(
"services:\n web:\n image: x\n secrets: [tok]\nsecrets:\n tok:\n environment: PODUP_TEST_SECRET\n",
)
.unwrap();
let with_a = temp_env::with_var("PODUP_TEST_SECRET", Some("alpha"), || {
config_hash(&file.services["web"], &file).unwrap()
});
let with_b = temp_env::with_var("PODUP_TEST_SECRET", Some("beta"), || {
config_hash(&file.services["web"], &file).unwrap()
});
assert_ne!(
with_a, with_b,
"a changed environment-sourced secret value must change the hash",
);
}
#[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",
);
}
}