Jak pisać skrypty Bash bez błędów: set, pułapki i dobre nawyki

0
54
Rate this post

Nawigacja:

Dlaczego skrypty Bash tak często sprawiają kłopoty

Bash jako „klej” systemu – ogromna moc, mało zabezpieczeń

Bash jest językiem, który co do zasady pełni rolę „kleju” pomiędzy narzędziami systemowymi. Umożliwia łączenie komend, przekierowań, potoków, pracy na plikach i sieci w jednym, krótkim skrypcie. Ta elastyczność ma jednak cenę: domyślne zachowanie Basha jest bardzo liberalne. Skrypt zwykle kontynuuje działanie nawet wtedy, gdy część kroków się nie udała, a powłoka nie wymusza jawnej obsługi błędów.

W praktyce oznacza to, że drobny błąd w jednej linii – literówka w nazwie katalogu, źle zacytowana zmienna, brak pliku – nie zatrzymuje skryptu. Kolejne polecenia wykonują się, jakby nic się nie stało, tyle że działają na niepełnych danych, złych ścieżkach albo na pustych wartościach. Efekty bywają trudne do odtworzenia, bo nie widać od razu, w którym momencie wszystko poszło nie tak.

Dodatkowo Bash nie ma wbudowanego systemu typów znanego z języków wysokiego poziomu. Zmienna może nagle stać się pusta, zostać nadpisana inną wartością lub zawierać dane w zupełnie innym formacie, niż zakładano. Bez dobrych nawyków: stosowania set, pułapek trap, walidacji danych wejściowych i czytelnego logowania błędów, skrypty powłoki stają się polem minowym.

Ręczne uruchamianie kontra cron, systemd i CI

Ten sam skrypt Bash może zachowywać się inaczej, w zależności od środowiska uruchomienia. Interaktywnie (w terminalu) użytkownik ma ustawione zmienne środowiskowe, wczytane profile (~/.bashrc, ~/.profile), a w razie problemu widzi błędy na ekranie. Gdy skrypt startuje z crona lub jako jednostka systemd, ma zazwyczaj znacznie uboższe środowisko, inne $PATH i brak interakcji z użytkownikiem.

W środowiskach CI/CD dochodzą kolejne czynniki: inna wersja Basha, inne ścieżki do narzędzi, brak niektórych zależności, równoległe uruchamianie wielu zadań. Skrypt napisany „na szybko” i przetestowany w lokalnym terminalu nagle zaczyna losowo się wywracać w pipeline’ach lub przy starcie serwisu. Co gorsza, brak konsekwentnej obsługi błędów często sprawia, że pipeline kończy się statusem 0, mimo że część kroków w środku nie zadziałała.

Przy projektowaniu bezpiecznych skryptów Bash trzeba więc założyć, że będą uruchamiane w różnych, czasem zaskakujących kontekstach. Odpowiedni shebang, jawne ustawianie $PATH, defensywne użycie set i trap, a także przemyślane logowanie komunikatów na stderr stanowią podstawę przewidywalnego działania w takich warunkach.

Konsekwencje drobnych błędów: niepełne operacje, utracone dane

Typowe błędy w Bashu rzadko kończą się głośnym, oczywistym „crashem”. Dużo częściej prowadzą do cichych, częściowych operacji: coś zostało skopiowane, ale nie do końca; baza została zbackupowana, ale tylko część tabel; logi zostały zarchiwizowane, ale przy okazji usunięto aktualnie używany katalog. Bez dobrego logowania i bez wymuszania zatrzymania przy pierwszej nieprawidłowości trudno to zauważyć od razu.

W systemach produkcyjnych takie zachowanie jest szczególnie niebezpieczne. Pół-wykonane deploymenty, nie w pełni zrestartowane usługi, katalogi z danymi pozostawione w stanie pośrednim – to typowe skutki niedopracowanych skryptów. Po kilku miesiącach nikt już nie pamięta, jakie były założenia, a zmiana środowiska (nowy serwer, nowa wersja systemu) obnaża ukryte problemy.

Bezpieczne skrypty Bash nie polegają na szczęściu. Opierają się na jawnym sprawdzaniu kodów wyjścia, świadomym użyciu set, przewidywalnych wzorcach obsługi błędów i konsekwentnym stosowaniu cudzysłowów przy zmiennych. Wtedy drobny błąd objawia się szybko i czytelnie: skrypt przerywa działanie z jednoznacznym komunikatem, zamiast po cichu pozostawiać system w dziwnym stanie.

Przykład z życia: rm -rf i pusta zmienna

Jedna z klasycznych historii dotyczy komendy:

rm -rf "$DIR"

Założenie: zmienna $DIR zawiera ścieżkę do katalogu tymczasowego. Gdzie tkwi problem? Jeśli wcześniej nie ustawiono DIR (albo została wyczyszczona), polecenie po rozwinięciu wygląda tak:

rm -rf ""

