PunchAbducted Plateau varsity student freed, hospitalisedBollywood HungamaRamayana: Bombay High Court grants anti-piracy protection to Ranbir Kapoor starrer ahead of November 5 releaseInquirer16 areas to experience dangerous heat levels Tuesday — PagasaThe Jerusalem PostShin Bet expands election interference probe to suspected Palestinian Authority funding - reportZDF heuteAktuelle Pressemitteilungen des ZDFCNN TürkBaşkan Topak, Avrupa Konseyi'nde Türkiye ve Ergene'yi anlattıThe South AfricanAmericans vote in midterms set to upend Donald Trump presidencyEuronewsRhodes floods: Cars swept away after torrential rain in Greece01net1 400 km d’autonomie et propulsion : la future Renault Mégane prépare une vraie révolution contre TeslaCNN بالعربيةهجوم سيبراني غير مألوف.. قراصنة يعبثون بثلاجات المتاجر بأوروبا ويتلفون الطعامInteriaNauczyciele powinni sprawdzać plecaki uczniów? Polacy zabrali głosABC NewsMidterm elections live updates: Voters heading to polls today for midterms
The Daily Newsstand · Free, Always
Tuesday, November 3, 2026

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

Translate

На этой неделе в топ Хабра попала история о том, как 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 или хотя бы лог удаляемых путей.

Код удаления выглядит скучно, поэтому на ревью его пролистывают. Но это одно из немногих мест, где ошибка в одной строке стоит пользователю всех его данных. Лишние десять строк проверок обходятся куда дешевле.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.