Как не стать героем истории «программа удалила мне 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 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/