pub mod build;
pub mod deploy;
pub mod develop;
pub mod env;
pub mod lifecycle;
pub mod network;
pub mod ports;
pub mod primitives;
pub mod resources;
pub mod service;
pub mod volume;
pub use build::*;
pub use deploy::*;
pub use develop::*;
pub use env::*;
pub use lifecycle::*;
pub use network::*;
pub use ports::*;
pub use primitives::*;
pub use resources::*;
pub use service::*;
pub use volume::*;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct SecretConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub driver_opts: HashMap<String, String>,
#[serde(default)]
pub labels: Labels,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_driver: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct ConfigConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default)]
pub labels: Labels,
#[serde(skip_serializing_if = "Option::is_none")]
pub driver: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub driver_opts: HashMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_driver: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct ModelConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_size: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_flags: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub model_variables: HashMap<String, String>,
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub unknown: IndexMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[non_exhaustive]
pub struct ComposeFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<IncludeConfig>,
#[serde(default)]
pub services: IndexMap<String, Service>,
#[serde(default)]
pub volumes: IndexMap<String, Option<VolumeConfig>>,
#[serde(default)]
pub networks: IndexMap<String, Option<NetworkConfig>>,
#[serde(default)]
pub secrets: IndexMap<String, SecretConfig>,
#[serde(default)]
pub configs: IndexMap<String, ConfigConfig>,
#[serde(default)]
pub models: IndexMap<String, ModelConfig>,
#[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
pub extensions: IndexMap<String, serde_yaml::Value>,
}
pub const REDACTED_PLACEHOLDER: &str = "<redacted>";
impl ComposeFile {
pub fn redact_inline_content(&mut self) {
for secret in self.secrets.values_mut() {
if secret.content.is_some() {
secret.content = Some(REDACTED_PLACEHOLDER.to_string());
}
}
for config in self.configs.values_mut() {
if config.content.is_some() {
config.content = Some(REDACTED_PLACEHOLDER.to_string());
}
}
}
pub fn strip_ignored_unknown_keys(&mut self) {
retain_extension_keys(&mut self.extensions);
for model in self.models.values_mut() {
retain_extension_keys(&mut model.unknown);
}
for net in self.networks.values_mut().flatten() {
retain_extension_keys(&mut net.unknown);
if let Some(ipam) = net.ipam.as_mut() {
retain_extension_keys(&mut ipam.unknown);
}
}
for vol in self.volumes.values_mut().flatten() {
retain_extension_keys(&mut vol.unknown);
}
for svc in self.services.values_mut() {
retain_extension_keys(&mut svc.unknown);
if let Some(hc) = svc.healthcheck.as_mut() {
retain_extension_keys(&mut hc.unknown);
}
if let Some(deploy) = svc.deploy.as_mut() {
retain_extension_keys(&mut deploy.unknown);
}
if let Some(develop) = svc.develop.as_mut() {
for rule in &mut develop.watch {
retain_extension_keys(&mut rule.unknown);
}
}
if let Some(cred) = svc.credential_spec.as_mut() {
retain_extension_keys(&mut cred.unknown);
}
if let Some(provider) = svc.provider.as_mut() {
retain_extension_keys(&mut provider.unknown);
}
}
}
}
fn retain_extension_keys(map: &mut IndexMap<String, serde_yaml::Value>) {
map.retain(|k, _| k.starts_with("x-"));
}
#[cfg(test)]
mod tests {
use crate::parse_str;
#[test]
fn redacts_inline_secret_and_config_content() {
let yaml = r#"
secrets:
inline_secret:
content: super-secret-value
file_secret:
file: ./token.txt
configs:
inline_config:
content: embedded-config-body
env_config:
environment: CONFIG_FROM_ENV
"#;
let mut file = parse_str(yaml).unwrap();
file.redact_inline_content();
assert_eq!(
file.secrets["inline_secret"].content.as_deref(),
Some("<redacted>")
);
assert!(file.secrets["file_secret"].content.is_none());
assert_eq!(
file.secrets["file_secret"].file.as_deref(),
Some("./token.txt")
);
assert_eq!(
file.configs["inline_config"].content.as_deref(),
Some("<redacted>")
);
assert!(file.configs["env_config"].content.is_none());
assert_eq!(
file.configs["env_config"].environment.as_deref(),
Some("CONFIG_FROM_ENV")
);
}
#[test]
fn rendered_config_never_contains_inline_secret_value() {
let yaml = r#"
secrets:
db_password:
content: hunter2-do-not-leak
"#;
let mut file = parse_str(yaml).unwrap();
file.redact_inline_content();
let rendered = serde_yaml::to_string(&file).unwrap();
assert!(!rendered.contains("hunter2-do-not-leak"));
assert!(rendered.contains("<redacted>"));
}
#[test]
fn strip_ignored_unknown_keys_drops_non_extension_at_every_level() {
let yaml = r#"
bogus_top: 1
x-keep-top: ok
services:
web:
image: nginx
bogus_svc: 2
x-keep-svc: ok
deploy:
bogus_deploy: 3
networks:
netA:
bogus_net: 4
volumes:
volA:
bogus_vol: 5
"#;
let mut file = parse_str(yaml).unwrap();
file.strip_ignored_unknown_keys();
let out = serde_yaml::to_string(&file).unwrap();
for dropped in [
"bogus_top",
"bogus_svc",
"bogus_deploy",
"bogus_net",
"bogus_vol",
] {
assert!(
!out.contains(dropped),
"{dropped} should be stripped: {out}"
);
}
assert!(out.contains("x-keep-top"), "top x- kept: {out}");
assert!(out.contains("x-keep-svc"), "svc x- kept: {out}");
}
}