use std::path::{Path, PathBuf};
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) enum Payload {
Inline(Vec<u8>),
File(PathBuf),
}
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<Payload>,
}
struct SourceDef<'a> {
content: Option<&'a str>,
environment: Option<&'a str>,
file_source: Option<&'a str>,
external: bool,
external_name: Option<&'a str>,
}
enum Source {
Inline(String, Vec<u8>),
File(String, PathBuf),
External(String),
}
pub(super) fn collect_native_plans(
project: &str,
service: &Service,
file: &ComposeFile,
base_dir: &Path,
) -> 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,
SourceDef {
content: def.content.as_deref(),
environment: def.environment.as_deref(),
file_source: def.file.as_deref(),
external: def.external == Some(true),
external_name: def.name.as_deref(),
},
base_dir,
)?;
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,
SourceDef {
content: def.content.as_deref(),
environment: def.environment.as_deref(),
file_source: def.file.as_deref(),
external: def.external == Some(true),
external_name: def.name.as_deref(),
},
base_dir,
)?;
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,
def: SourceDef<'_>,
base_dir: &Path,
) -> Result<Option<Source>> {
let SourceDef {
content,
environment,
file_source,
external,
external_name,
} = def;
if external {
return Ok(Some(Source::External(
external_name.unwrap_or(name).to_string(),
)));
}
let podup_created = content.is_some() || environment.is_some() || file_source.is_some();
if podup_created && !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(Some(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(Some(Source::Inline(
scoped_name(project, kind, name),
value.into_bytes(),
)));
}
if let Some(host_path) = file_source {
return Ok(Some(Source::File(
scoped_name(project, kind, name),
PathBuf::from(super::super::container::resolve_bind_source(
host_path, base_dir,
)),
)));
}
Ok(None)
}
fn push_plan(
plans: &mut Vec<NativePlan>,
source: Option<Source>,
target: String,
mode: Option<u32>,
uid: Option<String>,
gid: Option<String>,
) -> Result<()> {
let (source, payload, from_file) = match source {
None => return Ok(()),
Some(Source::Inline(s, p)) => (s, Some(Payload::Inline(p)), false),
Some(Source::File(s, p)) => (s, Some(Payload::File(p)), true),
Some(Source::External(s)) => (s, None, false),
};
let mode = if from_file {
mode
} else {
mode.or(Some(0o444))
};
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_podup_created_source(
external: Option<bool>,
content: Option<&str>,
environment: Option<&str>,
file_source: Option<&str>,
) -> bool {
external != Some(true) && (content.is_some() || environment.is_some() || file_source.is_some())
}
pub(super) fn host_file_secret_mode(path: &Path) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(md) => md.permissions().mode() & 0o666,
Err(_) => 0o444,
}
}
#[cfg(not(unix))]
{
let _ = path;
0o444
}
}
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, Path::new("/base")).unwrap()
}
fn inline_bytes(p: &NativePlan) -> Option<&[u8]> {
match &p.payload {
Some(Payload::Inline(b)) => Some(b),
_ => None,
}
}
#[test]
fn file_secret_is_a_scoped_native_secret_carrying_its_path() {
let p = plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n file: ./tok.txt\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "proj_secret_tok");
assert_eq!(p[0].target, "tok");
assert!(
matches!(&p[0].payload, Some(Payload::File(path)) if path == Path::new("/base/tok.txt"))
);
}
#[test]
fn file_secret_leaves_mode_unset_for_the_host_bits() {
let p = plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok:\n file: ./tok.txt\n");
assert_eq!(p[0].mode, None);
}
#[test]
fn file_secret_honours_an_explicit_mode() {
let p = plans("services:\n web:\n image: nginx\n secrets:\n - source: tok\n mode: 0400\nsecrets:\n tok:\n file: ./tok.txt\n");
assert_eq!(p[0].mode, Some(0o400));
}
#[test]
fn file_config_is_native_with_absolute_default_target() {
let p = plans("services:\n web:\n image: nginx\n configs: [cfg]\nconfigs:\n cfg:\n file: ./cfg.yaml\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].source, "proj_config_cfg");
assert_eq!(p[0].target, "/cfg");
}
#[test]
fn file_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 file: ./tok.txt\n",
)
.unwrap();
assert!(
collect_native_plans("proj", &file.services["web"], &file, Path::new("/base")).is_err()
);
}
#[test]
fn empty_def_contributes_no_plan() {
let p =
plans("services:\n web:\n image: nginx\n secrets: [tok]\nsecrets:\n tok: {}\n");
assert!(p.is_empty());
}
#[cfg(unix)]
#[test]
fn host_file_mode_masks_execute_and_special_bits() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("s.txt");
std::fs::write(&path, b"x").unwrap();
for (host, want) in [
(0o644, 0o644),
(0o600, 0o600),
(0o755, 0o644),
(0o4700, 0o600),
] {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(host)).unwrap();
assert_eq!(host_file_secret_mode(&path), want, "host mode {host:o}");
}
}
#[test]
fn host_file_mode_of_a_missing_file_falls_back_to_0444() {
assert_eq!(
host_file_secret_mode(Path::new("/nonexistent/secret")),
0o444
);
}
#[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!(inline_bytes(&p[0]), 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!(inline_bytes(&p[0]), 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!(inline_bytes(&p[0]), 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, Path::new("/base"))
.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: 0400\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(0o400));
}
#[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, Path::new("/base")).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, Path::new("/base")).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, Path::new("/base")).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, Path::new("/base")).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, Path::new("/base")).is_err()
);
}
#[test]
fn native_secret_without_mode_defaults_to_0444() {
let p = plans("services:\n web:\n image: x\n secrets: [tok]\nsecrets:\n tok:\n content: data\n");
assert_eq!(p.len(), 1);
assert_eq!(p[0].mode, Some(0o444));
}
#[test]
fn secret_mode_leading_zero_is_octal() {
let p = plans("services:\n web:\n image: x\n secrets:\n - source: tok\n mode: 0444\nsecrets:\n tok:\n content: data\n");
assert_eq!(p[0].mode, Some(0o444));
}
#[test]
fn is_podup_created_source_classifies_sources() {
assert!(is_podup_created_source(None, Some("x"), None, None));
assert!(is_podup_created_source(None, None, Some("VAR"), None));
assert!(is_podup_created_source(None, None, None, Some("./tok.txt")));
assert!(!is_podup_created_source(Some(true), Some("x"), None, None));
assert!(!is_podup_created_source(None, None, None, None));
}
}