use std::collections::BTreeMap;
use super::dockerfile::{CopyFlags, Instruction, RunMount, ShellOrExec};
pub(crate) fn expand(s: &str, lookup: &dyn Fn(&str) -> Option<String>) -> String {
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < b.len() {
let c = b[i];
if c == b'\\' && i + 1 < b.len() && b[i + 1] == b'$' {
out.push('$');
i += 2;
continue;
}
if c != b'$' {
out.push(c as char);
i += 1;
continue;
}
if i + 1 < b.len() && b[i + 1] == b'{' {
if let Some(close) = find_byte(b, i + 2, b'}') {
let inner = &s[i + 2..close];
match expand_braced(inner, lookup) {
Some(v) => out.push_str(&v),
None => {
out.push_str("${");
out.push_str(inner);
out.push('}');
}
}
i = close + 1;
continue;
}
out.push('$');
i += 1;
continue;
}
let start = i + 1;
let mut j = start;
while j < b.len() && is_name_byte(b[j], j == start) {
j += 1;
}
if j == start {
out.push('$');
i += 1;
continue;
}
let name = &s[start..j];
match lookup(name) {
Some(v) => out.push_str(&v),
None => {
out.push('$');
out.push_str(name);
}
}
i = j;
}
out
}
fn expand_braced(inner: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Option<String> {
let name_end = inner.find([':', '-', '+']).unwrap_or(inner.len());
let name = &inner[..name_end];
let rest = &inner[name_end..];
let val = lookup(name);
let set_nonempty = val.as_deref().map(|v| !v.is_empty()).unwrap_or(false);
if rest.is_empty() {
return val;
}
let rest = rest.strip_prefix(':').unwrap_or(rest);
let (op, word) = match rest.as_bytes().first() {
Some(b'-') => ('-', &rest[1..]),
Some(b'+') => ('+', &rest[1..]),
_ => return val,
};
Some(match op {
'-' if set_nonempty => val.unwrap_or_default(),
'-' => expand(word, lookup),
'+' if set_nonempty => expand(word, lookup),
_ => String::new(),
})
}
fn find_byte(b: &[u8], from: usize, target: u8) -> Option<usize> {
(from..b.len()).find(|&k| b[k] == target)
}
fn is_name_byte(c: u8, first: bool) -> bool {
c == b'_' || c.is_ascii_alphabetic() || (!first && c.is_ascii_digit())
}
pub(crate) fn resolve_stage_instructions(
instrs: &[Instruction],
global_args: &[(String, Option<String>)],
build_args: &BTreeMap<String, String>,
) -> Vec<Instruction> {
let mut env: BTreeMap<String, String> = BTreeMap::new();
let mut args: BTreeMap<String, String> = BTreeMap::new();
for (name, default) in global_args {
if let Some(v) = build_args.get(name).cloned().or_else(|| default.clone()) {
args.insert(name.clone(), v);
}
}
let mut out = Vec::with_capacity(instrs.len());
for instr in instrs {
match instr {
Instruction::Arg { name, default } => {
let resolved = build_args
.get(name)
.cloned()
.or_else(|| default.as_ref().map(|d| expand(d, &lk(&env, &args))));
args.insert(name.clone(), resolved.clone().unwrap_or_default());
out.push(Instruction::Arg {
name: name.clone(),
default: resolved,
});
}
Instruction::Env(pairs) => {
let mut new_pairs = Vec::with_capacity(pairs.len());
for (k, v) in pairs {
let ev = expand(v, &lk(&env, &args));
env.insert(k.clone(), ev.clone());
new_pairs.push((k.clone(), ev));
}
out.push(Instruction::Env(new_pairs));
}
other => out.push(expand_instr(other, &lk(&env, &args))),
}
}
out
}
fn lk<'a>(
env: &'a BTreeMap<String, String>,
args: &'a BTreeMap<String, String>,
) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| env.get(name).or_else(|| args.get(name)).cloned()
}
fn expand_instr(instr: &Instruction, lookup: &dyn Fn(&str) -> Option<String>) -> Instruction {
let ev = |s: &str| expand(s, lookup);
let evv = |v: &[String]| v.iter().map(|s| ev(s)).collect::<Vec<_>>();
match instr {
Instruction::Run { run, mounts } => Instruction::Run {
run: expand_soe(run, lookup),
mounts: mounts.iter().map(|m| expand_mount(m, lookup)).collect(),
},
Instruction::Copy {
sources,
dest,
flags,
} => Instruction::Copy {
sources: evv(sources),
dest: ev(dest),
flags: expand_flags(flags, lookup),
},
Instruction::Add {
sources,
dest,
flags,
} => Instruction::Add {
sources: evv(sources),
dest: ev(dest),
flags: expand_flags(flags, lookup),
},
Instruction::Workdir(d) => Instruction::Workdir(ev(d)),
Instruction::User(u) => Instruction::User(ev(u)),
Instruction::Expose(p) => Instruction::Expose(evv(p)),
Instruction::Label(pairs) => {
Instruction::Label(pairs.iter().map(|(k, v)| (k.clone(), ev(v))).collect())
}
Instruction::Entrypoint(e) => Instruction::Entrypoint(expand_soe(e, lookup)),
Instruction::Cmd(c) => Instruction::Cmd(expand_soe(c, lookup)),
Instruction::Volume(v) => Instruction::Volume(evv(v)),
Instruction::StopSignal(s) => Instruction::StopSignal(ev(s)),
Instruction::Shell(_) | Instruction::Arg { .. } | Instruction::Env(_) => instr.clone(),
}
}
fn expand_soe(soe: &ShellOrExec, lookup: &dyn Fn(&str) -> Option<String>) -> ShellOrExec {
match soe {
ShellOrExec::Shell(s) => ShellOrExec::Shell(expand(s, lookup)),
ShellOrExec::Exec(a) => ShellOrExec::Exec(a.iter().map(|s| expand(s, lookup)).collect()),
}
}
fn expand_flags(f: &CopyFlags, lookup: &dyn Fn(&str) -> Option<String>) -> CopyFlags {
CopyFlags {
from: f.from.clone(),
chown: f.chown.as_ref().map(|s| expand(s, lookup)),
chmod: f.chmod.as_ref().map(|s| expand(s, lookup)),
}
}
fn expand_mount(m: &RunMount, lookup: &dyn Fn(&str) -> Option<String>) -> RunMount {
let e = |o: &Option<String>| o.as_ref().map(|s| expand(s, lookup));
RunMount {
kind: m.kind.clone(),
target: e(&m.target),
source: e(&m.source),
id: e(&m.id),
from: m.from.clone(),
readonly: m.readonly,
required: m.required,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn lookup_from(m: &BTreeMap<String, String>) -> impl Fn(&str) -> Option<String> + '_ {
move |n: &str| m.get(n).cloned()
}
#[test]
fn basic_forms() {
let m = map(&[("V", "1.2"), ("EMPTY", "")]);
let l = lookup_from(&m);
assert_eq!(expand("v$V", &l), "v1.2");
assert_eq!(expand("v${V}x", &l), "v1.2x");
assert_eq!(expand("a${EMPTY}b", &l), "ab");
assert_eq!(expand("$MISSING.", &l), "$MISSING.");
assert_eq!(expand("a${MISSING}b", &l), "a${MISSING}b");
}
#[test]
fn undeclared_vars_pass_through_to_the_shell() {
let m = map(&[("V", "1")]);
let l = lookup_from(&m);
assert_eq!(
expand(r#"D="/root/.cache/x/bin" && mkdir -p "$D""#, &l),
r#"D="/root/.cache/x/bin" && mkdir -p "$D""#
);
assert_eq!(expand("X=hi && echo [$X]", &l), "X=hi && echo [$X]");
assert_eq!(expand("echo ${X}", &l), "echo ${X}");
assert_eq!(expand("v$V then $X", &l), "v1 then $X");
assert_eq!(expand("${V}-${X}", &l), "1-${X}");
assert_eq!(expand("${X:-fallback}", &l), "fallback");
assert_eq!(expand("${X:+set}", &l), "");
assert_eq!(expand(r"\$X stays", &l), "$X stays");
}
#[test]
fn default_and_alt_modifiers() {
let m = map(&[("SET", "x"), ("EMPTY", "")]);
let l = lookup_from(&m);
assert_eq!(expand("${SET:-d}", &l), "x");
assert_eq!(expand("${EMPTY:-d}", &l), "d");
assert_eq!(expand("${MISSING:-d}", &l), "d");
assert_eq!(expand("${SET:+yes}", &l), "yes");
assert_eq!(expand("${EMPTY:+yes}", &l), "");
assert_eq!(expand("${MISSING:+yes}", &l), "");
}
#[test]
fn escaped_dollar_is_literal() {
let m = map(&[("V", "1")]);
let l = lookup_from(&m);
assert_eq!(expand(r"price \$5 not $V", &l), "price $5 not 1");
}
#[test]
fn arg_default_then_env_precedence_and_accumulation() {
let instrs = vec![
Instruction::Arg {
name: "VERSION".into(),
default: Some("1.21".into()),
},
Instruction::run(ShellOrExec::Shell("wget go${VERSION}.tgz".into())),
Instruction::Env(vec![("DIR".into(), "/opt/${VERSION}".into())]),
Instruction::run(ShellOrExec::Shell("ls $DIR".into())),
Instruction::Arg {
name: "VERSION".into(),
default: Some("ignored".into()),
},
Instruction::Env(vec![("VERSION".into(), "9".into())]),
Instruction::run(ShellOrExec::Shell("echo ${VERSION}".into())),
];
let out = resolve_stage_instructions(&instrs, &[], &BTreeMap::new());
let shells: Vec<String> = out
.iter()
.filter_map(|i| match i {
Instruction::Run {
run: ShellOrExec::Shell(s),
..
} => Some(s.clone()),
_ => None,
})
.collect();
assert_eq!(shells[0], "wget go1.21.tgz");
assert_eq!(shells[1], "ls /opt/1.21");
assert_eq!(shells[2], "echo 9");
}
#[test]
fn build_arg_override_beats_default() {
let instrs = vec![
Instruction::Arg {
name: "TAG".into(),
default: Some("latest".into()),
},
Instruction::run(ShellOrExec::Shell("pull img:$TAG".into())),
];
let out = resolve_stage_instructions(&instrs, &[], &map(&[("TAG", "v2")]));
match &out[1] {
Instruction::Run {
run: ShellOrExec::Shell(s),
..
} => assert_eq!(s, "pull img:v2"),
_ => panic!(),
}
}
#[test]
fn global_args_visible_in_stage() {
let instrs = vec![Instruction::run(ShellOrExec::Shell("echo $G".into()))];
let out = resolve_stage_instructions(
&instrs,
&[("G".into(), Some("glob".into()))],
&BTreeMap::new(),
);
match &out[0] {
Instruction::Run {
run: ShellOrExec::Shell(s),
..
} => assert_eq!(s, "echo glob"),
_ => panic!(),
}
}
#[test]
fn copy_and_workdir_and_label_expand() {
let instrs = vec![
Instruction::Env(vec![("APP".into(), "myapp".into())]),
Instruction::Workdir("/srv/$APP".into()),
Instruction::Label(vec![("app".into(), "${APP}-prod".into())]),
];
let out = resolve_stage_instructions(&instrs, &[], &BTreeMap::new());
assert_eq!(out[1], Instruction::Workdir("/srv/myapp".into()));
assert_eq!(
out[2],
Instruction::Label(vec![("app".into(), "myapp-prod".into())])
);
}
#[test]
fn exec_form_run_and_adjacent_vars() {
let m = map(&[("A", "x"), ("B", "y")]);
let l = lookup_from(&m);
assert_eq!(expand("$A$B", &l), "xy");
assert_eq!(expand("${A}${B}z", &l), "xyz");
let instrs = vec![
Instruction::Env(vec![("BIN".into(), "mytool".into())]),
Instruction::run(ShellOrExec::Exec(vec![
"/usr/bin/$BIN".into(),
"--out=${BIN}.log".into(),
])),
];
let out = resolve_stage_instructions(&instrs, &[], &BTreeMap::new());
assert_eq!(
out[1],
Instruction::run(ShellOrExec::Exec(vec![
"/usr/bin/mytool".into(),
"--out=mytool.log".into(),
]))
);
}
#[test]
fn copy_chown_and_run_mount_fields_expand() {
use super::super::dockerfile::{CopyFlags, MountKind, RunMount};
let instrs = vec![
Instruction::Arg {
name: "U".into(),
default: Some("appuser".into()),
},
Instruction::Arg {
name: "CACHE".into(),
default: Some("/var/cache/$U".into()),
},
Instruction::Copy {
sources: vec!["src".into()],
dest: "/dst".into(),
flags: CopyFlags {
from: None,
chown: Some("$U:$U".into()),
chmod: None,
},
},
Instruction::Run {
run: ShellOrExec::Shell("make".into()),
mounts: vec![RunMount {
kind: MountKind::Cache,
target: Some("${CACHE}".into()),
source: None,
id: Some("c-$U".into()),
from: None,
readonly: false,
required: false,
}],
},
];
let out = resolve_stage_instructions(&instrs, &[], &BTreeMap::new());
match &out[2] {
Instruction::Copy { flags, .. } => {
assert_eq!(flags.chown.as_deref(), Some("appuser:appuser"))
}
_ => panic!("expected COPY"),
}
match &out[3] {
Instruction::Run { mounts, .. } => {
assert_eq!(mounts[0].target.as_deref(), Some("/var/cache/appuser"));
assert_eq!(mounts[0].id.as_deref(), Some("c-appuser"));
}
_ => panic!("expected RUN"),
}
}
#[test]
fn unterminated_brace_and_lone_dollar_are_literal() {
let m = map(&[("V", "1")]);
let l = lookup_from(&m);
assert_eq!(expand("a${V", &l), "a${V");
assert_eq!(expand("cost: $ 5", &l), "cost: $ 5");
assert_eq!(expand("trailing$", &l), "trailing$");
}
}