Как не стать героем истории «программа удалила мне 800 ГБ»: безопасное рекурсивное удаление в Bash и Python

На этой неделе в топ Хабра попала история о том, как Telegram Desktop при запуске рекурсивно чистил чужую папку C:\custom и унёс около 800 ГБ проектов и программ. Комментарии предсказуемо разделились на «как такое вообще можно выпустить» и «у нас в проекте есть такой же скрипт, просто ему пока везло».
Я отношусь ко второй группе. Почти в любом проекте есть код, который что-то удаляет рекурсивно: очистка кеша, временных файлов, артефактов сборки, старых бэкапов. Пишется он за пять минут, ревьюится по диагонали, и годами работает правильно — до первой пустой переменной или неожиданного симлинка.
Ниже — разбор типичных способов выстрелить себе в ногу и рабочий код на Bash и Python, который эти способы закрывает. Весь код я прогнал на Python 3.13 и GNU coreutils 9.7.
Классика жанра: пустая переменная
Самый известный пример — баг в Steam для Linux в начале 2015 года. В скрипте запуска была строка примерно такого вида:
rm -rf "$STEAMROOT/"*Если STEAMROOT по какой-то причине оказывалась пустой (у пострадавшего пользователя её «сломал» перенос папки Steam), строка превращалась в rm -rf "/"*. Дальше — всё, на что у пользователя были права, включая примонтированный диск с бэкапами.
Ещё раньше, в 2011-м, был Bumblebee — проект для гибридной графики NVIDIA на ноутбуках. В установочном скрипте затесался лишний пробел:
rm -rf /usr /lib/nvidia-current/xorg/xorgВместо одного пути получилось два, и первым из них был /usr.
Обратите внимание: ни в одном из случаев не было «сложного» кода. Одна строка, понятная любому джуну.

Bash: три строчки, которые стоит писать всегда
set -euo pipefail
#!/usr/bin/env bash
set -euo pipefailЗдесь нас больше всего интересует -u: обращение к неустановленной переменной становится ошибкой, и скрипт падает. Важная оговорка: -u не спасает от переменной, которая установлена, но пуста (CACHE_DIR=""). Поэтому одного set -u мало.
${VAR:?} прямо в пути
rm -rf -- "${CACHE_DIR:?CACHE_DIR не задан}/"*Конструкция ${VAR:?сообщение} завершает скрипт с ошибкой, если переменная не задана или пуста. Двоеточие тут принципиально: ${VAR?} без него пропустит пустую строку.
Проверяю:
$ bash t.sh
t.sh: line 3: CACHE_DIR: CACHE_DIR не задан
$ echo $?
1rm даже не запускается.
-- и кавычки
Двойные кавычки защищают от разбиения пути с пробелами на несколько аргументов (привет, Bumblebee). А -- говорит rm, что дальше идут только пути, а не опции: файл с именем -rf в папке перестаёт быть проблемой.
А как же --preserve-root?
В GNU rm защита --preserve-root включена по умолчанию, и rm -rf / откажется работать. Но она проверяет только буквальный корень. rm -rf /* — это уже не корень, а список всех каталогов в нём, которые раскрыла оболочка, и --preserve-root тут не поможет. Ровно так и выглядел случай со Steam.
Отдельно есть --one-file-system — он не даёт rm уходить в другие смонтированные файловые системы. Для скриптов очистки, которые никогда не должны трогать примонтированные диски, это хорошая страховка. И помните, что на macOS и в BusyBox rm не GNU, и набор флагов отличается.
Итоговый шаблон
#!/usr/bin/env bash
set -euo pipefail
: "${CACHE_DIR:?CACHE_DIR не задан}"
# Каталог должен существовать и быть именно каталогом, а не симлинком
if [[ ! -d "$CACHE_DIR" || -L "$CACHE_DIR" ]]; then
echo "Странный CACHE_DIR: $CACHE_DIR" >&2
exit 1
fi
real=$(realpath -- "$CACHE_DIR")
case "$real" in
/|/home|/home/*/|"$HOME"|/usr*|/etc*|/var)
echo "Отказываюсь чистить $real" >&2
exit 1
;;
esac
find "$real" -mindepth 1 -maxdepth 1 -exec rm -rf --one-file-system -- {} +find -mindepth 1 удаляет содержимое каталога, включая скрытые файлы, которые * в Bash по умолчанию не захватывает, — и при этом не трогает сам каталог.
Python: shutil.rmtree не думает за вас
В Python то же самое пишется одной строкой:
shutil.rmtree(path)И у неё ровно те же проблемы. rmtree удалит то, что ей передали, — даже если это домашний каталог, потому что конфиг прочитался не так.
Отдельная тема — симлинки. Сам rmtree на симлинк ругается (OSError: Cannot call rmtree on a symbolic link), а симлинки внутри дерева удаляет как ссылки, не заходя в них. Но есть гонка: между проверкой «это обычный каталог» и удалением его могут подменить на симлинк. На платформах, где есть функции семейства openat/unlinkat, rmtree работает через файловые дескрипторы и защищён от этой атаки. Проверить это можно атрибутом shutil.rmtree.avoids_symlink_attacks (появился в Python 3.3). На Linux он True, на других системах может быть False.