W tym wariancie nic złego się jeszcze nie dzieje – rm dostaje pusty argument i po prostu zgłasza błąd. Jednak w praktyce znacznie częściej brak cudzysłowów prowadzi do groźniejszej sytuacji:

rm -rf $DIR

Jeżeli $DIR jest pusty, polecenie staje się:

rm -rf

A to z kolei jest interpretowane przez powłokę jako rm -rf . (bieżący katalog) lub, w zależności od otoczenia, jako próba usunięcia wszystkiego w zasięgu. Połączenie braku cudzysłowów, braku walidacji zmiennej oraz braku opcji takich jak set -u potrafi spowodować realne straty na serwerze. Jedna linia, jedno przeoczenie, a skutki mogą być bardzo trudne do odrobienia.

Minimalne podstawy: jak Bash wykonuje skrypty i obsługuje statusy wyjścia

Kody wyjścia i rola zmiennej $?

Każde polecenie uruchomione w Bashu kończy się kodem wyjścia – liczbą całkowitą dostępną w specjalnej zmiennej $?. Konwencja jest prosta: 0 oznacza sukces, a wartość różna od 0 – błąd, warunek szczególny lub inną sytuację, którą trzeba zinterpretować. Bash sam w sobie nie rozróżnia „rodzajów” błędów; robi to dopiero skrypt, analizując wartości.

Przykład prostego sprawdzenia:

cp /tmp/plik /backup/ && echo "Kopia ok" || echo "Błąd kopiowania"

Jeżeli cp zakończy się kodem 0 (skopiowano plik), zostanie wykonane echo "Kopia ok". W przeciwnym przypadku nastąpi przejście do części po ||. Ten sam mechanizm wykorzystują instrukcje if:

if cp /tmp/plik /backup/; then
    echo "Sukces"
else
    echo "Niepowodzenie"
fi

Tymczasem zmienna $? przechowuje kod wyjścia OSTATNIEGO uruchomionego polecenia. Traci się go natychmiast po wykonaniu kolejnej komendy, więc jeśli trzeba go przeanalizować, należy to zrobić od razu:

cp /tmp/plik /backup/
status=$?
echo "Kopia zakończona kodem: $status"

Kody wyjścia w potokach, if, && i ||

W prostym, pojedynczym poleceniu kod wyjścia jest oczywisty. Schody zaczynają się przy potokach i złożonych konstrukcjach logicznych. Domyślnie Bash w potoku zwraca kod wyjścia ostatniego polecenia:

grep "wzorzec" plik.txt | wc -l
echo $?

Kod wyjścia będzie dotyczył wc, a nie grep. Jeśli grep zakończy się błędem (np. plik nie istnieje), ale wc przyjmie pusty strumień i zakończy się poprawnie, cały potok zwróci 0. Bez dodatkowej konfiguracji Bash nie sygnalizuje błędu w środku potoku, co bywa mylące.

Operatory && i || działają na poziomie kodów wyjścia:

  • cmd1 && cmd2cmd2 wykona się tylko jeśli cmd1 zakończyło się kodem 0;
  • cmd1 || cmd2cmd2 wykona się tylko jeśli cmd1 zakończyło się kodem różnym od 0.

W złożonych wyrażeniach łączonych && i || trzeba pamiętać, że ostatnie wykonane polecenie decyduje o stanie całego łańcucha. To jest istotne również przy użyciu set -e, które reaguje na niezerowe kody wyjścia w dość specyficzny sposób.

Błąd w Bashu a niezainicjowana lub pusta zmienna

Bash z definicji jest bardzo tolerancyjny dla zmiennych. Jeżeli odwołamy się do zmiennej, która nie została ustawiona, po prostu rozwija się ona do pustego łańcucha. Nie jest to błąd wykonania, nie podnosi kodu wyjścia, nie zatrzymuje skryptu. To jedna z głównych przyczyn cichych awarii. Dopiero użycie opcji takich jak set -u (nounset) powoduje, że próba użycia niezdefiniowanej zmiennej zakończy się błędem.

Od strony Basha „błąd” oznacza niezerowy kod wyjścia komendy. Pusty string, brak pliku konfiguracyjnego, niepoprawny format wejścia – to wszystko trzeba wykrywać ręcznie. Komenda taka jak [[ -z "$VAR" ]] sama w sobie może zakończyć się sukcesem (kod 0), choć logicznie wykrywa niepożądany stan (pusta zmienna). W bezpiecznych skryptach stan logiczny (np. brak wymaganej wartości) często przekłada się na manualne wygenerowanie błędu poprzez exit 1.

Z punktu widzenia projektowania należy więc odróżnić: sytuacje, w których samo polecenie się nie wywraca, od tych, w których skrypt nie powinien kontynuować działania. Bez tego łatwo o pozornie „poprawnie działające” skrypty, które w praktyce wykonują coś zupełnie innego niż zamierzono.

