451 lines
12 KiB
Bash
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 |