use std::path::Path;
use super::io_helpers::{hash_value, mtime_info, relativize};
use super::types::{EnvSource, EnvSourceKind, ValuePresence};
pub fn parse_dockerfile(path: &Path, root: &Path, base_rank: u8) -> Vec<(String, EnvSource)> {
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let rel = relativize(path, root);
let (mtime, age) = mtime_info(path);
let mtime_str = mtime.unwrap_or_default();
let collapsed = collapse_continuations(&raw);
let mut out = Vec::new();
for (idx, line) in collapsed.iter().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with("ENV ") && !trimmed.starts_with("ENV\t") {
continue;
}
let body = trimmed[3..].trim_start();
let pairs = parse_env_directive(body);
let line_no = (idx + 1) as u32;
for (name, value) in pairs {
let presence = if value.is_empty() {
ValuePresence::Empty
} else {
ValuePresence::Plain {
value_hash: hash_value(&value),
}
};
out.push((
name,
EnvSource {
kind: EnvSourceKind::Dockerfile,
path: rel.clone(),
line: Some(line_no),
mtime: mtime_str.clone(),
mtime_age_days: age,
git_age_days: None,
value_present: presence,
precedence_rank: base_rank,
},
));
}
}
out
}
fn collapse_continuations(raw: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut continuing = false;
for line in raw.lines() {
if continuing {
buf.push(' ');
buf.push_str(line.trim_end_matches('\\').trim());
continuing = line.trim_end().ends_with('\\');
if !continuing {
if let Some(last) = out.last_mut() {
last.push_str(&buf);
}
buf.clear();
}
continue;
}
let mut s = line.to_string();
if s.trim_end().ends_with('\\') {
s = s.trim_end().trim_end_matches('\\').to_string();
buf.clear();
continuing = true;
}
out.push(s);
}
if continuing && let Some(last) = out.last_mut() {
last.push_str(&buf);
}
out
}
fn parse_env_directive(body: &str) -> Vec<(String, String)> {
let body = body.trim();
if body.is_empty() {
return Vec::new();
}
if body.contains('=') && !body.starts_with(|c: char| c.is_whitespace()) {
return parse_kv_form(body);
}
let mut parts = body.splitn(2, char::is_whitespace);
let name = parts.next().unwrap_or("").trim();
let value = parts.next().unwrap_or("").trim().to_string();
if name.is_empty() {
return Vec::new();
}
vec![(name.to_string(), strip_quotes(&value))]
}
fn parse_kv_form(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut chars = body.char_indices().peekable();
let mut start = 0usize;
while let Some((i, c)) = chars.next() {
if c == '=' {
let key = body[start..i].trim().to_string();
let value_start = i + 1;
let (value, end) = read_value(body, value_start);
if !key.is_empty() {
out.push((key, value));
}
start = end;
while let Some(&(j, ch)) = chars.peek() {
if ch.is_whitespace() {
chars.next();
start = j + ch.len_utf8();
} else {
break;
}
}
}
}
out
}
fn read_value(s: &str, start: usize) -> (String, usize) {
let bytes = s.as_bytes();
if start >= bytes.len() {
return (String::new(), bytes.len());
}
let first = bytes[start];
if first == b'"' || first == b'\'' {
let quote = first;
let mut i = start + 1;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
if bytes[i] == quote {
return (s[start + 1..i].to_string(), i + 1);
}
i += 1;
}
return (s[start + 1..].to_string(), bytes.len());
}
let mut i = start;
while i < bytes.len() && !(bytes[i] as char).is_whitespace() {
i += 1;
}
(s[start..i].to_string(), i)
}
fn strip_quotes(value: &str) -> String {
if value.len() >= 2 {
let bytes = value.as_bytes();
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parses_kv_form_with_multiple_pairs() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("Dockerfile");
fs::write(
&path,
"FROM debian\nENV NODE_ENV=production API_PORT=3000\nENV LANG en_US.UTF-8\n",
)
.unwrap();
let out = parse_dockerfile(&path, tmp.path(), 40);
let names: Vec<&str> = out.iter().map(|(n, _)| n.as_str()).collect();
assert!(names.contains(&"NODE_ENV"));
assert!(names.contains(&"API_PORT"));
assert!(names.contains(&"LANG"));
}
#[test]
fn handles_quoted_values() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("Dockerfile");
fs::write(&path, "ENV PROMPT=\"hello world\" SECRET='do not leak'\n").unwrap();
let out = parse_dockerfile(&path, tmp.path(), 40);
let prompt = out.iter().find(|(n, _)| n == "PROMPT").unwrap();
let secret = out.iter().find(|(n, _)| n == "SECRET").unwrap();
match &prompt.1.value_present {
ValuePresence::Plain { value_hash } => {
assert_eq!(*value_hash, hash_value("hello world"))
}
other => panic!("unexpected: {:?}", other),
}
assert!(matches!(
&secret.1.value_present,
ValuePresence::Plain { .. }
));
}
#[test]
fn continuation_at_eof_does_not_drop_last_line() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("Dockerfile");
fs::write(&path, "FROM debian\nENV FOO=bar \\\n BAZ=qux").unwrap();
let out = parse_dockerfile(&path, tmp.path(), 40);
let names: Vec<&str> = out.iter().map(|(n, _)| n.as_str()).collect();
assert!(
names.contains(&"FOO"),
"FOO should be present; got: {:?}",
names
);
assert!(
names.contains(&"BAZ"),
"BAZ should be present; got: {:?}",
names
);
}
}