Znaczenie set -e przy niepełnym zrozumieniu mechaniki błędów

Opcja set -e (alias set -o errexit) jest często traktowana jako „magiczny przełącznik bezpieczeństwa”: jeśli coś się nie powiedzie, skrypt sam się zakończy. W praktyce set -e ma dość złożone i nieintuicyjne zasady działania, szczególnie w kontekście potoków, bloków if, pętli czy funkcji.

Bez zrozumienia, jak Bash interpretuje kody wyjścia w różnych konstrukcjach, set -e potrafi powodować trudne do zdiagnozowania sytuacje: skrypt nagle przestaje działać po „niewinnym” dodaniu kolejnej linii lub – przeciwnie – nie przerywa działania tam, gdzie użytkownik by się tego spodziewał. Dlatego przy bezpiecznym pisaniu skryptów Bash set -e jest tylko jednym z elementów, nigdy jedynym mechanizmem ochrony.

Przytulne biurko z notatkami i laptopem do pisania skryptów Bash
Źródło: Pexels | Autor: Ron Lach

set jako podstawowe narzędzie podnoszenia bezpieczeństwa skryptów

Rola polecenia set – zmiana domyślnego zachowania powłoki

Polecenie set służy do konfiguracji bieżącej powłoki. Pozwala włączać i wyłączać różne tryby, dzięki którym Bash staje się bardziej rygorystyczny – lub przeciwnie, bardziej elastyczny. W kontekście bezpiecznych skryptów interesuje przede wszystkim zestaw opcji wpływających na obsługę błędów, niezainicjowanych zmiennych oraz potoków.

Ważne jest, że set działa na poziomie instancji powłoki. Opcje włączone na początku skryptu obowiązują w nim (oraz z reguły w funkcjach), ale nie wpływają na inne niezależne procesy powłoki. W subshellach – tworzonych np. poprzez nawiasy ( ... ) – konfiguracja jest dziedziczona, ale można ją zmieniać lokalnie.

Prawidłowe wykorzystanie set nie jest magią. To świadomy wybór, jakie domyślne zachowanie powłoki uznaje się za akceptowalne. W skryptach przeznaczonych do pracy na produkcji zwykle stosuje się kombinację kilku opcji, aby ograniczyć efekt przypadkowych błędów.

Najczęściej używane opcje: -e, -u, -o pipefail, -x

W praktyce bezpieczeństwa skryptów Bash najczęściej pojawiają się następujące opcje:

  • set -e / set -o errexit – powoduje zakończenie skryptu, gdy niektóre polecenia zwrócą niezerowy kod wyjścia (z istotnymi wyjątkami);
  • set -u / set -o nounset – traktuje odwołania do niezdefiniowanych zmiennych jako błąd wykonania;
  • set -o pipefail – zmienia zachowanie potoków tak, aby kod wyjścia dotyczył pierwszego polecenia, które zakończyło się błędem, a nie tylko ostatniego;
  • set -x – włącza tryb debugowania; Bash wypisuje na stderr każdą wykonywaną komendę wraz z rozwiniętymi zmiennymi.

Zwykle korzysta się z nich w kombinacji. Przykładowo:

Łączenie opcji set w praktyce

Najczęściej spotykany „zestaw startowy” to:

set -Eeuo pipefail

Co tu się dzieje konkretnie:

  • -E – rozszerza działanie pułapek na błędy (ERR) także na funkcje i subshelle;
  • -e – kończy skrypt przy niezerowym kodzie wyjścia (z wyjątkami);
  • -u – traktuje użycie niezdefiniowanej zmiennej jako błąd;
  • -o pipefail – powoduje, że potok kończy się błędem, jeśli którekolwiek polecenie w potoku go zwróci.

Tak skonfigurowany Bash zachowuje się znacznie mniej „liberalnie”. Skrypt przestaje milcząco kontynuować pracę przy typowych pomyłkach (literówka w nazwie zmiennej, błąd w środku potoku). To nie zwalnia z myślenia, lecz ogranicza pole minowe.

Korzystne bywa również lokalne wyłączenie niektórych opcji. Przykładowo, jeśli w jednym miejscu trzeba bezpiecznie sprawdzić, czy zmienna istnieje, można użyć subshella:

( set +u
  echo "Wartość (jeśli istnieje): $MOZE_PUSTA"
)

Wyłączenie -u w subshellu nie zmienia ustawień w głównym skrypcie. To sposób na ograniczone „zawieszenie rygoru”, bez całkowitego rezygnowania z ochrony.

Pułapki opcji set -e i typowe wyjątki

set -e nie przerywa skryptu w każdej sytuacji, gdy polecenie kończy się błędem. Bash zawiera kilka wyjątków, o których trzeba pamiętać:

  • w bloku if – niezerowy kod wyjścia komendy testowanej w if nie przerywa działania, bo sam if służy do interpretacji sukcesu/porażki;
  • w warunku pętli while / until – podobnie jak przy if, niezerowy kod jest częścią logiki sterującej;
  • po lewej stronie && i || – błędy są wtedy traktowane jako element wyrażenia logicznego;
  • w niektórych kontekstach w subshellach – zależnie od wersji Basha i sposobu wywołania.

