BMAD-METHOD/bmad-core/utils/bmad-lib-v2.sh

451 lines
12 KiB
Bash

#!/usr/bin/env bash
# bmad-lib-v2.sh - Refined utilities with security and compatibility fixes
# Author: Quinn (Senior Developer QA) - v2.0.0
# SECURITY: All inputs sanitized, POSIX compliant, atomic operations
set -euo pipefail # Strict error handling
IFS=$'\n\t' # Secure Internal Field Separator
# ============================================================================
# SECURITY LAYER
# ============================================================================
# Sanitize input to prevent injection attacks
sanitize_input() {
local input="$1"
# Remove all special characters that could cause command injection
printf '%s' "$input" | sed 's/[^a-zA-Z0-9_.-]//g'
}
# Validate file path to prevent directory traversal
validate_path() {
local path="$1"
local base_dir="${2:-.bmad}"
# Resolve to absolute path
local abs_path
abs_path=$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")
local abs_base
abs_base=$(cd "$base_dir" 2>/dev/null && pwd)
# Ensure path is within base directory
if [[ "$abs_path" != "$abs_base"* ]]; then
echo "ERROR: Path traversal attempt detected" >&2
return 1
fi
echo "$abs_path"
}
# ============================================================================
# ENHANCED SHELL COMPATIBILITY
# ============================================================================
# Detect shell with better accuracy
detect_shell() {
if [ -n "${BASH_VERSION:-}" ]; then
echo "bash"
# Enable bash safety features
set -o noclobber # Prevent accidental overwrites
set -o pipefail # Pipe failures propagate
elif [ -n "${ZSH_VERSION:-}" ]; then
echo "zsh"
setopt PIPE_FAIL 2>/dev/null || true
setopt NO_NOMATCH 2>/dev/null || true
elif [ -n "${KSH_VERSION:-}" ]; then
echo "ksh"
else
echo "sh"
# POSIX mode - most restrictive
fi
}
BMAD_SHELL=$(detect_shell)
export BMAD_SHELL
# POSIX-compliant emoji alternatives
get_status_indicator() {
local status="$1"
# Use ASCII if terminal doesn't support UTF-8
if ! locale | grep -q "UTF-8"; then
case "$status" in
"draft") echo "[D]" ;;
"ready") echo "[R]" ;;
"in_progress") echo "[>]" ;;
"code_review") echo "[CR]" ;;
"qa_testing") echo "[QA]" ;;
"completed"|"done") echo "[X]" ;;
"blocked") echo "[!]" ;;
*) echo "[?]" ;;
esac
else
case "$status" in
"draft") echo "📝" ;;
"ready") echo "📋" ;;
"in_progress") echo "🔄" ;;
"code_review") echo "👀" ;;
"qa_testing") echo "🧪" ;;
"completed"|"done") echo "✅" ;;
"blocked") echo "🚫" ;;
*) echo "❓" ;;
esac
fi
}
# ============================================================================
# ATOMIC FILE OPERATIONS
# ============================================================================
# Atomic write to prevent corruption
atomic_write() {
local file="$1"
local content="$2"
local temp_file
# Create temp file in same directory for atomic rename
temp_file=$(mktemp "${file}.XXXXXX")
# Write content
printf '%s\n' "$content" > "$temp_file"
# Atomic rename
mv -f "$temp_file" "$file"
}
# File locking for concurrent access
with_lock() {
local lock_file="$1"
local timeout="${2:-10}"
shift 2
local count=0
# Try to acquire lock
while ! mkdir "$lock_file" 2>/dev/null; do
count=$((count + 1))
if [ "$count" -ge "$timeout" ]; then
echo "ERROR: Failed to acquire lock after ${timeout}s" >&2
return 1
fi
sleep 1
done
# Execute command with lock held
local exit_code=0
"$@" || exit_code=$?
# Release lock
rmdir "$lock_file" 2>/dev/null || true
return $exit_code
}
# ============================================================================
# SECURE YAML PARSING
# ============================================================================
# Safe YAML field extraction with injection prevention
get_yaml_field_secure() {
local file="$1"
local field="$2"
local default="${3:-}"
# Validate inputs
[ -f "$file" ] || { echo "$default"; return; }
# Sanitize field name to prevent regex injection
local safe_field
safe_field=$(sanitize_input "$field")
# Use awk for safer parsing
local value
value=$(awk -v field="$safe_field" '
$0 ~ "^" field ":" {
sub("^" field ":[[:space:]]*", "")
# Remove quotes if present
gsub(/^["'\'']|["'\'']$/, "")
print
exit
}
' "$file" 2>/dev/null)
# Return value or default
if [ -z "$value" ] || [ "$value" = "null" ]; then
echo "$default"
else
echo "$value"
fi
}
# Validate YAML structure without eval
validate_yaml_structure() {
local file="$1"
# Check for YAML bomb attempts
local line_count
line_count=$(wc -l < "$file")
if [ "$line_count" -gt 10000 ]; then
echo "ERROR: File too large, possible YAML bomb" >&2
return 1
fi
# Check for malicious patterns
if grep -qE '(&|\*)[a-zA-Z0-9_]+' "$file"; then
echo "WARNING: YAML anchors/aliases detected" >&2
fi
# Basic structure validation
local required_fields="id title status epic"
local missing=""
for field in $required_fields; do
if ! grep -q "^${field}:" "$file" 2>/dev/null; then
missing="$missing $field"
fi
done
if [ -n "$missing" ]; then
echo "ERROR: Missing required fields:$missing" >&2
return 1
fi
return 0
}
# ============================================================================
# PERFORMANCE MONITORING
# ============================================================================
# Track execution time
time_execution() {
local name="$1"
shift
local start_time
start_time=$(date +%s%N 2>/dev/null || date +%s)
"$@"
local exit_code=$?
local end_time
end_time=$(date +%s%N 2>/dev/null || date +%s)
if [ ${#start_time} -eq ${#end_time} ]; then
local duration=$((end_time - start_time))
if [ ${#start_time} -gt 10 ]; then
# Nanoseconds available
duration=$((duration / 1000000)) # Convert to milliseconds
echo "DEBUG: $name completed in ${duration}ms" >&2
else
echo "DEBUG: $name completed in ${duration}s" >&2
fi
fi
return $exit_code
}
# ============================================================================
# IMPROVED ERROR HANDLING
# ============================================================================
# Enhanced error reporting with stack trace
error_handler() {
local line_no="$1"
local exit_code="$2"
local command="$3"
echo "ERROR: Command failed at line $line_no: $command (exit code: $exit_code)" >&2
# Print call stack if available (bash only)
if [ "$BMAD_SHELL" = "bash" ]; then
echo "Call stack:" >&2
local frame=0
while caller $frame >&2; do
frame=$((frame + 1))
done
fi
exit "$exit_code"
}
# Install error handler (bash only)
if [ "$BMAD_SHELL" = "bash" ]; then
trap 'error_handler $LINENO $? "$BASH_COMMAND"' ERR
fi
# ============================================================================
# OPTIMIZED DATA PROCESSING
# ============================================================================
# Single-pass story analysis with validation
analyze_stories_optimized() {
local story_dir="${1:-.bmad/stories}"
# Validate directory
[ -d "$story_dir" ] || { echo "{}"; return; }
# Single AWK pass with security checks
find "$story_dir" -name "*.yaml" -type f -size -100k | \
head -1000 | \ # Limit number of files
xargs awk '
BEGIN {
# Initialize counters
total = 0
by_status["draft"] = 0
by_status["ready"] = 0
by_status["in_progress"] = 0
by_status["code_review"] = 0
by_status["qa_testing"] = 0
by_status["completed"] = 0
blocked = 0
total_points = 0
}
# Security: Skip suspicious patterns
/[;&|`$()]/ { next }
# New file
FILENAME != prev_file {
if (prev_file && story_id) {
total++
if (story_status in by_status) {
by_status[story_status]++
}
total_points += story_points
if (is_blocked == "true") blocked++
}
prev_file = FILENAME
story_id = ""
story_status = ""
story_points = 0
is_blocked = "false"
}
/^id:/ { story_id = $2 }
/^status:/ { story_status = $2 }
/^points:/ { story_points = $2 + 0 } # Force numeric
/^blocked: true/ { is_blocked = "true" }
END {
# Process last file
if (story_id) {
total++
if (story_status in by_status) {
by_status[story_status]++
}
total_points += story_points
if (is_blocked == "true") blocked++
}
# Output JSON for easy parsing
printf "{"
printf "\"total\":%d,", total
printf "\"draft\":%d,", by_status["draft"]
printf "\"ready\":%d,", by_status["ready"]
printf "\"in_progress\":%d,", by_status["in_progress"]
printf "\"code_review\":%d,", by_status["code_review"]
printf "\"qa_testing\":%d,", by_status["qa_testing"]
printf "\"completed\":%d,", by_status["completed"]
printf "\"blocked\":%d,", blocked
printf "\"total_points\":%d", total_points
printf "}\n"
}' 2>/dev/null || echo "{}"
}
# ============================================================================
# CACHING WITH INTEGRITY
# ============================================================================
# Secure cache management
cache_get() {
local key="$1"
local cache_dir="${BMAD_CACHE_DIR:-.bmad/.cache}"
local cache_file="$cache_dir/$(echo "$key" | sha256sum | cut -d' ' -f1)"
# Check cache validity (5 minute TTL)
if [ -f "$cache_file" ]; then
local age
age=$(( $(date +%s) - $(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file" 2>/dev/null || echo 0) ))
if [ "$age" -lt 300 ]; then
cat "$cache_file"
return 0
fi
fi
return 1
}
cache_set() {
local key="$1"
local value="$2"
local cache_dir="${BMAD_CACHE_DIR:-.bmad/.cache}"
mkdir -p "$cache_dir"
local cache_file="$cache_dir/$(echo "$key" | sha256sum | cut -d' ' -f1)"
# Atomic write with lock
with_lock "${cache_file}.lock" 5 atomic_write "$cache_file" "$value"
}
# ============================================================================
# BACKWARD COMPATIBILITY
# ============================================================================
# Wrapper for legacy functions
get_yaml_field() {
get_yaml_field_secure "$@"
}
validate_story_file() {
validate_yaml_structure "$@"
}
# ============================================================================
# INITIALIZATION
# ============================================================================
# Self-test on load
bmad_lib_selftest() {
local test_file
test_file=$(mktemp)
# Test YAML parsing
cat > "$test_file" <<EOF
id: TEST-001
title: Test Story
status: draft
epic: Test
evil: \$(echo pwned)
EOF
local evil_value
evil_value=$(get_yaml_field_secure "$test_file" "evil" "safe")
if [ "$evil_value" != "safe" ] && [[ "$evil_value" == *"pwned"* ]]; then
echo "ERROR: Security test failed - command injection possible!" >&2
rm -f "$test_file"
return 1
fi
rm -f "$test_file"
return 0
}
# Run self-test
if ! bmad_lib_selftest; then
echo "ERROR: Library self-test failed!" >&2
exit 1
fi
# Export public functions
export -f sanitize_input
export -f validate_path
export -f get_yaml_field_secure
export -f validate_yaml_structure
export -f atomic_write
export -f with_lock
export -f analyze_stories_optimized
export -f cache_get
export -f cache_set
export -f get_status_indicator
echo "bmad-lib v2.0.0 loaded successfully (shell: $BMAD_SHELL)" >&2