Chapter 14Lesson 04~70 minutes

Arrays, Command Substitution, and Input Handling

Represent argument lists as arrays, capture command output without corrupting boundaries, and read text or path data through explicit delimiters and trusted channels.

Indexed arraysSafe inputNUL-delimited lab

Learning objectives

By the end of this lesson

  • Create and expand indexed and associative Bash arrays safely.
  • Distinguish one string from an argument vector.
  • Understand command substitution’s newline and splitting behavior.
  • Read text with IFS= read -r and paths with NUL delimiters.
  • Avoid eval, unsafe parsing, and accidental code execution from input.

1. Shell automation needs explicit data boundaries

Many Bash defects are data-model defects. A filename list is stored in one string; a command and its arguments are concatenated; command output is split on whitespace; or user input is reinterpreted as shell source. Arrays, delimiters, and separate input channels let scripts carry structure instead of guessing it later.

Choosing a Bash representation from the input model
flowchart LR
  S["Source: arguments, file, or command"] --> D{"What is the data model?"}
  D -- one scalar --> V["Quoted variable"]
  D -- argument list --> A["Indexed array"]
  D -- key/value map --> M["Associative array"]
  D -- text records --> R["IFS= read -r"]
  D -- path records --> N["NUL delimiter"]
  V --> E["Expand with preserved boundary"]
  A --> E
  M --> E
  R --> E
  N --> E
Do not serialize too early

If you already have separate arguments, keep them in an array. Turning them into a string and later reparsing that string creates ambiguity and injection risk.

2. Indexed arrays preserve ordered argument lists