Efekt jest taki, że pozornie podobne fragmenty kodu mogą zachowywać się odmiennie:

set -e

cp plik1 plik2
echo "To się nie wykona, jeśli cp zwróci błąd"

if cp plik1 plik2; then
    echo "Sukces"
fi
echo "To się wykona nawet, jeśli cp się nie powiedzie"

Tego typu niuanse powodują, że set -e warto traktować jako wsparcie, a nie jedyny bezpiecznik. Często lepszym rozwiązaniem jest jawne sprawdzanie wyników i reagowanie w kontrolowany sposób (funkcje pomocnicze do obsługi błędów, pułapki).

Zmienianie ustawień set w funkcjach i modułach

W większych skryptach naturalne staje się dzielenie kodu na funkcje i osobne pliki źródłowe (source). Konfigurację set dobrze jest wtedy uporządkować:

  • ustalenie „twardych” zasad na samym początku głównego skryptu, np.: set -Eeuo pipefail;
  • unikanie rozbijania tej konfiguracji w wielu miejscach; wyjątki stosować lokalnie (subshell, krótkie set +e z natychmiastowym przywróceniem set -e);
  • w plikach dołączanych przez source ostrożnie używać set – lepiej je traktować jako biblioteki funkcji, które respektują zasady z głównego skryptu, niż samodzielnie wszystko przełączają.

Przykładowy wzorzec lokalnej zmiany:

krytyczna_operacja() {
    set +e
    jakiś_komplikowany_potok
    local status=$?
    set -e

    if (( status != 0 )); then
        echo "Błąd w potoku" >&2
        return "$status"
    fi
}

W ten sposób logika błędu jest jawnie zapisana, a ogólny rygor -e nadal obowiązuje poza funkcją.

Pułapki (trap) – kontrola nad sygnałami, błędami i sprzątaniem

Podstawy działania trap

trap pozwala zarejestrować komendę, która ma zostać wykonana przy wystąpieniu określonego sygnału lub „zdarzenia” powłoki (takiego jak koniec skryptu czy błąd). Najprostsza forma:

trap 'echo "Przerywam"; exit 1' INT

Sprawia, że naciśnięcie Ctrl+C (sygnał INT) nie zakończy skryptu w sposób domyślny, lecz wykona wskazaną komendę. W kontekście bezpieczeństwa kluczowe są pułapki na:

  • EXIT – wywoływana przy zakończeniu powłoki (niezależnie od przyczyny);
  • ERR – wywoływana przy niezerowym kodzie wyjścia (zależnie od set -E);
  • sygnały typu INT, TERM, HUP – przerwanie pracy przez użytkownika lub system.

Sprzątanie zasobów przy użyciu trap EXIT

Przy pracy z plikami tymczasowymi, blokadami czy zasobami zewnętrznymi (np. sesja SSH, połączenie VPN) kluczowe jest ich uporządkowanie niezależnie od tego, czy skrypt zakończy się sukcesem, czy błędem. Do tego najlepiej nadaje się pułapka EXIT:

tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

# ... używanie $tmpdir ...

# przy każdym zakończeniu skryptu tmpdir zostanie usunięty

Ważne, aby komenda w trap była odporna na częściowe niepowodzenia. Jeżeli pułapka sama spowoduje błąd (np. rm zakończy się kodem różnym od 0), skrypt jest już w trakcie kończenia, więc próba dalszej obsługi często nie ma sensu. Dobrą praktyką jest stosowanie poleceń idempotentnych – „sprzątanie kilka razy z rzędu” nie powinno szkodzić.

Pułapka ERR i zależność od set -E

trap ERR pozwala zarejestrować reakcję na błędy, czyli niezerowe kody wyjścia. Przykład:

set -Eeuo pipefail

on_error() {
    local status=$?
    echo "Błąd (kod: $status) w linii $BASH_LINENO" >&2
}

trap on_error ERR

Sama pułapka zadziała jednak tylko na odpowiednich poziomach. Włączenie opcji -E powoduje, że ERR jest dziedziczony w funkcjach i subshellach. Bez -E łatwo dojść do sytuacji, w której błędy wewnątrz funkcji nie uruchamiają pułapki, a skrypt kontynuuje pracę.

Trzeba też brać pod uwagę, że ERR nie jest wywoływane w niektórych kontekstach podobnych do wyjątków set -e – np. w if. Dlatego pułapka błędów powinna być traktowana jako centralny punkt logowania i diagnostyki, a nie jako jedyne miejsce, w którym „łapie się” każde niepowodzenie.

Obsługa sygnałów przerwania (INT, TERM, HUP)

