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,
))
}
#[cfg(test)]
mod tests {
use super::{config_hash, resolve_links, resolve_volume_name};
use crate::parse_str;
#[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");
std::env::set_var("HOME", "/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",
);
}
}