Przejdź do treści
Lekcja 49 z 117

Kurs › Moduł 5 › Lekcja 49

Spacje w nazwach plików

⏱ ok. 12 min czytania· moduł 5: Skrypty, które się nie sypią

Po tej lekcji: kursant pisze skrypty i pętle, które nie wykładają się na plikach ze spacją, myślnikiem na początku ani nawet znakiem nowej linii w nazwie, i wie, kiedy użyć cudzysłowu, IFS, find -print0 i nullglob.

Wstęp

Skrypt backupu działał rok. Codziennie o drugiej w nocy, zero błędów, admin spał spokojnie. Aż księgowa założyła folder Faktury 2026. Ze spacją. Bo czemu nie, przecież w Windowsie wszyscy tak robią.

Skrypt zobaczył spację i uznał, że to dwa foldery: Faktury i 2026. Żadnego nie znalazł, wypisał błąd do logu, którego nikt nie czytał, i poszedł dalej. Przez trzy miesiące.

Spacja w nazwie pliku to dla Basha jak „Jan Maria Kowalski” w formularzu z jednym polem na imię. Dziś nauczysz się pisać skrypty, które nie robią z Jana Marii trzech różnych ludzi.

Jak Bash tnie słowa

Przypomnij sobie urzędnika z okienka. Dostaje od ciebie kartkę z poleceniem i zanim cokolwiek zrobi, tnie ją nożyczkami na słowa. Tnie w każdym miejscu, gdzie jest spacja, tabulator albo nowa linia. Pierwsze słowo to komenda, reszta to argumenty.

To cięcie nazywa się dzielenie na słowa, po angielsku word splitting. I tu jest haczyk: urzędnik tnie nie tylko to, co wpisałeś, ale też to, co wyszło ze zmiennej albo z $( ), jeśli nie jest w cudzysłowie.

Mała pokazówka. Folder z dwoma plikami:

touch "raport 2026.txt" notatki.txt
ls
notatki.txt
raport 2026.txt

Klasyk z internetu, pętla po wyniku ls:

for f in $(ls); do echo "[$f]"; done
[notatki.txt]
[raport]
[2026.txt]

Nawiasy kwadratowe dodałem, żeby było widać, gdzie każde „słowo” się zaczyna i kończy. Dwa pliki, trzy kawałki. raport 2026.txt został pocięty na pół, bo wynik $(ls) to po prostu tekst, a tekst urzędnik tnie.

A teraz to samo, tylko gwiazdką:

for f in *; do echo "[$f]"; done
[notatki.txt]
[raport 2026.txt]

Idealnie. Gwiazdka nie produkuje tekstu do pocięcia, tylko gotową listę nazw. Każda nazwa to osobna, nienaruszalna pozycja. Zapamiętaj: po plikach chodzisz gwiazdką, nigdy po wyniku ls. ls jest dla ludzi, nie dla skryptów.

Cudzysłów, czyli koperta

Kiedy to cięcie robi prawdziwą krzywdę? Kiedy zmienna z nazwą pliku trafia do komendy bez cudzysłowu.

dwa pliki: stare ważne.txt i ważne.txt

Mamy dwa pliki: stare ważne.txt, który chcemy usunąć, i ważne.txt, którego za nic nie chcemy stracić.

f="stare ważne.txt"
rm $f
ls -1
rm: cannot remove 'stare': No such file or directory
stare ważne.txt

Przeczytaj to jeszcze raz. Plik, który miał zniknąć, dalej jest. A ważne.txt zniknął. Urzędnik pociął stare ważne.txt na stare i ważne.txt, dostał dwa polecenia usunięcia, pierwszego pliku nie znalazł, a drugi usunął bez mrugnięcia okiem. Błąd na ekranie dotyczy tego, co się NIE stało. O tym, co się stało, nikt cię nie poinformował.

Lekarstwo znasz z lekcji o zmiennych: cudzysłów, czyli koperta. Wszystko, co jest w kopercie, urzędnik bierze w całości, bez nożyczek.

rm "$f"

ShellCheck łapie to od ręki:

In zly.sh line 3:
rm $f
   ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
rm "$f"

Zwróć uwagę na słowo „globbing”. Zmienna bez cudzysłowu ma dwa problemy, nie jeden. Najpierw jest cięta na słowa, a potem każde słowo jest jeszcze sprawdzane, czy nie jest wzorcem z gwiazdką. Jeśli ktoś nazwie plik *, a ty zrobisz rm $f… Tak, Linux pozwala nazwać plik gwiazdką. Linux pozwala na bardzo dużo rzeczy.

Zasada na całe życie: każde $zmienna, $(komenda), $1 i ${tablica[@]} w cudzysłowie, chyba że masz konkretny powód. Dla tablic forma "${pliki[@]}" daje każdy element osobno i nienaruszony, a to dokładnie to, czego chcesz. To samo, co "$@" z lekcji o argumentach.