Przy dłużej działających skryptach (backupy, migracje, przetwarzanie dużych zbiorów danych) naturalne jest, że ktoś kiedyś naciśnie Ctrl+C albo proces zostanie zakończony przez system. Odpowiednia obsługa sygnałów pozwala na kontrolowane wyjście:

cleanup() {
    echo "Sprzątanie przed wyjściem..." >&2
    # np. odblokowanie plików, zatrzymanie usług pomocniczych
}

trap cleanup INT TERM HUP

Jeżeli skrypt modyfikuje dane w kilku krokach (np. wprowadza zmiany do bazy i plików równolegle), zatrzymanie go „w pół” bez dodatkowej logiki może pozostawić system w stanie niejednoznacznym. Czasem wystarczy wyraźny komunikat i przerwanie przed wykonaniem krytycznego etapu, innym razem konieczne jest wdrożenie mechanizmu rollbacku – to już jednak zagadnienie projektowania całego procesu, a nie samego Basha.

Łączenie wielu trap i ponowne ustawianie pułapek

Pułapki można nadpisywać – kolejne wywołanie trap dla tego samego sygnału zastąpi poprzednie. W większych skryptach łatwo wtedy przypadkowo usunąć globalne sprzątanie. Rozsądniejsze jest dobudowywanie zachowania:

global_cleanup() {
    # globalne sprzątanie
}

trap global_cleanup EXIT

lokalna_sekcja() {
    lokalne_cleanup() {
        # loklane sprzątanie
    }

    trap 'lokalne_cleanup; global_cleanup' EXIT
    # ...
}

Takie podejście jest jednak podatne na pomyłki (np. wywołanie global_cleanup kilka razy). W praktyce wygodniej jest trzymać jeden, centralny handler EXIT, który wywołuje serię prostych, idempotentnych funkcji sprzątających, rejestrowanych w jednym miejscu.

Kolorowy kod PHP na ciemnym ekranie monitora
Źródło: Pexels | Autor: Pixabay

Struktura bezpiecznego skryptu Bash – szkielet i organizacja kodu

Szkielet startowy skryptu

Ustalony, powtarzalny szkielet zmniejsza ryzyko prostych przeoczeń. Przykładowy, praktyczny układ:

#!/usr/bin/env bash
set -Eeuo pipefail

# Globalne zmienne tylko z wyraźnym uzasadnieniem
SCRIPT_NAME=${0##*/}

# Pułapki
cleanup() {
    # sprzątanie globalne
    :
}

on_error() {
    local status=$?
    echo "[$SCRIPT_NAME] Błąd (kod: $status) w linii ${BASH_LINENO[0]}" >&2
}

trap cleanup EXIT
trap on_error ERR

usage() {
    cat <<'EOF'
Użycie: skrypt [opcje]
  -h    pomoc
EOF
}

parse_args() {
    while getopts ":h" opt; do
        case "$opt" in
            h) usage; exit 0 ;;
            ?) echo "Nieznana opcja: -$OPTARG" >&2; exit 2 ;;
        esac
    done
    shift $((OPTIND - 1))
}

main() {
    parse_args "$@"
    # główna logika
}

main "$@"

Taki szkielet narzuca kilka dobrych nawyków jednocześnie: odseparowanie logiki od parsowania argumentów, centralne miejsce na pułapki, brak „latającego” kodu globalnego poza main.

Unikanie efektów ubocznych w kodzie globalnym

Umieszczanie „prawdziwego” kodu od razu w przestrzeni globalnej (poza funkcjami) utrudnia testowanie i ponowne wykorzystanie. Bezpieczniej jest, gdy w części globalnej znajdują się wyłącznie:

  • deklaracje ustawień (set),
  • definicje funkcji,
  • deklaracje stałych i konfiguracji,
  • rejestracja pułapek,
  • końcowe wywołanie main "$@".

Reszta powinna być zamknięta w funkcjach. Dzięki temu łatwiej zarządzać błędami (np. return zamiast exit), a także testować poszczególne części skryptu w izolacji.

Konwencje nazewnicze i podział na moduły

Przy skryptach liczących kilkaset linii naturalne staje się wprowadzenie modularności. Typowy podział:

  • lib/fs.sh – funkcje operujące na systemie plików (bezpośrednio: cp, mv, rsync);
  • lib/log.sh – funkcje logujące (log_info, log_error, z czasem, poziomami logowania);
  • lib/validate.sh – walidacja wejścia (ścieżki, parametry, formaty).

Główny skrypt staje się wówczas raczej orkiestratorem niż zbiorem pojedynczych poleceń. Wpływa to także na bezpieczeństwo: miejsca wykonujące faktycznie niebezpieczne operacje (np. usuwanie plików) są skupione w jednym module, łatwiejszym do przeglądu pod kątem błędów.

Zmienne, cudzysłowy i rozwinięcia – źródło większości niespodzianek