Indexed arrays use integer subscripts. Append with array+=(value), inspect length with ${#array[@]}, and expand every element with "${array[@]}". Quoted [@] preserves each element as a separate argument.

declare -a curl_args=(
  --fail
  --silent
  --show-error
  --connect-timeout 5
)

url='https://example.com/health?name=blue team'
curl_args+=(--header 'Accept: application/json')

printf 'argument_count=%d\n' "${#curl_args[@]}"
printf 'arg=<%s>\n' "${curl_args[@]}"

# The command receives the URL as one argument and every option separately.
# curl "${curl_args[@]}" "$url"

for index in "${!curl_args[@]}"; do
  printf 'curl_args[%d]=%q\n' "$index" "${curl_args[$index]}"
done

"${array[*]}" joins all elements into one word using the first character of IFS. That can be useful for display, but it is not the correct way to pass an argument list.

3. Associative arrays model small key/value maps

Bash associative arrays use string keys. They are useful for small in-memory mappings such as environment-to-endpoint or status counters. They are not a replacement for JSON, YAML, databases, or ordered records when interoperability and schema matter.

declare -A endpoints=(
  [development]='http://127.0.0.1:8080'
  [staging]='https://staging.example.net'
  [production]='https://api.example.net'
)

environment=${1:-staging}

if [[ -v 'endpoints[$environment]' ]]; then
  printf 'endpoint=%s\n' "${endpoints[$environment]}"
else
  printf 'Unknown environment: %s\n' "$environment" >&2
  exit 2
fi

for key in "${!endpoints[@]}"; do
  printf '%s\t%s\n' "$key" "${endpoints[$key]}"
done | sort

Iteration order is not a reliable configuration contract. Sort keys for human reports or use a format designed for ordered data.

4. Command substitution captures text, not arbitrary records

$(command) captures standard output and removes trailing newline characters. When the substitution is unquoted, the result is then subject to word splitting and pathname expansion. Quoted substitution preserves one argument, but it is still one text value—not an array of records.

kernel=$(uname -r)
printf 'kernel=<%s>\n' "$kernel"

# Capture multiple lines as array elements.
mapfile -t shells < <(awk -F: '{print $7}' /etc/passwd | sort -u)
printf 'shell=<%s>\n' "${shells[@]}"

# Do not use: files=($(find ...))
# Pathnames may contain spaces, tabs, wildcard characters, or newlines.

declare -a config_files=()
while IFS= read -r -d '' path; do
  config_files+=("$path")
done < <(find /etc -maxdepth 2 -type f -name '*.conf' -print0 2>/dev/null)

printf 'config_count=%d\n' "${#config_files[@]}"

Process substitution, such as < <(command), provides a file-like input to a command or loop while keeping the loop in the current shell. It is a Bash feature and therefore belongs in scripts that explicitly target Bash.

5. Input handling requires an explicit delimiter and trust model

read is designed for one record at a time. Use IFS= to prevent trimming at the edges and -r to keep backslashes literal. For pathnames, newline is not a complete delimiter because Linux filenames may contain newlines; NUL is the robust separator.

# Read ordinary text lines exactly, except for the line-ending delimiter.
while IFS= read -r line || [[ -n $line ]]; do
  printf 'line=<%s>\n' "$line"
done < input.txt

# Split a controlled colon-delimited record.
while IFS=: read -r name _ uid gid comment home shell; do
  printf 'user=%s uid=%s home=%s shell=%s\n' \
    "$name" "$uid" "$home" "$shell"
done < /etc/passwd

# Read pathnames safely from find.
while IFS= read -r -d '' path; do
  printf 'path=%q\n' "$path"
done < <(find . -type f -print0)
Input is data

Do not pass input to eval, embed it in shell source, or use it as an unquoted format string. Validate according to the expected grammar and pass it as quoted arguments.

6. Here-documents and here-strings create explicit input streams

A here-document feeds a block of text to standard input. Quoting the delimiter controls whether parameter, command, and arithmetic expansion occur inside the body. A here-string feeds one expanded string followed by a newline.

service='payments-api'

# Quoted delimiter: body remains literal.
cat > template.txt <<'EOF'
service=${service}
host=$(hostname)
EOF

# Unquoted delimiter: selected expansions occur before input is delivered.
cat > rendered.txt <<EOF
service=${service}
host=$(hostname)
EOF

# Feed one value as standard input.
grep -q '^payments-' <<< "$service" && printf 'expected prefix\n' 

Never include secrets in shell tracing or generated here-documents without understanding file permissions, process arguments, logs, and cleanup. Chapter 15 develops temporary-resource and cleanup controls.

7. Hands-on lab: inventory difficult filenames safely

The lab creates filenames containing spaces, wildcard characters, tabs, and a newline, then inventories them using NUL-delimited records and arrays.

lab="$HOME/devops-academy/linux/chapter14/lesson04"
rm -rf "$lab"
mkdir -p "$lab/input"
cd "$lab"

printf 'alpha\n' > 'input/normal.txt'
printf 'beta\n' > 'input/two words.txt'
printf 'gamma\n' > 'input/*.literal'
printf 'delta\n' > $'input/tab\tname.txt'
printf 'epsilon\n' > $'input/line\nbreak.txt'

cat > inventory.sh <<'SCRIPT'
#!/usr/bin/env bash

main() {
  if (( $# != 1 )); then
    printf 'Usage: %s DIRECTORY\n' "${0##*/}" >&2
    return 2
  fi

  local root=$1 path
  if [[ ! -d $root ]]; then
    printf 'Not a directory: %s\n' "$root" >&2
    return 3
  fi

  local -a files=()
  mapfile -d '' -t files < <(find "$root" -maxdepth 1 -type f -print0)

  printf 'count=%d\n' "${#files[@]}"
  for path in "${files[@]}"; do
    printf 'path=%q\tsize=%s\n' "$path" "$(stat -c %s -- "$path")"
  done
}

main "$@"
SCRIPT

chmod u+x inventory.sh
./inventory.sh input | tee inventory.txt
bash -n inventory.sh
printf 'filesystem_count=%s\n' "$(find input -maxdepth 1 -type f -printf '.' | wc -c)"

Verification checklist

8. Common input and array mistakes

“A space-separated string is an argument list.”

It cannot preserve embedded spaces or empty elements. Use an indexed array.

“Quoted command substitution creates an array.”

It creates one string. Use mapfile or a read loop with an explicit delimiter.

“One filename per line is always safe.”

Filenames may contain newline characters. NUL-delimited records are the robust pathname interface.

“eval is needed for dynamic commands.”

Usually the command name and arguments can be represented separately and invoked with an array, avoiding source-code reinterpretation.

9. Knowledge check

Question 1. What is the difference between "${items[@]}" and "${items[*]}"?

Question 2. Why is files=($(find ...)) unsafe?

Question 3. Why use IFS= read -r?

10. Summary

Safe input handling starts by choosing the correct representation. Use quoted scalars for one value, arrays for argument lists, associative arrays for small maps, text reads with explicit delimiters, and NUL records for pathnames. Command substitution captures text, not structured records, and untrusted input must remain data rather than shell source.

11. Further reading

Next lesson

Debugging Bash with set Options and shellcheck

Diagnose syntax, expansion, status, and data defects using tracing, state inspection, option semantics, static analysis, and tests.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.