IFS, czyli którymi nożyczkami

Gdzie Bash trzyma informację, w których miejscach ciąć? W zmiennej o nazwie IFS, od Internal Field Separator, wewnętrzny separator pól. Zajrzyjmy:

printf '%q\n' "$IFS"
$' \t\n'

Spacja, tabulator, nowa linia. Trzy ostrza nożyczek. printf '%q' pokazuje niewidzialne znaki tak, żeby było je widać.

IFS przydaje się sam w sobie, najczęściej z read. Pamiętasz /etc/passwd? Pola rozdzielone dwukropkiem. Podmieniasz nożyczki tylko na czas jednej komendy:

IFS=: read -r user _ uid _ _ home shell <<< "root:x:0:0:root:/root:/bin/bash"
echo "user=$user uid=$uid home=$home shell=$shell"
user=root uid=0 home=/root shell=/bin/bash

_ to umowna nazwa na „to pole mnie nie obchodzi”. A IFS=: przed read działa tylko dla tego jednego read. Po nim IFS wraca do normy.

Druga sztuczka: IFS= pusty. Wtedy read nie obcina spacji na początku i końcu linii:

linia="  Jan Kowalski  "
read -r a <<< "$linia"; echo "[$a]"
IFS= read -r a <<< "$linia"; echo "[$a]"
[Jan Kowalski]
[  Jan Kowalski  ]

Dlatego od lekcji o pętlach piszę zawsze while IFS= read -r linia. IFS= zostawia spacje w spokoju, -r zostawia w spokoju backslashe. Czytasz linijkę dokładnie taką, jaka jest.

Popularny trik z internetu to IFS=$'\n' na górze skryptu, żeby ciąć tylko na nowej linii. Działa… połowicznie. Po pierwsze, psuje każdą inną pętlę w skrypcie, która liczy na cięcie po spacji: for n in $dane z dane="1 2 3" nagle daje jedno słowo 1 2 3. Po drugie, nie ratuje przed plikiem z nową linią w nazwie. Bo tak, to też jest dozwolone.

Najgorsze nazwy świata

folder zdjecia, a w nim pliki, które wyglądają jak sabotaż

Zróbmy folder testowy z nazwami, które spotkasz prędzej czy później. Zrób go u siebie, przyda się w zadaniu:

mkdir -p "zdjecia/wakacje 2026"
cd zdjecia
touch zwykle.jpg "wakacje 2026/plaża o zachodzie.jpg"
touch $'nowa\nlinia.jpg'
touch -- -dziwne.jpg
ls
-dziwne.jpg
'nowa'$'\n''linia.jpg'
'wakacje 2026'
zwykle.jpg

$'nowa\nlinia.jpg' to sposób Basha na wpisanie prawdziwego znaku nowej linii. ls w terminalu pokazuje taką nazwę w cudzysłowach, żebyś wiedział, że coś tu jest nie tak. A -- przy touch to znak „koniec opcji, dalej są już tylko nazwy”. Bez tego touch uznałby -dziwne.jpg za zestaw opcji i odmówił współpracy.

Myślnik na początku

Zanim przejdziemy do pętli, ta jedna jest zdradliwa. W folderze leżą a.txt, b.txt i plik o nazwie -l:

ls *
-rw-r--r-- 1 root root 2 Nov  3 22:09 a.txt
-rw-r--r-- 1 root root 0 Nov  3 22:09 b.txt

Gwiazdka rozwinęła się do -l a.txt b.txt, a ls przeczytał -l jako opcję „długi format”. Plik -l zniknął z listy, za to dostaliśmy szczegóły. Tu niewinnie. Teraz wyobraź sobie plik o nazwie -rf i komendę rm *. Nie wyobrażaj sobie zbyt długo.

Dwa lekarstwa: -- albo ./ przed gwiazdką.