Rozwinięcia słów a słynne „word splitting”

Przy każdej komendzie Bash przeprowadza serię kroków: rozwija zmienne, wykonuje globbing (rozszerzanie wzorców *, ?), a następnie dzieli wynik na argumenty na podstawie separatorów (domyślnie białe znaki). To właśnie tzw. word splitting. Bez cudzysłowów łatwo uzyskać zupełnie inne argumenty niż zakładane:

FILE="nazwa z spacjami.txt"

# Źle:
cp $FILE /backup/

# Dobrze:
cp "$FILE" /backup/

W pierwszym przypadku plik zostanie potraktowany jako dwa osobne argumenty. Dodanie set -u czy pipefail nie rozwiąże tego problemu – ochroną jest jedynie konsekwentne używanie cudzysłowów.

Zasada domyślna: wszystko w cudzysłowach, wyjątki świadome

W praktyce bezpiecznym nawykiem jest obejmowanie cudzysłowami każdej zmiennej użytej jako argument polecenia, z dwoma głównymi wyjątkami:

Świadome wyjątki od „wszędzie cudzysłowy”

Są sytuacje, w których brak cudzysłowów jest zamierzony i potrzebny. Zwykle chodzi o świadome dopuszczenie splitowania lub globbingu. Kilka typowych przypadków:

  • Kontrolowane użycie read:
    IFS=':' read -r user home shell <<<"$line"
    

    Tu wręcz korzysta się z podziału na pola według IFS, więc cudzysłowy wokół $line są prawidłowe, a brak cudzysłowów celowo pozostawia się po prawej stronie, przy zmiennych, które mają przyjąć wyniki splitowania.

  • Dynamiczne listy argumentów, np. z tablic:
    args=(--verbose --flag value)
    some_cmd "${args[@]}"   # poprawnie – każdy element osobnym argumentem
    some_cmd ${args[@]}     # zły pomysł – podlega word splitting i globbing
    

    Wyjątek polega tu na użyciu specjalnej formy "${tablica[@]}", która zachowuje granice elementów.

  • Świadome globbing:
    for file in ./*.log; do
        process_log "$file"
    done
    

    Wzorzec ./*.log celowo nie jest w cudzysłowach, bo ma zostać rozwinięty do listy plików. Dopiero potem każdy plik obsługiwany jest już w cudzysłowach.

Wszystkie inne przypadki, w których zmienna „ląduje” w komendzie bez cudzysłowów, powinny budzić podejrzenie i być bardzo dokładnie uzasadnione.

Niebezpieczne rozszerzenia: globbing i pathname expansion

Rozszerzanie wzorców ścieżek (globbing) potrafi wprowadzać błędy, które ujawniają się dopiero na konkretnym zestawie danych. Przykładowy klasyk:

rm -f $DIR/*

Wystarczy, że $DIR jest pustą zmienną (a set -u nie jest użyte), żeby komenda zadziałała jak:

rm -f /*

co może mieć oczywiste, drastyczne skutki. Oprócz konsekwentnych cudzysłowów warto ograniczać globbing do kontrolowanych miejsc oraz rozważyć czasowe wyłączenie go:

set -f          # wyłączenie globbingu
# ... kod, który nie powinien podlegać globbingowi ...
set +f          # ponowne włączenie

Taki zabieg jest przydatny, gdy operuje się na danych dostarczonych z zewnątrz (np. nazwy plików z API, wejścia użytkownika), które mogą zawierać znaki *, ? czy nawiasy kwadratowe – w przeciwnym razie powłoka potraktuje je jako wzorce.

Rozwinięcia poleceń – kiedy backticki szkodzą

Do pobierania wyniku innego polecenia służą rozwinięcia poleceń. Stara forma z backtickami:

value=`some_cmd "$arg"`

jest źródłem wielu problemów: trudniej ją zagnieżdżać, ciężej czytać, a błędy w quoting pojawiają się niemal odruchowo. Bezpieczniejsza i czytelniejsza jest forma $(...):

value=$(some_cmd "$arg")

Przy takiej konstrukcji łatwiej postawić cudzysłowy we właściwym miejscu:

# wynik traktowany jako jeden argument
some_other_cmd "$(some_cmd "$arg")"

# wynik rozbity na argumenty (tylko jeśli naprawdę o to chodzi)
some_other_cmd $(some_cmd "$arg")

W drugim wariancie wynik podlega word splitting i globbingowi. To czasem bywa użyteczne, ale w skryptach nastawionych na przewidywalność pierwszeństwo powinny mieć argumenty w całości zamknięte w cudzysłowach.

Tablice jako remedium na niekontrolowane splitowanie

Bash zapewnia tablice, które pozwalają przechowywać listy argumentów bez utraty informacji o granicach elementów. W środowiskach piszących skrypty na co dzień, korzystanie z tablic zamiast „spacji w zmiennej” jest standardem:

files=()
while IFS= read -r line; do
    files+=("$line")
done < <(find "$dir" -type f -name '*.log')

# Później:
printf '%sn' "${files[@]}"

Zastosowanie "${files[@]}" gwarantuje, że każdy element zostanie przekazany jako osobny argument, niezależnie od spacji czy znaków specjalnych w nazwie. Uproszczona konstrukcja ${files[*]} działa inaczej i z reguły nie jest pożądana przy wywoływaniu komend.

Bezpieczne łączenie ścieżek i nazw – unikanie „ręcznego” konkatenowania

Częstym źródłem pomyłek są ręczne konkatenacje ścieżek:

dest=$DIR/$FILENAME

Jeżeli którakolwiek ze zmiennych jest pusta lub zawiera ukośniki na początku/końcu, łatwo o niezamierzone efekty. Bardziej bezpieczne wzorce:

  • stosowanie oddzielnych argumentów:
    cp -- "$DIR/$FILENAME" "$TARGET_DIR/"
    
  • lub w bardziej złożonych przypadkach – narzędzi takich jak printf '%s/%sn':
    printf -v dest '%s/%s' "$DIR" "$FILENAME"
    

printf -v pozwala zbudować wartość zmiennej bezpośrednio, z pełną kontrolą nad formatem, unikając przypadkowego splitowania.

Zbliżenie ekranu z kodem PHP ilustrującym programowanie i debugowanie
Źródło: Pexels | Autor: Pixabay

Obsługa błędów krok po kroku – zamiast „set -e i nadzieja”

Dlaczego samo set -e nie wystarcza

set -e wygląda kusząco: „skrypt przerwie się przy pierwszym błędzie”. W praktyce jego zachowanie jest pełne wyjątków. Komenda zakończona niezerowym kodem nie przerwie skryptu m.in. wtedy, gdy:

  • jest częścią warunku if, while, until;
  • pojawia się w lewym argumencie operatorów logicznych &&, ||;
  • jest elementem listy w klauzulach takich jak ! cmd (negacja).

Przykład pozornie poprawny:

set -e

if some_check; then
    do_something
fi

echo "Dalsze kroki..."

Jeżeli some_check zwróci niezerowy kod jako „fałsz logiczny”, skrypt nie zostanie przerwany, mimo iż funkcja mogła zakończyć się „prawdziwym” błędem. Rozstrzygnięcie, czy dany kod wyjścia jest „normalny”, czy powinien zatrzymać skrypt, wymaga dodatkowej logiki. Opcja -e tego nie rozróżnia.

Wzorzec explicite: sprawdzanie kodów wyjścia

Bezpieczniejsze jest jawne sprawdzanie kodu wyjścia tam, gdzie ma to znaczenie, zamiast liczyć na „magiczne” zatrzymanie. Najprostsza forma:

if ! some_cmd; then
    echo "some_cmd nie powiodło się" >&2
    exit 1
fi

Przy większej liczbie komend pomocna bywa funkcja pomocnicza:

die() {
    echo "Błąd: $*" >&2
    exit 1
}

run_or_die() {
    "$@" || die "Komenda zakończyła się błędem: $*"
}

run_or_die cp "$src" "$dst"
run_or_die rsync -a "$src_dir/" "$dst_dir/"

Taki wzorzec uczy, że każda krytyczna komenda jest otoczona kontrolą, a jej skutki są jasne. Jest to bardziej przewidywalne niż liczenie na domyślną semantykę set -e.

Grupowanie operacji w funkcjach i sprawdzanie na granicach

Zamiast sprawdzać każdy pojedynczy krok, wygodnie jest grupować powiązane operacje w funkcjach. Błąd wewnątrz funkcji kończy ją niepowodzeniem, a skrypt sprawdza status na „wejściu” i „wyjściu” większych etapów:

prepare_backup() {
    mkdir -p "$backup_dir"
    cp -a "$src_dir" "$backup_dir"/ || return 1
    touch "$backup_dir/.ready" || return 1
}

perform_backup() {
    prepare_backup || return 1
    tar -czf "$archive" -C "$backup_dir" . || return 1
}

main() {
    if ! perform_backup; then
        log_error "Backup nie powiódł się"
        return 1
    fi
    log_info "Backup zakończony pomyślnie"
}

Funkcje zwracające status wykorzystują return i unikają exit, który kończy cały skrypt. Dzięki temu można budować hierarchię etapów i raportować błąd dopiero tam, gdzie ma to sens (np. w main).

Errory „oczekiwane” a „nieoczekiwane” – rozdzielenie ścieżek

W wielu skryptach istnieją błędy, które są częścią normalnego przepływu (np. „plik nie istnieje” przy próbie jego odczytu testowego) oraz błędy rzeczywiste. Łączenie ich w jedną kategorię utrudnia czytelność i diagnostykę. Dobrą praktyką jest rozdzielenie tych ścieżek:

if [[ ! -f "$config" ]]; then
    echo "Brak pliku konfiguracyjnego: $config" >&2
    exit 2   # „oczekiwany” brak – osobny kod wyjścia
fi

if ! load_config "$config"; then
    echo "Nie udało się wczytać konfiguracji" >&2
    exit 1   # błąd wykonania
fi

Różne kody wyjścia pozwalają otoczeniu (inne skrypty, system systemd, narzędzia monitorujące) zareagować adekwatnie. Zwykle opłaca się zdefiniować kilka stałych symbolicznych:

EXIT_OK=0
EXIT_USAGE=2
EXIT_RUNTIME=1
EXIT_TEMPFAIL=75   # np. chwilowy błąd zewnętrznej usługi

exit "$EXIT_RUNTIME"

Integracja trap ERR z jawnych sprawdzaniem błędów

trap ERR dobrze współgra z jawną obsługą błędów, ale nie zastępuje jej. Przykładowo, pułapkę można wykorzystać do logowania szczegółów, natomiast logikę „co robić dalej” pozostawić funkcjom:

on_error() {
    local status=$?
    local line=${BASH_LINENO[0]}
    echo "[ERROR] Kod: $status, linia: $line, komenda: ${BASH_COMMAND}" >&2
}

trap on_error ERR

critical_step() {
    do_risky_thing || return 1
    do_another_risky_thing || return 1
}

main() {
    if ! critical_step; then
        echo "Etap krytyczny nie powiódł się" >&2
        return 1
    fi
}

W takim układzie nie trzeba w każdym miejscu dopisywać szczegółowego logowania – robi to pułapka – ale decyzja, czy błąd ma zakończyć cały proces, zapada w main albo na poziomie wywołującym.

Kontrolowane użycie operatorów logicznych && i ||

Operatorzy && i || są wygodni, lecz nadmiernie stosowani rozmywają obsługę błędów. Typowa, nieczytelna konstrukcja:

do_step1 && do_step2 && do_step3 || echo "Coś poszło źle"

Tutaj błąd któregokolwiek kroku spowoduje wykonanie echo, ale sekwencja szybko staje się nieintuicyjna, a set -e może zareagować różnie w zależności od sytuacji. Bezpieczniej wyrazić to bardziej wprost:

if do_step1 && do_step2 && do_step3; then
    echo "Wszystko OK"
else
    echo "Krok się nie powiódł" >&2
    exit 1
fi

Taka struktura jest bardziej rozwlekła, ale ułatwia późniejsze modyfikacje (np. dodanie logowania szczegółowego dla różnych etapów) i współgra z set -e.

Retry i backoff – radzenie sobie z błędami zewnętrznych usług

W praktyce wiele błędów nie wynika z samego skryptu, lecz z zewnętrznych zależności (API, bazy danych, sieć). Zamiast przerywać działanie przy pojedynczej awarii, rozsądne jest dodanie mechanizmu powtórzeń:

retry() {
    local attempts=$1
    shift
    local count=0
    until "$@"; do
        count=$((count + 1))
        if (( count >= attempts )); then
            return 1
        fi
        sleep 1
    done
}

if ! retry 3 curl -fsS "$url" -o "$dest"; then
    echo "Nie udało się pobrać $url po 3 próbach" >&2
    exit 1
fi

Prosty mechanizm retry ogranicza wpływ krótkotrwałych problemów i czyni skrypt bardziej odpornym, bez wprowadzania skomplikowanej logiki wyjątków.

Najważniejsze wnioski

  • Bash co do zasady działa bardzo „liberalnie”: skrypt zwykle idzie dalej mimo błędów pośrednich, więc bez świadomej obsługi błędów drobna literówka lub brak pliku może przejść niezauważony i dopiero później dać trudne do zdiagnozowania skutki.
  • Ten sam skrypt może zachowywać się inaczej w terminalu, z crona, przez systemd czy w CI/CD – różni się m.in. $PATH, zestaw zmiennych środowiskowych i interaktywność, dlatego trzeba jawnie ustawiać środowisko (shebang, PATH, zmienne) zamiast polegać na konfiguracji użytkownika.
  • Brak konsekwentnej obsługi kodów wyjścia (statusów 0/≠0) prowadzi często do cichych, częściowych operacji: kopia zrobiona tylko częściowo, archiwizacja bez części plików, deployment zatrzymany w połowie – system „działa”, ale w stanie pośrednim.
  • Bezpieczne skrypty Bash opierają się na defensywnym podejściu: użyciu opcji set (np. do reagowania na błędy i nieużyte zmienne), pułapek trap, walidacji danych wejściowych oraz czytelnym logowaniu błędów na stderr.
  • Krytyczne jest konsekwentne cytowanie zmiennych (np. "$DIR"): brak cudzysłowów połączony z pustą zmienną może w praktyce zamienić rm -rf "$DIR" w operację usuwania bieżącego katalogu lub szerszego zakresu danych.