Обёртка, которая отказывается удалять лишнее
Идея простая: удаление разрешено только внутри заранее известного корня — каталога приложения, кеша, временной папки. Всё остальное — исключение.
import shutil
import tempfile
from pathlib import Path
class UnsafeDeletion(Exception):
pass
FORBIDDEN = {Path("/"), Path.home().resolve()}
def safe_rmtree(target, allowed_root, dry_run=False):
if not target or not str(target).strip():
raise UnsafeDeletion("пустой путь")
root = Path(allowed_root).resolve(strict=True)
path = Path(target)
if path.is_symlink():
raise UnsafeDeletion(f"{path} — симлинк, удалять содержимое не будем")
real = path.resolve(strict=True)
if real in FORBIDDEN or real == root:
raise UnsafeDeletion(f"{real} — запрещённый путь")
if not real.is_relative_to(root):
raise UnsafeDeletion(f"{real} вне {root}")
if dry_run:
for p in sorted(real.rglob("*")):
print("удалил бы:", p)
return
if not shutil.rmtree.avoids_symlink_attacks:
raise UnsafeDeletion("платформа не защищает rmtree от симлинк-атак")
shutil.rmtree(real)Что здесь важно:
Пустая строка проверяется до
Path().Path("")превращается вPath("."), то есть в текущий каталог. Это питоновский аналог пустого$STEAMROOT, и он куда менее заметен.resolve(strict=True)раскрывает..и симлинки и падает, если пути нет. Сравнивать нужно уже настоящие пути: строкаcache/../../формально начинается сcache.is_relative_to(Python 3.9+) проверяет вложенность по компонентам пути. Проверка черезstr(real).startswith(str(root))ошибочна:/srv/app-old«начинается» с/srv/app.Сам корень удалить нельзя — только то, что внутри него.
dry_run— дешёвый способ увидеть, что именно уйдёт, до того как оно уйдёт.
Проверяем на живой файловой системе
Создаём во временном каталоге cache с файлом внутри, а рядом — «чужую» папку с important.txt и симлинк на неё из кеша:
base = Path(tempfile.mkdtemp())
cache = base / "cache"
(cache / "a").mkdir(parents=True)
(cache / "a" / "f.txt").write_text("x")
victim = Path(tempfile.mkdtemp())
(victim / "important.txt").write_text("!")
(cache / "link").symlink_to(victim)
for t in ["", "/", str(base), str(base / "cache" / ".." / ".."), str(cache / "link")]:
try:
safe_rmtree(t, base)
except UnsafeDeletion as e:
print("заблокировано:", e)
safe_rmtree(cache, base, dry_run=True)
safe_rmtree(cache, base)
print(cache.exists(), (victim / "important.txt").exists())Вывод:
заблокировано: пустой путь
заблокировано: / — запрещённый путь
заблокировано: /tmp/tmps_zcsnez — запрещённый путь
заблокировано: /tmp вне /tmp/tmps_zcsnez
заблокировано: /tmp/tmps_zcsnez/cache/link — симлинк, удалять содержимое не будем
удалил бы: /tmp/tmps_zcsnez/cache/a
удалил бы: /tmp/tmps_zcsnez/cache/a/f.txt
удалил бы: /tmp/tmps_zcsnez/cache/link
False TrueКеш удалён, important.txt на месте: симлинк внутри дерева rmtree снёс как ссылку и в чужую папку не полез.
onerror, onexc и read-only файлы на Windows
На Windows rmtree часто спотыкается о файлы с атрибутом «только чтение» — например, внутри .git. Обычное решение — обработчик ошибок, который снимает атрибут и повторяет операцию:
import os, stat
def _force(func, path, exc):
os.chmod(path, stat.S_IWRITE)
func(path)
shutil.rmtree(real, onexc=_force)Параметр onexc появился в Python 3.12, а старый onerror в той же версии объявлен устаревшим. Если нужно поддерживать 3.11 и ниже, придётся использовать onerror — у него третий аргумент не исключение, а кортеж из sys.exc_info().
И главное: не используйте ignore_errors=True «чтобы не падало». Вы просто перестанете узнавать, что удаление пошло не так.
Что ещё помогает на практике

Удаляйте только то, что создали сами. Лучший вариант — tempfile.TemporaryDirectory() или mktemp -d: путь гарантированно новый, и удаляете вы ровно его. Если приложение кладёт файлы в общий каталог, пусть оставляет там маркер (например, пустой .myapp-cache) и проверяет его перед очисткой.
Не берите путь для удаления напрямую из конфига или пользовательского ввода. Конфиг — это строка, которую когда-нибудь кто-нибудь заполнит неправильно. Из него можно брать имя подкаталога, но корень, внутри которого разрешено удаление, лучше зашить в код.
Сначала переносите, потом удаляйте. Перемещение в «корзину» вида trash/2026-11-03/ с отложенной очисткой даёт время заметить ошибку. Для кешей это почти бесплатно.
Логируйте, что удаляете. Хотя бы корневой путь и количество файлов. Когда пользователь придёт с вопросом «куда делись мои данные», у вас будет ответ.
Тестируйте на временной ФС. Тест выше занимает пару десятков строк и ловит пустые пути, .. и симлинки. В pytest для этого есть готовая фикстура tmp_path.
Чек-лист для ревью
В Bash:
set -euo pipefail,${VAR:?}в каждом пути дляrm, кавычки,--.Нет конструкций
"$DIR/"*без проверкиDIR.В Python пустая строка отсекается до создания
Path.Пути сравниваются после
resolve(), а вложенность — черезis_relative_to, а неstartswith.Удаление разрешено только внутри известного корня, и сам корень удалить нельзя.
Нет
ignore_errors=Trueбез очень веской причины.Есть
dry_runили хотя бы лог удаляемых путей.
Код удаления выглядит скучно, поэтому на ревью его пролистывают. Но это одно из немногих мест, где ошибка в одной строке стоит пользователю всех его данных. Лишние десять строк проверок обходятся куда дешевле.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.