use std::path::Path;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::error::{ComposeError, Result};
fn append_context<W: std::io::Write>(
tar: &mut tar::Builder<W>,
context: &Path,
ignore_patterns: &[String],
skip_names: &[&str],
) -> Result<()> {
tar.follow_symlinks(false);
for abs in super::super::walk_dir(context).map_err(ComposeError::Io)? {
let rel = abs
.strip_prefix(context)
.map_err(|_| ComposeError::Build("path strip error".into()))?;
let rel_str = rel.to_string_lossy();
if skip_names.iter().any(|n| rel_str == *n) {
continue;
}
if is_ignored(&rel_str, ignore_patterns) {
continue;
}
let is_dir = abs.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false);
if is_dir {
tar.append_dir(rel, &abs)
.map_err(|e| ComposeError::Build(e.to_string()))?;
} else {
tar.append_path_with_name(&abs, rel)
.map_err(|e| ComposeError::Build(e.to_string()))?;
}
}
Ok(())
}
fn append_extra_files<W: std::io::Write>(
tar: &mut tar::Builder<W>,
extra_files: &[(String, Vec<u8>)],
) -> Result<()> {
for (name, bytes) in extra_files {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o600);
header.set_cksum();
tar.append_data(&mut header, name, bytes.as_slice())
.map_err(|e| ComposeError::Build(e.to_string()))?;
}
Ok(())
}
pub(super) fn build_context_tar_with_inline(
context: &Path,
inline: &str,
extra_files: &[(String, Vec<u8>)],
) -> Result<(Vec<u8>, String)> {
let inline_name = ".dockerfile-inline";
let ignore_patterns = read_dockerignore(context);
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
let mut header = tar::Header::new_gnu();
header.set_size(inline.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, inline_name, inline.as_bytes())
.map_err(|e| ComposeError::Build(e.to_string()))?;
append_context(&mut tar, context, &ignore_patterns, &[".dockerignore"])?;
let dockerignore = inline_dockerignore(context, inline_name);
let mut di_header = tar::Header::new_gnu();
di_header.set_size(dockerignore.len() as u64);
di_header.set_mode(0o644);
di_header.set_cksum();
tar.append_data(&mut di_header, ".dockerignore", dockerignore.as_bytes())
.map_err(|e| ComposeError::Build(e.to_string()))?;
append_extra_files(&mut tar, extra_files)?;
let gz = tar
.into_inner()
.map_err(|e| ComposeError::Build(e.to_string()))?;
let bytes = gz
.finish()
.map_err(|e| ComposeError::Build(e.to_string()))?;
Ok((bytes, inline_name.to_string()))
}
pub(crate) fn build_context_tar(
context: &Path,
_dockerfile: &str,
extra_files: &[(String, Vec<u8>)],
) -> Result<Vec<u8>> {
let ignore_patterns = read_dockerignore(context);
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
append_context(&mut tar, context, &ignore_patterns, &[])?;
append_extra_files(&mut tar, extra_files)?;
let gz = tar
.into_inner()
.map_err(|e| ComposeError::Build(e.to_string()))?;
let bytes = gz
.finish()
.map_err(|e| ComposeError::Build(e.to_string()))?;
Ok(bytes)
}
fn inline_dockerignore(context: &Path, inline_name: &str) -> String {
let existing =
crate::filesystem::read_to_string_capped(context.join(".dockerignore")).unwrap_or_default();
let mut out = existing.trim_end_matches(['\n', '\r']).to_string();
if !out.is_empty() {
out.push('\n');
}
out.push_str(inline_name);
out.push('\n');
out
}
pub(super) fn map_additional_context(base_dir: &Path, value: &str) -> String {
if let Some(img) = value.strip_prefix("docker-image://") {
format!("image:{img}")
} else if value.starts_with("http://")
|| value.starts_with("https://")
|| value.starts_with("git://")
{
format!("url:{value}")
} else {
format!("localpath:{}", base_dir.join(value).display())
}
}
fn read_dockerignore(context: &Path) -> Vec<String> {
let path = context.join(".dockerignore");
let Ok(content) = crate::filesystem::read_to_string_capped(path) else {
return Vec::new();
};
content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
}
fn is_ignored(path: &str, patterns: &[String]) -> bool {
let mut ignored = false;
for pattern in patterns {
let (negated, pat) = match pattern.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, pattern.as_str()),
};
if pattern_matches(pat, path) {
ignored = !negated;
}
}
ignored
}
fn pattern_matches(pattern: &str, path: &str) -> bool {
if pattern.is_empty() {
return false;
}
if let Some(dir) = pattern.strip_suffix('/') {
return path == dir || path.starts_with(&format!("{dir}/"));
}
if pattern.contains('*') || pattern.contains('?') {
return glob_match(pattern, path);
}
path == pattern
|| (path.starts_with(pattern) && path.as_bytes().get(pattern.len()) == Some(&b'/'))
}
fn glob_match(pattern: &str, path: &str) -> bool {
if !pattern.contains('/') && !pattern.contains("**") {
let filename = path.rsplit('/').next().unwrap_or(path);
return glob_rec(pattern.as_bytes(), filename.as_bytes());
}
glob_rec(pattern.as_bytes(), path.as_bytes())
}
fn glob_rec(pat: &[u8], s: &[u8]) -> bool {
if pat.is_empty() {
return s.is_empty();
}
if pat.starts_with(b"**") {
let mut rest = &pat[2..];
if rest.first() == Some(&b'/') && glob_rec(&rest[1..], s) {
return true;
}
if rest.is_empty() {
rest = b"";
}
for i in 0..=s.len() {
if glob_rec(rest, &s[i..]) {
return true;
}
}
return false;
}
match pat[0] {
b'*' => {
let mut i = 0;
loop {
if glob_rec(&pat[1..], &s[i..]) {
return true;
}
if i >= s.len() || s[i] == b'/' {
return false;
}
i += 1;
}
}
b'?' => !s.is_empty() && s[0] != b'/' && glob_rec(&pat[1..], &s[1..]),
c => !s.is_empty() && s[0] == c && glob_rec(&pat[1..], &s[1..]),
}
}
#[cfg(test)]
mod tests {
use super::{
build_context_tar, build_context_tar_with_inline, glob_match, is_ignored,
map_additional_context, read_dockerignore,
};
use std::fs;
use std::path::Path;
use tempfile::tempdir;
#[test]
fn additional_context_prefix_mapping() {
let base = Path::new("/proj");
assert_eq!(
map_additional_context(base, "docker-image://alpine"),
"image:alpine"
);
assert_eq!(
map_additional_context(base, "https://example.org/ctx.tar"),
"url:https://example.org/ctx.tar"
);
assert_eq!(
map_additional_context(base, "git://example.org/r.git"),
"url:git://example.org/r.git"
);
let local = map_additional_context(base, "sub/dir");
assert!(local.starts_with("localpath:"));
assert!(local.ends_with(&base.join("sub/dir").display().to_string()));
}
#[test]
fn extra_files_are_added_to_context_tar() {
use flate2::read::GzDecoder;
use std::io::Read;
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), b"FROM alpine\n").unwrap();
let extra = vec![(".podup-build-secret-tok".to_string(), b"hunter2".to_vec())];
let bytes = build_context_tar(dir.path(), "Dockerfile", &extra).unwrap();
let mut raw = Vec::new();
GzDecoder::new(bytes.as_slice())
.read_to_end(&mut raw)
.unwrap();
let mut archive = tar::Archive::new(raw.as_slice());
let names: Vec<String> = archive
.entries()
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().into_owned()))
.collect();
assert!(
names.iter().any(|n| n.contains(".podup-build-secret-tok")),
"secret entry must be present: {names:?}"
);
}
#[test]
fn build_ignored_exact() {
let patterns = vec!["secret.txt".to_string()];
assert!(is_ignored("secret.txt", &patterns));
assert!(!is_ignored("secret.txt.bak", &patterns));
}
#[test]
fn build_ignored_dir() {
let patterns = vec!["node_modules/".to_string()];
assert!(is_ignored("node_modules/foo.js", &patterns));
assert!(!is_ignored("other/foo.js", &patterns));
}
#[test]
fn build_ignored_path_separator() {
let patterns = vec!["vendor".to_string()];
assert!(is_ignored("vendor/lib.rs", &patterns));
assert!(!is_ignored("notvendor/lib.rs", &patterns));
}
#[test]
fn build_ignored_glob_extension() {
let patterns = vec!["*.key".to_string()];
assert!(is_ignored("secret.key", &patterns));
assert!(is_ignored("certs/ca.key", &patterns));
assert!(!is_ignored("key.txt", &patterns));
}
#[test]
fn build_ignored_glob_in_subdir() {
let patterns = vec!["logs/*.log".to_string()];
assert!(is_ignored("logs/error.log", &patterns));
assert!(!is_ignored("other/error.log", &patterns));
}
#[test]
fn glob_match_star_extension() {
assert!(glob_match("*.env", "production.env"));
assert!(glob_match("*.env", "config/.env"));
assert!(!glob_match("*.env", "env.txt"));
}
#[test]
fn glob_match_star_prefix() {
assert!(glob_match("id_*", "id_rsa"));
assert!(glob_match("id_*", "id_ed25519"));
assert!(!glob_match("id_*", "not_id_rsa"));
}
#[test]
fn glob_match_double_star_any_depth() {
assert!(glob_match("**/*.key", "secret.key"));
assert!(glob_match("**/*.key", "a/b/c/secret.key"));
assert!(glob_match("a/**/b", "a/b"));
assert!(glob_match("a/**/b", "a/x/y/b"));
assert!(!glob_match("a/**/b", "z/b"));
}
#[test]
fn glob_match_question_mark() {
assert!(glob_match("file?.txt", "file1.txt"));
assert!(!glob_match("file?.txt", "file.txt"));
assert!(!glob_match("file?.txt", "file12.txt"));
}
#[test]
fn dockerignore_negation_reincludes() {
let patterns = vec!["*.log".to_string(), "!keep.log".to_string()];
assert!(is_ignored("error.log", &patterns));
assert!(!is_ignored("keep.log", &patterns));
}
#[test]
fn dockerignore_negation_order_matters() {
let patterns = vec![
"logs/".to_string(),
"!logs/keep/".to_string(),
"logs/keep/secret.txt".to_string(),
];
assert!(is_ignored("logs/a.log", &patterns));
assert!(!is_ignored("logs/keep/b.log", &patterns));
assert!(is_ignored("logs/keep/secret.txt", &patterns));
}
#[test]
fn dockerignore_parsed_correctly() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(".dockerignore"),
b"# comment\n\ntarget/\n*.log\n",
)
.unwrap();
let patterns = read_dockerignore(dir.path());
assert_eq!(patterns, vec!["target/", "*.log"]);
}
#[test]
fn dockerignore_missing_returns_empty() {
let dir = tempdir().unwrap();
let patterns = read_dockerignore(dir.path());
assert!(patterns.is_empty());
}
#[test]
fn context_tar_produces_valid_gzip() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), b"FROM alpine\n").unwrap();
fs::write(dir.path().join("app.rs"), b"fn main() {}").unwrap();
let bytes = build_context_tar(dir.path(), "Dockerfile", &[]).unwrap();
assert_eq!(&bytes[..2], &[0x1f, 0x8b]);
}
#[test]
fn context_tar_excludes_dockerignore_glob() {
use flate2::read::GzDecoder;
use std::io::Read;
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), b"FROM alpine\n").unwrap();
fs::write(dir.path().join("secret.key"), b"top secret").unwrap();
fs::write(dir.path().join(".dockerignore"), b"*.key\n").unwrap();
let bytes = build_context_tar(dir.path(), "Dockerfile", &[]).unwrap();
let mut gz_content = Vec::new();
GzDecoder::new(bytes.as_slice())
.read_to_end(&mut gz_content)
.unwrap();
let mut archive = tar::Archive::new(gz_content.as_slice());
let names: Vec<String> = archive
.entries()
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().into_owned()))
.collect();
assert!(
!names.iter().any(|n| n.contains("secret.key")),
"secret.key must be excluded: {names:?}"
);
}
#[test]
fn context_tar_with_subdirectory() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), b"FROM alpine\n").unwrap();
fs::create_dir(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), b"fn main() {}").unwrap();
let bytes = build_context_tar(dir.path(), "Dockerfile", &[]).unwrap();
assert_eq!(&bytes[..2], &[0x1f, 0x8b]);
}
#[test]
fn inline_tar_produces_valid_gzip() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("app.txt"), b"content").unwrap();
let inline = "FROM alpine\nRUN echo hello\n";
let (bytes, df_name) = build_context_tar_with_inline(dir.path(), inline, &[]).unwrap();
assert_eq!(&bytes[..2], &[0x1f, 0x8b]);
assert!(!df_name.is_empty());
}
#[test]
fn inline_dockerfile_excluded_via_dockerignore() {
use flate2::read::GzDecoder;
use std::io::Read;
let dir = tempdir().unwrap();
fs::write(dir.path().join("app.txt"), b"x").unwrap();
let (bytes, _) = build_context_tar_with_inline(dir.path(), "FROM alpine\n", &[]).unwrap();
let mut raw = Vec::new();
GzDecoder::new(bytes.as_slice())
.read_to_end(&mut raw)
.unwrap();
let mut archive = tar::Archive::new(raw.as_slice());
let mut di = String::new();
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
if entry.path().unwrap().to_string_lossy() == ".dockerignore" {
entry.read_to_string(&mut di).unwrap();
}
}
assert!(
di.lines().any(|l| l == ".dockerfile-inline"),
"inline Dockerfile must be excluded from COPY via .dockerignore: {di:?}"
);
}
#[test]
fn inline_dockerignore_preserves_user_rules() {
use flate2::read::GzDecoder;
use std::io::Read;
let dir = tempdir().unwrap();
fs::write(dir.path().join("app.txt"), b"x").unwrap();
fs::write(dir.path().join(".dockerignore"), b"*.log\n").unwrap();
let (bytes, _) = build_context_tar_with_inline(dir.path(), "FROM alpine\n", &[]).unwrap();
let mut raw = Vec::new();
GzDecoder::new(bytes.as_slice())
.read_to_end(&mut raw)
.unwrap();
let mut archive = tar::Archive::new(raw.as_slice());
let mut di_entries = 0;
let mut di = String::new();
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
if entry.path().unwrap().to_string_lossy() == ".dockerignore" {
di_entries += 1;
entry.read_to_string(&mut di).unwrap();
}
}
assert_eq!(di_entries, 1, "exactly one .dockerignore entry");
assert!(di.lines().any(|l| l == "*.log"), "user rule kept: {di:?}");
assert!(
di.lines().any(|l| l == ".dockerfile-inline"),
"inline exclusion added: {di:?}"
);
}
#[cfg(unix)]
#[test]
fn context_tar_packs_symlink_as_link_not_target() {
use flate2::read::GzDecoder;
use std::io::Read;
let outside = tempdir().unwrap();
fs::write(outside.path().join("secret"), b"TOPSECRET").unwrap();
let dir = tempdir().unwrap();
fs::write(dir.path().join("Dockerfile"), b"FROM alpine\n").unwrap();
std::os::unix::fs::symlink(outside.path().join("secret"), dir.path().join("leak")).unwrap();
let bytes = build_context_tar(dir.path(), "Dockerfile", &[]).unwrap();
let mut raw = Vec::new();
GzDecoder::new(bytes.as_slice())
.read_to_end(&mut raw)
.unwrap();
let mut archive = tar::Archive::new(raw.as_slice());
let mut found = false;
for entry in archive.entries().unwrap() {
let entry = entry.unwrap();
if entry.path().unwrap().to_string_lossy() == "leak" {
found = true;
assert_eq!(
entry.header().entry_type(),
tar::EntryType::Symlink,
"symlink must be packed as a link"
);
assert_eq!(
entry.header().size().unwrap(),
0,
"link entry must not carry the target's bytes"
);
}
}
assert!(found, "symlink entry must be present in the context tar");
}
#[test]
fn build_ignored_empty_pattern_matches_nothing() {
let patterns = vec![String::new()];
assert!(!is_ignored("anything.txt", &patterns));
assert!(!is_ignored("a/b/c", &patterns));
}
#[test]
fn glob_match_double_star_suffix_spans_subtree() {
assert!(glob_match("build/**", "build/out.o"));
assert!(glob_match("build/**", "build/a/b/out.o"));
assert!(!glob_match("build/**", "src/out.o"));
}
#[test]
fn glob_match_double_star_middle_with_no_match_fails() {
assert!(glob_match("a/**/z", "a/b/c/z"));
assert!(!glob_match("a/**/z", "a/b/c/y"));
}
#[test]
fn glob_match_question_mark_matches_single_non_slash_char() {
assert!(glob_match("file?.txt", "file1.txt"));
assert!(!glob_match("file?.txt", "file.txt"));
assert!(!glob_match("a?b", "a/b"));
}
}