На этой неделе в топ Хабра попала история о том, как 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 $? 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 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или хотя бы лог удаляемых путей.
Код удаления выглядит скучно, поэтому на ревью его пролистывают. Но это одно из немногих мест, где ошибка в одной строке стоит пользователю всех его данных. Лишние десять строк проверок обходятся куда дешевле.
opium
У safe_rmtree остаётся гонка на промежуточных каталогах, даже при shutil.rmtree.avoids_symlink_attacks=True. Проверил на Linux с Python 3.15: после проверок обёртки, непосредственно перед rmtree(real), отдельный процесс переименовал allowed/parent и заменил его симлинком на foreign — в результате удалился foreign/cache/important.txt, а исходный allowed/parent/cache остался в переименованном каталоге. Защита rmtree от симлинков не закрепляет предков переданного абсолютного пути. Если другой процесс может менять эти каталоги, для такой обёртки нужно либо исключить эту возможность правами доступа, либо открывать путь от доверенного дескриптора по компонентам с запретом симлинков и передавать rmtree имя последнего компонента с dir_fd открытого родителя.
nikita-practice Автор
Спасибо, точное замечание. Обёртка в статье защищает от ошибок в самом скрипте: пустой переменной, не того пути, симлинка внутри дерева. От другого процесса, который в этот момент может менять родительские каталоги, она не защищает, и
avoids_symlink_attacksтут действительно не помогает: он касается только обхода внутри удаляемого дерева, а не предков пути. Если такой процесс возможен, правильнее идти от доверенного дескриптора: открывать компоненты пути по одному через os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent_fd) и вызывать shutil.rmtree(last_name, dir_fd=parent_fd) (параметрdir_fdесть с Python 3.11). Либо, как вы пишете, закрыть права на запись в эти каталоги. Добавлю это оговоркой в статью.