Bash Function Argument Handling: Preventing Quote Stripping in Command Passthrough


2 views

When passing complex commands through Bash function arguments, we often encounter unexpected quote stripping that breaks command execution. This becomes particularly problematic when implementing dry-run functionality or command wrappers.

The issue occurs because Bash performs word splitting and quote removal before passing arguments to functions. Consider this simplified example:

test_fn() {
    echo "Args: $@"
}

# What you expect to see:
test_fn echo "hello world"   # Args: echo hello world

# What actually happens:
test_fn echo "hello world"   # Args: echo hello world

This becomes critical when dealing with nested commands or commands containing special characters. The original example shows how a git deployment command gets mangled:

# Intended command structure
su - user -c "cd /path && git log -1 -p | mail -s 'Subject' admin@example.com"

# After quote stripping:
su - user -c cd /path && git log -1 -p | mail -s 'Subject' admin@example.com

Solution 1: Using printf with %q

The %q format specifier in printf properly escapes all special characters:

dry_run() {
    printf '%q ' "$@"
    echo
    
    if [[ "$DRY_RUN" ]]; then
        return 0
    fi
    
    "$@"
}

Solution 2: Array-Based Approach

Using arrays preserves the original command structure:

email_admin() {
    local cmd=(
        su - "$target_username" -c
        "cd $GIT_WORK_TREE && git log -1 -p | mail -s '$mail_subject' $admin_email"
    )
    
    dry_run "${cmd[@]}"
}

Solution 3: Eval with Proper Quoting

While eval has security implications, it can solve this specific problem when used carefully:

dry_run() {
    echo "$@"
    
    if [[ "$DRY_RUN" ]]; then
        return 0
    fi
    
    eval "$@"
}

Here's a comprehensive solution combining array handling and proper quoting:

dry_run() {
    # Print the command with preserved quotes
    printf 'Executing: '
    printf '"%s" ' "$@"
    echo
    
    # Execute if not in dry-run mode
    if [[ -z "$DRY_RUN" ]]; then
        "$@"
    fi
}

safe_run() {
    local cmd=("$@")
    
    # Print for debugging
    printf 'Command: '
    printf '%s ' "${cmd[@]}"
    echo
    
    # Execute
    "${cmd[@]}"
}

Always verify with complex test cases:

# Test command with multiple levels of quoting
test_cmd() {
    dry_run bash -c "echo 'hello world' && pwd"
}

# Test command with special characters
test_special_chars() {
    dry_run find . -name "*.txt" -exec grep -l "some'text" {} \;
}

When dealing with user-provided input or variables:

  • Always validate and sanitize input
  • Prefer array-based approaches over eval
  • Use shellcheck to identify potential issues

When implementing dry-run functionality in Bash scripts, many developers encounter the frustrating issue of quotes getting stripped from command arguments. This particularly affects commands that need to be passed through multiple function layers before execution.

# The problematic scenario
dry_run() {
    echo "$@"
    "$@"
}

email_admin() {
    dry_run su - user -c "complex command with 'quotes'"
}

Bash performs word splitting and quote removal during parameter expansion. When you pass quoted arguments through functions, the shell processes them before they reach the final command. This behavior is by design but often unexpected.

Here are several approaches to preserve quotes in dry-run implementations:

1. Using printf with %q

dry_run() {
    printf '%q ' "$@"
    echo
    [[ $DRY_RUN ]] || "$@"
}

2. Array-based Approach

dry_run() {
    local cmd=("$@")
    echo "${cmd[@]@Q}"
    [[ $DRY_RUN ]] || "${cmd[@]}"
}

3. Eval with Proper Quoting

dry_run() {
    echo "$*"
    [[ $DRY_RUN ]] || eval "$*"
}

# Usage requires careful quoting:
email_admin() {
    dry_run su - user -c "\"cd /path && command 'with quotes'\""
}

For most cases, I recommend combining array storage with printf %q for maximum reliability:

dry_run() {
    local cmd=("$@")
    
    # Display the command with preserved quotes
    printf 'DRY RUN: '
    printf '%q ' "${cmd[@]}"
    echo
    
    # Execute if not dry run
    [[ $DRY_RUN ]] || "${cmd[@]}"
}

# Example usage preserving complex quoting
email_admin() {
    local mail_cmd="cd '$GIT_WORK_TREE' && git log -1 -p | mail -s '$mail_subject' $admin_email"
    dry_run su - "$target_username" -c "$mail_cmd"
}

Be particularly careful with:

  • Nested quotes within commands
  • Commands containing environment variables
  • Pipelines and redirections
  • Commands with special characters ($, *, etc.)

For maximum compatibility with static analysis tools:

dry_run() {
    if (( DRY_RUN )); then
        # Use shellcheck disable as we're intentionally using eval
        # shellcheck disable=SC2145
        echo "DRY RUN: $@"
    else
        "$@"
    fi
}