На этой неделе в топ Хабра попала история о том, как 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 bashset -euo pipefail
Здесь нас больше всего интересует -u: обращение к неустановленной переменной становится ошибкой, и скрипт падает. Важная оговорка: -u не спасает от переменной, которая установлена, но пуста (CACHE_DIR=""). Поэтому одного set -u мало.
${VAR:?} прямо в пути
rm -rf -- "${CACHE_DIR:?CACHE_DIR не задан}/"*
Конструкция ${VAR:?сообщение} завершает скрипт с ошибкой, если переменная не задана или пуста. Двоеточие тут принципиально: ${VAR?} без него пропустит пустую строку.
Проверяю:
$ bash t.sht.sh: line 3: CACHE_DIR: CACHE_DIR не задан$ echo $?1
rm даже не запускается.
— и кавычки
Двойные кавычки защищают от разбиения пути с пробелами на несколько аргументов (привет, 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 bashset -euo pipefail: "${CACHE_DIR:?CACHE_DIR не задан}"# Каталог должен существовать и быть именно каталогом, а не симлинкомif [[ ! -d "$CACHE_DIR" || -L "$CACHE_DIR" ]]; then echo "Странный CACHE_DIR: $CACHE_DIR" >&2 exit 1fireal=$(realpath -- "$CACHE_DIR")case "$real" in /|/home|/home/*/|"$HOME"|/usr*|/etc*|/var) echo "Отказываюсь чистить $real" >&2 exit 1 ;;esacfind "$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 shutilimport tempfilefrom pathlib import Pathclass UnsafeDeletion(Exception): passFORBIDDEN = {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/linkFalse True
Кеш удалён, important.txt на месте: симлинк внутри дерева rmtree снёс как ссылку и в чужую папку не полез.
onerror, onexc и read-only файлы на Windows
На Windows rmtree часто спотыкается о файлы с атрибутом «только чтение» — например, внутри .git. Обычное решение — обработчик ошибок, который снимает атрибут и повторяет операцию:
import os, statdef _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или хотя бы лог удаляемых путей.
Код удаления выглядит скучно, поэтому на ревью его пролистывают. Но это одно из немногих мест, где ошибка в одной строке стоит пользователю всех его данных. Лишние десять строк проверок обходятся куда дешевле.
ссылка на оригинал статьи https://habr.com/ru/articles/1102984/