zeroclaw/tests/dockerignore_test.rs
argenis de la rosa 76074cb789 fix: run Docker container as non-root user (closes #34)
- Switch to gcr.io/distroless/cc-debian12:nonroot
- Add explicit USER 65534:65534 directive
- Add Docker security CI job verifying non-root UID, :nonroot base, and USER directive
- Document CIS Docker Benchmark compliance in SECURITY.md
- Add tests and edge cases for container security
2026-02-14 13:16:33 -05:00

322 lines
9.6 KiB
Rust

//! Tests to verify .dockerignore excludes sensitive paths from Docker build context.
//!
//! These tests validate that:
//! 1. The .dockerignore file exists
//! 2. All security-critical paths are excluded
//! 3. All build-essential paths are NOT excluded
//! 4. Pattern syntax is valid
use std::fs;
use std::path::Path;
/// Paths that MUST be excluded from Docker build context (security/performance)
const MUST_EXCLUDE: &[&str] = &[
".git",
"target",
"docs",
"examples",
"tests",
"*.md",
"*.png",
"*.db",
"*.db-journal",
".DS_Store",
".github",
".githooks",
"deny.toml",
"LICENSE",
".env",
];
/// Paths that MUST NOT be excluded (required for build)
const MUST_INCLUDE: &[&str] = &["Cargo.toml", "Cargo.lock", "src/"];
/// Parse .dockerignore and return all non-comment, non-empty lines
fn parse_dockerignore(content: &str) -> Vec<String> {
content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| line.to_string())
.collect()
}
/// Check if a pattern would match a given path
fn pattern_matches(pattern: &str, path: &str) -> bool {
// Handle negation patterns
if pattern.starts_with('!') {
return false; // Negation re-includes, so it doesn't "exclude"
}
// Handle glob patterns
if pattern.starts_with("*.") {
let ext = &pattern[1..]; // e.g., ".md"
return path.ends_with(ext);
}
// Handle directory patterns (with or without trailing slash)
let pattern_normalized = pattern.trim_end_matches('/');
let path_normalized = path.trim_end_matches('/');
// Exact match
if path_normalized == pattern_normalized {
return true;
}
// Pattern is a prefix (directory match)
if path_normalized.starts_with(&format!("{}/", pattern_normalized)) {
return true;
}
// Wildcard prefix patterns like ".tmp_*"
if pattern.contains('*') && !pattern.starts_with("*.") {
let prefix = pattern.split('*').next().unwrap_or("");
if !prefix.is_empty() && path.starts_with(prefix) {
return true;
}
}
false
}
/// Check if any pattern in the list would exclude the given path
fn is_excluded(patterns: &[String], path: &str) -> bool {
let mut excluded = false;
for pattern in patterns {
if pattern.starts_with('!') {
// Negation pattern - re-include
let negated = &pattern[1..];
if pattern_matches(negated, path) {
excluded = false;
}
} else if pattern_matches(pattern, path) {
excluded = true;
}
}
excluded
}
#[test]
fn dockerignore_file_exists() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
assert!(
path.exists(),
".dockerignore file must exist at project root"
);
}
#[test]
fn dockerignore_excludes_security_critical_paths() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
for must_exclude in MUST_EXCLUDE {
// For glob patterns, test with a sample file
let test_path = if must_exclude.starts_with("*.") {
format!("sample{}", &must_exclude[1..])
} else {
must_exclude.to_string()
};
assert!(
is_excluded(&patterns, &test_path),
"Path '{}' (tested as '{}') MUST be excluded by .dockerignore but is not. \
This is a security/performance issue.",
must_exclude,
test_path
);
}
}
#[test]
fn dockerignore_does_not_exclude_build_essentials() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
for must_include in MUST_INCLUDE {
assert!(
!is_excluded(&patterns, must_include),
"Path '{}' MUST NOT be excluded by .dockerignore (required for build)",
must_include
);
}
}
#[test]
fn dockerignore_excludes_git_directory() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
// .git directory and its contents must be excluded
assert!(is_excluded(&patterns, ".git"), ".git must be excluded");
assert!(
is_excluded(&patterns, ".git/config"),
".git/config must be excluded"
);
assert!(
is_excluded(&patterns, ".git/objects/pack/pack-abc123.pack"),
".git subdirectories must be excluded"
);
}
#[test]
fn dockerignore_excludes_target_directory() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(is_excluded(&patterns, "target"), "target must be excluded");
assert!(
is_excluded(&patterns, "target/debug/zeroclaw"),
"target/debug must be excluded"
);
assert!(
is_excluded(&patterns, "target/release/zeroclaw"),
"target/release must be excluded"
);
}
#[test]
fn dockerignore_excludes_database_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, "brain.db"),
"*.db files must be excluded"
);
assert!(
is_excluded(&patterns, "memory.db"),
"*.db files must be excluded"
);
assert!(
is_excluded(&patterns, "brain.db-journal"),
"*.db-journal files must be excluded"
);
}
#[test]
fn dockerignore_excludes_markdown_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, "README.md"),
"*.md files must be excluded"
);
assert!(
is_excluded(&patterns, "CHANGELOG.md"),
"*.md files must be excluded"
);
assert!(
is_excluded(&patterns, "CONTRIBUTING.md"),
"*.md files must be excluded"
);
}
#[test]
fn dockerignore_excludes_image_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, "zeroclaw.png"),
"*.png files must be excluded"
);
assert!(
is_excluded(&patterns, "logo.png"),
"*.png files must be excluded"
);
}
#[test]
fn dockerignore_excludes_env_files() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, ".env"),
".env must be excluded (contains secrets)"
);
}
#[test]
fn dockerignore_excludes_ci_configs() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
let patterns = parse_dockerignore(&content);
assert!(
is_excluded(&patterns, ".github"),
".github must be excluded"
);
assert!(
is_excluded(&patterns, ".github/workflows/ci.yml"),
".github/workflows must be excluded"
);
}
#[test]
fn dockerignore_has_valid_syntax() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(".dockerignore");
let content = fs::read_to_string(&path).expect("Failed to read .dockerignore");
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
// Skip empty lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Check for invalid patterns
assert!(
!trimmed.contains("**") || trimmed.matches("**").count() <= 2,
"Line {}: Too many ** in pattern '{}'",
line_num + 1,
trimmed
);
// Check for trailing spaces (can cause issues)
assert!(
line.trim_end() == line.trim_start().trim_end(),
"Line {}: Pattern '{}' has leading whitespace which may cause issues",
line_num + 1,
line
);
}
}
#[test]
fn dockerignore_pattern_matching_edge_cases() {
// Test the pattern matching logic itself
let patterns = vec![
".git".to_string(),
"target".to_string(),
"*.md".to_string(),
"*.db".to_string(),
".tmp_*".to_string(),
];
// Should match
assert!(is_excluded(&patterns, ".git"));
assert!(is_excluded(&patterns, ".git/config"));
assert!(is_excluded(&patterns, "target"));
assert!(is_excluded(&patterns, "target/debug/build"));
assert!(is_excluded(&patterns, "README.md"));
assert!(is_excluded(&patterns, "brain.db"));
assert!(is_excluded(&patterns, ".tmp_todo_probe"));
// Should NOT match
assert!(!is_excluded(&patterns, "src"));
assert!(!is_excluded(&patterns, "src/main.rs"));
assert!(!is_excluded(&patterns, "Cargo.toml"));
assert!(!is_excluded(&patterns, "Cargo.lock"));
}