diff --git a/functions/_scf b/functions/_scf new file mode 100644 index 0000000..f959130 --- /dev/null +++ b/functions/_scf @@ -0,0 +1,27 @@ +#compdef scf + +_scf() { + _arguments -s \ + '(-r)-r[Recursively inspect referenced files and directories]' \ + '(-n)-n[Open candidate number directly]:candidate number:' \ + '--[End of options]' \ + '1:systemd service:_scf_services' +} + +_scf_services() { + local -a services + + if (( $+commands[systemctl] )); then + services=("${(@f)$(systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null \ + | awk '$1 ~ /\.service$/ {print $1}' \ + | sort -u)}") + fi + + if (( ${#services} > 0 )); then + _describe -t services 'systemd service' services + else + _message 'systemd service' + fi +} + +_scf "$@" diff --git a/scripts/scf b/scripts/scf new file mode 100755 index 0000000..8f16ac4 --- /dev/null +++ b/scripts/scf @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +# Author: OpenAI Codex +# AI-assisted implementation, generated and reviewed with OpenAI Codex. + +set -euo pipefail +# Keep glob expansion and sorting stable across locales, so -n remains +# repeatable when the service and filesystem contents have not changed. +scf_lc_all_was_set=false +scf_original_lc_all="" +if [[ -n ${LC_ALL+x} ]]; then + scf_lc_all_was_set=true + scf_original_lc_all=$LC_ALL +fi +export LC_ALL=C + +restore_locale() { + if [[ $scf_lc_all_was_set == true ]]; then + export LC_ALL=$scf_original_lc_all + else + unset LC_ALL + fi +} + +usage() { + cat >&2 <<'EOF' +Usage: scf [-r] [-n NUM] SERVICE + +Open the configuration file referenced by a systemd service's ExecStart. +SERVICE may be given with or without the .service suffix. + + -r recursively inspect referenced files and directories (maximum reference depth: 3) + -n open candidate NUM directly, without displaying the selection list + +Environment: + SCF_MAX_CANDIDATES maximum number of candidates (default: 20) + SCF_MAX_DEPTH maximum reference depth (default: 3) +EOF + exit 2 +} + +recursive=false +selection_number="" +service="" +while (( $# > 0 )); do + argument=$1 + shift + + if [[ $argument == -- ]]; then + [[ -z $service && $# -eq 1 ]] || usage + service=$1 + shift + break + fi + + if [[ $argument == -* ]]; then + options=${argument#-} + [[ -n $options ]] || usage + while [[ -n $options ]]; do + option=${options:0:1} + options=${options:1} + case $option in + r) recursive=true ;; + n) + if [[ -n $options ]]; then + selection_number=$options + options="" + else + (( $# > 0 )) || usage + selection_number=$1 + shift + fi + ;; + *) usage ;; + esac + done + else + [[ -z $service ]] || usage + service=$argument + fi +done + +if [[ -z $service ]]; then + usage +fi + +interactive=false +if [[ -t 0 && -t 1 && -t 2 ]]; then + interactive=true +fi + +# Prompts and candidate details are written to stderr. Keep pipelines and +# redirected output free of escape sequences. +if [[ $interactive == true ]]; then + color_item=$'\033[1;36m' + color_detail=$'\033[2;37m' + color_prompt=$'\033[1;33m' + color_reset=$'\033[0m' +else + color_item="" + color_detail="" + color_prompt="" + color_reset="" +fi + +if [[ -n $selection_number && ! $selection_number =~ ^[0-9]+$ ]]; then + echo "scf: invalid candidate number: $selection_number" >&2 + exit 2 +fi +if [[ -n $selection_number ]]; then + # Bash arithmetic treats numbers with a leading zero as octal. + selection_number=$((10#$selection_number)) + if (( selection_number < 1 )); then + echo "scf: invalid candidate number: 0" >&2 + exit 2 + fi +fi + +service_argument=$service +if [[ $service != *.service ]]; then + service+=.service +fi + +# Keep the argument a unit name, rather than allowing it to become a +# systemctl option. This also makes accidental shell syntax harmless. +if [[ ! $service =~ ^[A-Za-z0-9_.@:%+-]+\.service$ ]]; then + echo "scf: invalid service name: $service_argument" >&2 + exit 2 +fi + +if ! exec_start=$(systemctl show --no-pager --property=ExecStart --value "$service"); then + echo "scf: cannot inspect $service" >&2 + exit 1 +fi + +if [[ -z $exec_start || $exec_start == "{}" ]]; then + echo "scf: $service has no ExecStart" >&2 + exit 1 +fi + +# Keep recursion bounded even when configuration files refer to each other. +max_candidates=${SCF_MAX_CANDIDATES:-20} +max_reference_depth=${SCF_MAX_DEPTH:-3} +if [[ ! $max_candidates =~ ^[0-9]+$ ]]; then + echo "scf: SCF_MAX_CANDIDATES must be a positive integer" >&2 + exit 2 +fi +if [[ ! $max_reference_depth =~ ^[0-9]+$ ]]; then + echo "scf: SCF_MAX_DEPTH must be a non-negative integer" >&2 + exit 2 +fi +# Normalize user-provided decimal values before using Bash arithmetic. +max_candidates=$((10#$max_candidates)) +max_reference_depth=$((10#$max_reference_depth)) +if (( max_candidates < 1 )); then + echo "scf: SCF_MAX_CANDIDATES must be a positive integer" >&2 + exit 2 +fi +declare -A candidate_seen=() +declare -A candidate_depth=() +declare -A candidate_source=() +declare -A candidate_source_detail=() +config_paths=() +other_paths=() +recursive_paths=() +recursive_depths=() +declare -A recursive_seen=() + +path_pattern="/[^[:space:];,'\"})]+" + +extract_paths() { + grep -a -oE "$path_pattern" || true +} + +has_config_extension() { + local path=$1 + [[ $path =~ \.(conf|cfg|cnf|ini|json|toml|ya?ml|xml|properties)$ ]] +} + +is_config_path() { + local path=$1 + has_config_extension "$path" \ + || [[ $path == *config* || $path == *configuration* ]] +} + +add_candidate() { + local path=$1 + local depth=$2 + local source=$3 + local source_detail=${4:-} + + [[ -f $path ]] || return 1 + [[ -n ${candidate_seen[$path]+seen} ]] && return 0 + (( ${#candidate_seen[@]} < max_candidates )) || return 1 + + candidate_seen[$path]=1 + candidate_depth[$path]=$depth + candidate_source[$path]=$source + candidate_source_detail[$path]=$source_detail + if is_config_path "$path"; then + config_paths+=("$path") + else + other_paths+=("$path") + fi +} + +enqueue_recursive() { + local path=$1 + local depth=$2 + + [[ $recursive == true ]] || return 0 + (( depth <= max_reference_depth )) || return 0 + [[ -f $path ]] || return 0 + [[ -n ${recursive_seen[$path]+seen} ]] && return 0 + + recursive_seen[$path]=1 + recursive_paths+=("$path") + recursive_depths+=("$depth") +} + +expand_directory() { + local directory=$1 + local depth=$2 + local source=$3 + local path + local entries + + [[ $recursive == true && -d $directory ]] || return 0 + + # dotglob also catches hidden configuration files. nullglob prevents the + # literal pattern from being treated as a candidate for an empty directory. + shopt -s dotglob nullglob + entries=("$directory"/*) + shopt -u dotglob nullglob + + for path in "${entries[@]}"; do + # A service's configuration often contains runtime directories such as + # Python's bin directory. Only configuration-looking files from a + # directory should consume the candidate budget. + has_config_extension "$path" || continue + add_candidate "$path" "$depth" "$source" "directory $directory" || continue + enqueue_recursive "$path" "$depth" + done +} + +consider_initial_path() { + local path=$1 + + if [[ -f $path ]]; then + add_candidate "$path" 0 "ExecStart" || true + elif [[ -d $path ]]; then + expand_directory "$path" 0 "ExecStart" + fi +} + +# systemctl's ExecStart value contains both `path=` and `argv[]=`. Pull out +# absolute path tokens, then drop the first one (the executable itself). +# NixOS-generated configuration files are normally immutable regular files in +# /nix/store, but the same also works for /etc and /run paths. +mapfile -t initial_paths < <( + printf '%s\n' "$exec_start" \ + | extract_paths \ + | awk '!seen[$0]++' +) + +if (( ${#initial_paths[@]} > 0 )); then + initial_paths=("${initial_paths[@]:1}") +fi + +for path in "${initial_paths[@]}"; do + consider_initial_path "$path" +done + +paths=("${config_paths[@]}") +if (( ${#paths[@]} == 0 )); then + paths=("${other_paths[@]}") +fi + +if [[ $recursive == true ]]; then + for path in "${paths[@]}"; do + enqueue_recursive "$path" 0 + done + + recursive_index=0 + while (( recursive_index < ${#recursive_paths[@]} )); do + path=${recursive_paths[recursive_index]} + depth=${recursive_depths[recursive_index]} + recursive_index=$((recursive_index + 1)) + (( depth < max_reference_depth )) || continue + next_depth=$((depth + 1)) + + mapfile -t referenced_paths < <(extract_paths < "$path") + for referenced_path in "${referenced_paths[@]}"; do + if [[ -f $referenced_path ]]; then + add_candidate "$referenced_path" "$next_depth" "$path" || continue + enqueue_recursive "$referenced_path" "$next_depth" + elif [[ -d $referenced_path ]]; then + expand_directory "$referenced_path" "$next_depth" "$path" + fi + done + done + + paths=("${config_paths[@]}") + if (( ${#paths[@]} == 0 )); then + paths=("${other_paths[@]}") + fi +fi + +if (( ${#paths[@]} == 0 )); then + echo "scf: no existing configuration path found in ExecStart of $service" >&2 + echo "ExecStart: $exec_start" >&2 + exit 1 +fi + +if [[ $interactive == false && -z $selection_number ]]; then + selection_number=1 +fi + +if [[ -n $selection_number ]]; then + if (( selection_number > ${#paths[@]} )); then + echo "scf: candidate number $selection_number is out of range (1-${#paths[@]})" >&2 + exit 2 + fi + config_path=${paths[selection_number - 1]} +elif (( ${#paths[@]} > 1 )); then + declare -A displayed_indices=() + for i in "${!paths[@]}"; do + displayed_indices["${paths[i]}"]=$((i + 1)) + done + + printf '%sscf: multiple possible configuration files for %s:%s\n' \ + "$color_prompt" "$service" "$color_reset" >&2 + for i in "${!paths[@]}"; do + path=${paths[i]} + source=${candidate_source[$path]} + if [[ $source == ExecStart ]]; then + source_label=ExecStart + elif [[ -n ${displayed_indices[$source]+seen} ]]; then + source_label="#${displayed_indices[$source]}" + else + source_label=$source + fi + source_detail=${candidate_source_detail[$path]} + if [[ -n $source_detail ]]; then + source_label+=" ($source_detail)" + fi + printf '%s %d) %s%s\n' \ + "$color_item" "$((i + 1))" "$path" "$color_reset" >&2 + printf '%s depth: %d; derived from: %s%s\n' \ + "$color_detail" "${candidate_depth[$path]}" "$source_label" "$color_reset" >&2 + done + read -r -p "${color_prompt}Select a file [1]: ${color_reset}" selection + selection=${selection:-1} + if [[ ! $selection =~ ^[0-9]+$ ]]; then + echo "scf: invalid selection" >&2 + exit 2 + fi + selection=$((10#$selection)) + if (( selection < 1 || selection > ${#paths[@]} )); then + echo "scf: invalid selection" >&2 + exit 2 + fi + config_path=${paths[selection - 1]} +else + config_path=${paths[0]} +fi + +if [[ $interactive == false ]]; then + restore_locale + exec cat -- "$config_path" +fi + +editor=${VISUAL:-${EDITOR:-vim}} +read -r -a editor_command <<< "$editor" +if (( ${#editor_command[@]} == 0 )); then + editor_command=(vim) +fi +restore_locale +exec "${editor_command[@]}" "$config_path" diff --git a/tools/test-scf.sh b/tools/test-scf.sh new file mode 100755 index 0000000..4c7cca8 --- /dev/null +++ b/tools/test-scf.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +THIS_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SCF="$THIS_DIR/../scripts/scf" +TEST_DIR=$(mktemp -d /tmp/scf.XXXXXX) +trap 'rm -rf "$TEST_DIR"' EXIT + +MOCK_BIN="$TEST_DIR/bin" +DATA_DIR="$TEST_DIR/data" +MOCK_SYSTEMCTL_LOG="$TEST_DIR/systemctl.log" +MOCK_MAIN="$DATA_DIR/main.yaml" +MOCK_CHILD="$DATA_DIR/child.conf" +MOCK_NOTE="$DATA_DIR/notes.txt" +mkdir -p "$MOCK_BIN" "$DATA_DIR" +export MOCK_SYSTEMCTL_LOG MOCK_MAIN MOCK_CHILD MOCK_NOTE + +printf 'include %s\n' "$MOCK_CHILD" > "$MOCK_MAIN" +printf 'child configuration\n' > "$MOCK_CHILD" +printf 'not a configuration candidate\n' > "$MOCK_NOTE" + +cat > "$MOCK_BIN/systemctl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG" +service=${@: -1} +case "$service" in + demo.service) + printf 'path=/usr/bin/demo ; argv[0]=demo ; argv[1]=%s ; argv[2]=%s ;\n' \ + "$MOCK_MAIN" "$MOCK_NOTE" + ;; + empty.service) + printf 'path=/usr/bin/empty ; argv[0]=empty ;\n' + ;; + *) + exit 1 + ;; +esac +EOF +chmod +x "$MOCK_BIN/systemctl" + +run_scf() { + PATH="$MOCK_BIN:$PATH" "$SCF" "$@" +} + +# A service name without .service is accepted, and non-interactive mode prints +# the only configuration candidate instead of opening an editor. +output=$(run_scf demo 2> "$TEST_DIR/stderr") +grep -Fxq 'include '"$MOCK_CHILD" <<< "$output" +grep -Fq -- '--property=ExecStart --value demo.service' "$MOCK_SYSTEMCTL_LOG" + +# Recursive mode discovers configuration files referenced by the first one. +output=$(run_scf -r -n 2 demo) +grep -Fxq 'child configuration' <<< "$output" + +# Decimal values with a leading zero must not be treated as invalid octal. +output=$(SCF_MAX_DEPTH=08 SCF_MAX_CANDIDATES=08 run_scf demo) +grep -Fxq 'include '"$MOCK_CHILD" <<< "$output" +if run_scf -n 08 demo > "$TEST_DIR/out" 2> "$TEST_DIR/stderr"; then + echo 'expected scf -n 08 to fail because only one candidate exists' >&2 + exit 1 +fi +grep -Fq 'candidate number 8 is out of range' "$TEST_DIR/stderr" + +# Invalid service names are rejected before systemctl is invoked. +before=$(wc -l < "$MOCK_SYSTEMCTL_LOG") +if run_scf 'demo; touch /tmp/unexpected' > "$TEST_DIR/out" 2> "$TEST_DIR/stderr"; then + echo 'expected an invalid service name to fail' >&2 + exit 1 +fi +grep -Fq 'invalid service name' "$TEST_DIR/stderr" +after=$(wc -l < "$MOCK_SYSTEMCTL_LOG") +test "$before" -eq "$after" + +# A service without a referenced file reports a useful failure. +if run_scf empty > "$TEST_DIR/out" 2> "$TEST_DIR/stderr"; then + echo 'expected a service without a config path to fail' >&2 + exit 1 +fi +grep -Fq 'no existing configuration path found' "$TEST_DIR/stderr" + +echo 'scf tests passed' diff --git a/tools/test.zsh b/tools/test.zsh index 63ea27b..cb0c67b 100644 --- a/tools/test.zsh +++ b/tools/test.zsh @@ -34,6 +34,7 @@ dogo doll dfs cd tools/test-getopts.sh +tools/test-scf.sh tools/test-riot-gpg.sh tools/test-sagent-gpg-pin.sh tools/common.sh get_os_name