use crate::compose::types::{ComposeFile, Service, ServiceConfigRef, ServiceSecretRef};
use crate::error::{ComposeError, Result};
use super::super::staging;
pub(super) const MAX_SECRET_BYTES: usize = 512_000;
pub(super) struct NativePlan {
pub(super) source: String,
pub(super) target: String,
pub(super) mode: Option<u32>,
pub(super) uid: Option<u32>,
pub(super) gid: Option<u32>,
pub(super) payload: Option<Vec<u8>>,
}
enum Source {
Bind,
Inline(String, Vec<u8>),
External(String),
}
pub(super) fn collect_native_plans(
project: &str,
service: &Service,
file: &ComposeFile,
) -> Result<Vec<NativePlan>> {
let mut plans = Vec::new();
for secret_ref in &service.secrets {
let (name, target_override, mode, uid, gid) = secret_ref_parts(secret_ref);
if let Some(def) = file.secrets.get(&name) {
let source = resolve_source(
project,
"secret",
&name,
def.content.as_deref(),
def.environment.as_deref(),
def.external == Some(true),
def.name.as_deref(),
)?;
push_plan(
&mut plans,
source,
target_override.unwrap_or(name),
mode,
uid,
gid,
)?;
}
}
for config_ref in &service.configs {
let (name, target_override, mode, uid, gid) = config_ref_parts(config_ref);
if let Some(def) = file.configs.get(&name) {
let source = resolve_source(
project,
"config",
&name,
def.content.as_deref(),
def.environment.as_deref(),
def.external == Some(true),
def.name.as_deref(),
)?;
let target = target_override.unwrap_or_else(|| format!("/{name}"));
push_plan(&mut plans, source, target, mode, uid, gid)?;
}
}
Ok(plans)
}
fn resolve_source(
project: &str,
kind: &str,
name: &str,
content: Option<&str>,
environment: Option<&str>,
external: bool,
external_name: Option<&str>,
) -> Result<Source> {
if external {
return Ok(Source::External(external_name.unwrap_or(name).to_string()));
}
let is_inline = content.is_some() || environment.is_some();
if is_inline && !staging::is_safe_project_name(name) {
return Err(ComposeError::Unsupported(format!(
"{kind} name {name:?} must be ASCII alphanumeric/dash/underscore/dot, \
at most 128 chars, and not start with a dot"
)));
}
if let Some(content) = content {
return Ok(Source::Inline(
scoped_name(project, kind, name),
content.as_bytes().to_vec(),
));
}
if let Some(env_var) = environment {
let value = std::env::var(env_var).map_err(|_| {
ComposeError::Unsupported(format!(
"{kind} '{name}' references env var '{env_var}' which is not set"
))
})?;
return Ok(Source::Inline(
scoped_name(project, kind, name),
value.into_bytes(),
));
}
Ok(Source::Bind)
}
fn push_plan(
plans: &mut Vec<NativePlan>,
source: Source,
target: String,
mode: Option<u32>,
uid: Option<String>,
gid: Option<String>,
) -> Result<()> {
let (source, payload) = match source {
Source::Bind => return Ok(()),
Source::Inline(s, p) => (s, Some(p)),
Source::External(s) => (s, None),
};
if let Some(m) = mode {
staging::reject_dangerous_secret_mode(m, &source)?;
}
plans.push(NativePlan {
source,
target,
mode,
uid: uid.and_then(|s| s.parse().ok()),
gid: gid.and_then(|s| s.parse().ok()),
payload,
});
Ok(())
}
pub(super) fn scoped_name(project: &str, kind: &str, name: &str) -> String {
format!("{project}_{kind}_{name}")
}
pub(super) fn check_secret_size(name: &str, len: usize) -> Result<()> {
if len == 0 || len >= MAX_SECRET_BYTES {
return Err(ComposeError::Unsupported(format!(
"secret '{name}' is {len} bytes; a Podman secret payload must be \
larger than 0 and smaller than {MAX_SECRET_BYTES} bytes"
)));
}
Ok(())
}
pub(super) fn is_inline_source(
external: Option<bool>,
content: Option<&str>,
environment: Option<&str>,
) -> bool {
external != Some(true) && (content.is_some() || environment.is_some())
}
pub(super) fn ref_name_target(source: &str, target: Option<&str>) -> (String, Option<String>) {
(source.to_string(), target.map(str::to_string))
}
fn secret_ref_parts(
r: &ServiceSecretRef,
) -> (
String,
Option<String>,
Option<u32>,
Option<String>,
Option<String>,
) {
match r {
ServiceSecretRef::Short(s) => (s.clone(), None, None, None, None),
ServiceSecretRef::Long {
source,
target,
mode,
uid,
gid,
} => (
source.clone(),
target.clone(),
*mode,
uid.clone(),
gid.clone(),
),
}
}
fn config_ref_parts(
r: &ServiceConfigRef,
) -> (
String,
Option<String>,
Option<u32>,
Option<String>,
Option<String>,
) {
match r {
ServiceConfigRef::Short(s) => (s.clone(), None, None, None, None),
ServiceConfigRef::Long {
source,
target,
mode,
uid,
gid,
} => (
source.clone(),
target.clone(),
*mode,
uid.clone(),
gid.clone(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plans(yaml: &str) -> Vec<NativePlan> {
let file = crate::compose::parse_str_raw(yaml).unwrap();
collect_native_plans("proj", &file.services["web"], &file).unwrap()
}
#[test]
fn file_secret_is_not_a_native_secret() {
let p = plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n file: ./tok.txt\n");
assert!(p.is_empty());
}
#[test]
fn inline_content_secret_is_scoped_native_with_payload() {
let p = plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n content: supersecret\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "proj_secret_tok");
assert_eq!(p[0].target, "tok");
assert_eq!(p[0].payload.as_deref(), Some(b"supersecret".as_slice()));
}
#[test]
fn inline_content_config_is_scoped_native_with_absolute_target() {
let p = plans("services:\n web:\n image: nginx\n configs: [cfg]\nconfigs:\n cfg:\n content: key=value\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "proj_config_cfg");
assert_eq!(p[0].target, "/cfg");
assert_eq!(p[0].payload.as_deref(), Some(b"key=value".as_slice()));
}
#[test]
fn env_secret_payload_comes_from_environment() {
temp_env::with_var("PODUP_TEST_SECRET", Some("env-value"), || {
let p = plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n environment: PODUP_TEST_SECRET\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "proj_secret_tok");
assert_eq!(p[0].payload.as_deref(), Some(b"env-value".as_slice()));
});
}
#[test]
fn env_secret_missing_var_errors() {
temp_env::with_var("PODUP_TEST_MISSING", None::<&str>, || {
let file = crate::compose::parse_str_raw("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n environment: PODUP_TEST_MISSING\n").unwrap();
assert!(collect_native_plans("proj", &file.services["web"], &file).is_err());
});
}
#[test]
fn external_secret_keeps_compose_name_unscoped_no_payload() {
let p = plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n external: true\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "tok");
assert_eq!(p[0].target, "tok");
assert!(p[0].payload.is_none());
}
#[test]
fn external_secret_long_form_maps_source_target_and_perms() {
let p = plans("services:\n web:\n image: nginx\n secrets:\n - source: tok\n target: app_tok\n uid: \"100\"\n gid: \"101\"\n mode: 256\nsecrets:\n tok:\n external: true\n name: real_tok\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "real_tok");
assert_eq!(p[0].target, "app_tok");
assert_eq!(p[0].uid, Some(100));
assert_eq!(p[0].gid, Some(101));
assert_eq!(p[0].mode, Some(256));
}
#[test]
fn external_config_becomes_native_with_absolute_default_target() {
let p = plans("services:\n web:\n image: nginx\n configs: [cfg]\nconfigs:\n cfg:\n external: true\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "cfg");
assert_eq!(p[0].target, "/cfg");
}
#[test]
fn non_numeric_uid_drops_to_default() {
let p = plans("services:\n web:\n image: nginx\n secrets:\n - source: tok\n uid: appuser\nsecrets:\n tok:\n external: true\n");
assert_eq!(p.len(), 1);
assert!(p[0].uid.is_none());
}
#[test]
fn native_secret_rejects_setuid_mode() {
let file = crate::compose::parse_str_raw("services:\n web:\n image: nginx\n secrets:\n - source: tok\n mode: 2048\nsecrets:\n tok:\n external: true\n").unwrap();
assert!(collect_native_plans("proj", &file.services["web"], &file).is_err());
}
#[test]
fn native_secret_rejects_execute_mode() {
let file = crate::compose::parse_str_raw("services:\n web:\n image: nginx\n secrets:\n - source: tok\n mode: 511\nsecrets:\n tok:\n external: true\n").unwrap();
assert!(collect_native_plans("proj", &file.services["web"], &file).is_err());
}
#[test]
fn native_config_rejects_setgid_mode() {
let file = crate::compose::parse_str_raw("services:\n web:\n image: nginx\n configs:\n - source: cfg\n mode: 1024\nconfigs:\n cfg:\n external: true\n").unwrap();
assert!(collect_native_plans("proj", &file.services["web"], &file).is_err());
}
#[test]
fn inline_secret_rejects_dangerous_mode() {
let file = crate::compose::parse_str_raw("services:\n web:\n image: nginx\n secrets:\n - source: tok\n mode: 511\nsecrets:\n tok:\n content: data\n").unwrap();
assert!(collect_native_plans("proj", &file.services["web"], &file).is_err());
}
#[test]
fn native_secret_allows_world_readable_mode() {
let p = plans("services:\n web:\n image: nginx\n secrets:\n - source: tok\n mode: 292\nsecrets:\n tok:\n external: true\n");
assert_eq!(p[0].mode, Some(0o444));
}
#[test]
fn empty_and_oversized_payloads_rejected() {
assert!(check_secret_size("s", 0).is_err());
assert!(check_secret_size("s", MAX_SECRET_BYTES).is_err());
assert!(check_secret_size("s", MAX_SECRET_BYTES - 1).is_ok());
assert!(check_secret_size("s", 1).is_ok());
}
#[test]
fn inline_secret_with_unsafe_name_is_rejected() {
let file = crate::compose::parse_str_raw(
"services:\n web:\n image: x\n secrets: ['../evil']\nsecrets:\n '../evil':\n content: data\n",
)
.unwrap();
assert!(collect_native_plans("proj", &file.services["web"], &file).is_err());
}
#[test]
fn is_inline_source_classifies_sources() {
assert!(is_inline_source(None, Some("x"), None));
assert!(is_inline_source(None, None, Some("VAR")));
assert!(!is_inline_source(Some(true), Some("x"), None));
assert!(!is_inline_source(None, None, None));
}
}