ls -- *
ls ./*
-l
a.txt
b.txt
./-l
./a.txt
./b.txt

./* jest jeszcze lepsze, bo działa z każdym programem, także z takim, który nie zna --. Nazwa zaczyna się od kropki, a nie od myślnika, więc żaden program nie weźmie jej za opcję.

Bezpieczne pętle po plikach

Teraz zdjęcia, także w podfolderach. Pierwsze, co przychodzi do głowy:

for f in $(find . -name '*.jpg'); do echo "[$f]"; done
[./zwykle.jpg]
[./nowa]
[linia.jpg]
[./-dziwne.jpg]
[./wakacje]
[2026/plaża]
[o]
[zachodzie.jpg]

Cztery pliki, osiem kawałków. Plaża o zachodzie słońca rozpadła się na cztery części jak źle zapakowany talerz.

Poprawny sposób składa się z trzech części, które znasz osobno:

while IFS= read -r -d '' f; do
    printf '[%s]\n' "$f"
done < <(find . -name '*.jpg' -print0)
[./zwykle.jpg]
[./nowa
linia.jpg]
[./-dziwne.jpg]
[./wakacje 2026/plaża o zachodzie.jpg]

Cztery pliki, cztery wyniki, nawet ten z nową linią. Rozbierzmy to:

  • find -print0 kończy każdą nazwę bajtem zerowym zamiast nowej linii. Jedynym znakiem, którego w nazwie być nie może.
  • read -d '' czyta do bajtu zerowego, a nie do końca linii.
  • IFS= i -r zostawiają nazwę nietkniętą.
  • < <( ) to podstawianie procesu z lekcji 39. I to nie jest ozdobnik.

Bo zobacz, co się dzieje, kiedy ten sam find podasz rurą i chcesz policzyć pliki:

n=0
find . -name '*.jpg' -print0 | while IFS= read -r -d '' f; do n=$((n+1)); done
echo "znaleziono: $n"
znaleziono: 0

Zero. Pętla liczyła poprawnie, ale za rurą działa w osobnej podpowłoce. To jak liczenie paczek na taśmie w sąsiedniej hali: naliczyłeś cztery, wychodzisz, a tablica w twojej hali dalej pokazuje zero. Z < <(find …) pętla zostaje w głównej hali i licznik przeżywa.

Są jeszcze dwa bezpieczne sposoby, gdy nie potrzebujesz pętli:

find . -name '*.jpg' -exec ls -l {} +
find . -name '*.jpg' -print0 | xargs -0 ls -l
-rw-r--r-- 1 root root    0 Nov  3 22:09 ./-dziwne.jpg
-rw-r--r-- 1 root root    0 Nov  3 22:09 ./nowa
linia.jpg
-rw-r--r-- 1 root root  300 Nov  3 22:09 ./wakacje 2026/plaża o zachodzie.jpg
-rw-r--r-- 1 root root 1200 Nov  3 22:09 ./zwykle.jpg

A jeśli chcesz mieć wszystko w tablicy:

mapfile -d '' -t pliki < <(find . -name '*.jpg' -print0)
echo "${#pliki[@]}"
4

macOS: systemowy Bash 3.2 nie ma mapfile. Pętla z read -r -d '' działa wszędzie.

nullglob, czyli gdy nie ma czego szukać

Ostatnia pułapka. Folder, w którym nie ma żadnego .png:

for f in *.png; do echo "przetwarzam: [$f]"; done
przetwarzam: [*.png]

Kiedy wzorzec niczego nie znajdzie, Bash zostawia go… dosłownie. Skrypt próbuje przetworzyć plik o nazwie *.png. Jak kelner, który na pytanie „co jest na deser?” przynosi kartkę z napisem „deser”.

Włączasz opcję nullglob i pusty wzorzec znika:

shopt -s nullglob
for f in *.png; do echo "przetwarzam: [$f]"; done
pliki=(*.png)
echo "liczba png: ${#pliki[@]}"
liczba png: 0

Pętla nie wykonała się ani razu i dobrze. A tablica z gwiazdki to najprostszy sposób, żeby sprawdzić, czy jakieś pliki w ogóle są.

Ale uwaga, nullglob też ma ciemną stronę:

shopt -s nullglob
ls *.png
-dziwne.jpg
nowa
linia.jpg
wakacje 2026
zwykle.jpg

Wzorzec zniknął, ls dostał zero argumentów, więc wypisał cały folder. Z ls to śmieszne. Z rm -- *.bak… już mniej, bo rm bez argumentów tylko marudzi, ale inne programy bez argumentów potrafią wziąć „wszystko”. Dlatego nullglob włączaj świadomie, najlepiej tylko na czas pętli, a potem shopt -u nullglob.

Jest też failglob: pusty wzorzec to błąd i komenda się nie wykona.

glob.sh: line 16: no match: *.png

Jak to działa pod spodem

Bash rozbiera każdą linijkę w ustalonej kolejności. Najpierw rozwija nawiasy klamrowe, tyldę, zmienne, $( ) i arytmetykę. Potem tnie wyniki tych rozwinięć na słowa według IFS, ale tylko te, które nie są w cudzysłowie. Potem rozwija gwiazdki i znaki zapytania. Na końcu usuwa cudzysłowy i odpala program.

Z tej kolejności wynika wszystko, co dziś widziałeś. Tekst wpisany ręcznie jest cięty przy wczytywaniu linijki, ale wynik zmiennej i $( ) jest cięty jeszcze raz, chyba że siedzi w kopercie. Gwiazdka jest rozwijana po cięciu, więc nazwy z gwiazdki nie są już cięte. Dlatego for f in * jest bezpieczne, a for f in $(ls) nie.

I jeszcze jedno: sam program, rm czy ls, nigdy nie widzi ani cudzysłowów, ani gwiazdki. Dostaje od jądra gotową listę argumentów, każdy jako osobny napis zakończony bajtem zerowym. O wszystkim decyduje urzędnik przy okienku, zanim przekaże sprawę dalej.

Ściąga flag

KomendaFlagaCo robi
"$zmienna"—koperta: bez cięcia na słowa i bez rozwijania gwiazdek
"${tab[@]}"—każdy element tablicy osobno i w całości
IFS=: read -r a bIFS=nożyczki tylko dla tej jednej komendy
read-rnie rusza backslashy
read-d ''czyta do bajtu zerowego zamiast do nowej linii
find-print0nazwy rozdzielone bajtem zerowym
find-exec … {} +podaje wiele plików naraz, bezpiecznie
xargs-0czyta nazwy rozdzielone bajtem zerowym
mapfile-d '' -twczytuje nazwy z -print0 do tablicy
rm, ls, cp--koniec opcji, dalej tylko nazwy
shopt-s nullglobpusty wzorzec znika
shopt-s failglobpusty wzorzec to błąd
shopt-s dotglob* łapie też pliki z kropką
printf%qpokazuje niewidzialne znaki

⚠ Typowy błąd

Kopiowanie w pętli bez cudzysłowu:

for f in *.txt; do cp $f /backup/; done
cp: cannot stat 'raport': No such file or directory
cp: cannot stat '2026.txt': No such file or directory

Pętla jest dobra, bo idzie gwiazdką. Ale w środku $f bez koperty i urzędnik znowu bierze nożyczki. Komunikat cannot stat 'raport' z kawałkiem nazwy to zawsze ten sam objaw: gdzieś brakuje cudzysłowu.

Naprawa: cp -- "$f" /backup/. I od razu przepuść skrypt przez ShellCheck, bo jeśli brakuje cudzysłowu w jednym miejscu, to zwykle brakuje go w pięciu.

✎ Zadanie

Zrób u siebie folder zdjecia z najgorszymi nazwami świata, komendami z lekcji. Wrzuć do dwóch plików trochę danych, na przykład head -c 1200 /dev/urandom > zdjecia/zwykle.jpg. Potem napisz skrypt policz_jpg.sh, który dostaje folder jako argument (domyślnie bieżący), znajduje wszystkie pliki .jpg także w podfolderach, wypisuje każdy w nawiasach kwadratowych z rozmiarem w bajtach, a na końcu podaje liczbę plików i łączny rozmiar. Nie może się pomylić na żadnej nazwie. ShellCheck ma milczeć.

Pokaż rozwiązanie
#!/bin/bash
# policz_jpg.sh FOLDER — liczy pliki .jpg, odporny na dziwne nazwy
folder=${1:-.}
licznik=0
suma=0
while IFS= read -r -d '' plik; do
    rozmiar=$(stat -c %s -- "$plik")
    printf '[%s] %s B\n' "$plik" "$rozmiar"
    licznik=$((licznik + 1))
    suma=$((suma + rozmiar))
done < <(find "$folder" -type f -iname '*.jpg' -print0)
echo "Plików: $licznik, razem: $suma B"
[zdjecia/zwykle.jpg] 1200 B
[zdjecia/nowa
linia.jpg] 0 B
[zdjecia/-dziwne.jpg] 0 B
[zdjecia/wakacje 2026/plaża o zachodzie.jpg] 300 B
Plików: 4, razem: 1500 B

Cztery pliki, żaden nie pocięty, ShellCheck milczy. -print0 z read -d '' niesie nazwy, < <( ) ratuje liczniki przed podpowłoką, ${1:-.} daje bieżący folder, gdy nie podasz żadnego. -iname łapie też ZDJECIE.JPG z telefonu, który krzyczy wielkimi literami. stat -c %s podaje rozmiar w bajtach, a -- chroni przed -dziwne.jpg, chociaż tu ścieżka i tak zaczyna się od nazwy folderu.

macOS: stat jest z rodziny BSD i rozmiar daje stat -f %z.

Jeśli twoja wersja wypisała Plików: 0, to pewnie dałeś find … | while. Licznik został w sąsiedniej hali.

Co dalej

Twój skrypt już nie boi się spacji. Ale co zrobi, jeśli w połowie jedna komenda się wysypie? Domyślnie: nic, pojedzie dalej, jakby nigdy nic. W następnej lekcji włączymy tryb „zatrzymaj się przy błędzie”, czyli set -e, set -u i pipefail, i zobaczymy na żywo, gdzie każdy z nich potrafi cię oszukać.