JavaRush /Курсы /Go SELF /filepath.WalkDir: рек...

filepath.WalkDir: рекурсивный обход и безопасность

Go SELF
41 уровень , 2 лекция
Открыта

1. Когда нужен рекурсивный обход

Если вы только начинаете, то кажется логичным: «Ну я же уже умею os.ReadDir, значит сделаю цикл, а если там папка — ещё раз сделаю ReadDir…». И да, так можно. Ровно один раз. Потом ваш код начнёт обрастать проверками, глубиной, сбором путей, обработкой ошибок, а ещё вы случайно забудете return в одном месте — и получите очень творческую бесконечность.

Рекурсивный обход нужен в реальных задачах постоянно. Например, вы хотите найти все файлы с расширением .log внутри ./logs, или посчитать, сколько «мусорных» временных файлов осталось после аварийного завершения программы, или собрать список файлов проекта (но аккуратно, не залезая в .git и vendor). Это всё один и тот же паттерн: «есть корень, есть дерево, нужно пройтись по всем узлам».

filepath.WalkDir как раз и нужен, чтобы не превращать ваш main.go в роман на 800 страниц с печальным финалом.

2. filepath.WalkDir: что делает и как устроен

filepath.WalkDir(root, fn) обходит дерево файловой системы, начиная с root, и вызывает вашу функцию fn для каждого найденного объекта: и для директорий, и для файлов, включая сам root. Сигнатура выглядит так: func WalkDir(root string, fn fs.WalkDirFunc) error.

Полезная гарантия: обход идёт в лексикографическом порядке (по именам). Это делает вывод детерминированным (воспроизводимым), но означает, что для каждой директории нужно прочитать её содержимое целиком перед тем, как углубляться дальше. В большинстве учебных и прикладных сценариев это поведение — прям подарок.

И очень важный момент про безопасность и предсказуемость: WalkDir не следует символическим ссылкам. Это снижает риск бесконечных циклов обхода (когда ссылка указывает «куда‑то вверх» или образует петлю), и часто делает поведение понятнее.

Колбэк: «вам звонят, когда нашли файл»

Когда вы впервые видите WalkDir(".", func(...) error { ... }), мозг иногда говорит: «Так… погодите… кто кого вызывает?». Это нормальная реакция. Здесь работает модель «инверсия управления»: не вы ходите по дереву и дергаете обработчик, а стандартная библиотека ходит по дереву и вызывает вашу функцию на каждом шаге.

Представьте, что вы наняли курьера обходить все квартиры в подъезде. Вы не ходите сами, вы только оставили ему инструкцию: «Когда найдёшь дверь — позвони, скажи номер и что там написано». WalkDir — это курьер, а fn — ваш телефонный разговор.

Схематично это можно представить так:

flowchart TD
    A["filepath.WalkDir(root, fn)"] --> B["находит путь path"]
    B --> C["вызывает fn(path, d, err)"]
    C --> D["fn возвращает nil/SkipDir/SkipAll/ошибку"]
    D --> E["WalkDir решает: продолжать, пропустить, остановиться"]

Главная мысль: ваша функция fn — это правила поведения на каждом найденном объекте.

Контракт fs.WalkDirFunc: path, d, err

Функция‑обработчик имеет тип fs.WalkDirFunc. В документации описано, что её вызывают для каждого файла/директории, и именно возвращаемое значение управляет тем, как продолжится обход.

Ключевая тройка параметров такая:

Параметр Что это Практический смысл
path string
текущий путь то, что вы обычно печатаете или анализируете
d fs.DirEntry
«лёгкое описание» объекта можно спросить d.IsDir(), d.Name() и т.д.
err error
ошибка, связанная с этим path сигнал: «по этому пути что-то пошло не так»

Самое важное правило на практике звучит скучно, но спасает нервы: в начале обработчика проверьте err. В документации прямо сказано, что err сообщает об ошибке доступа к path, и функция решает, что делать: вернуть ошибку (остановить обход) или продолжать другим способом.

И вот тут вспоминаем философию Go: «ошибки — это значения, с ними работают как с данными, а не как с катастрофой». В WalkDir это ощущается особенно явно: ошибка не обязательно означает «всё пропало», иногда это просто «эту папку не открыли».

3. Базовые примеры: печатаем и фильтруем

Самый маленький пример: печатаем всё, что нашли

Начинать лучше с кода, который делает одну понятную вещь: выводит все пути. Да, это похоже на «всем известный ls -R», но это идеальная тренировка мышц.


package main

import (
	"fmt"
	"io/fs"
	"path/filepath"
)

func main() {
	err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		fmt.Println(path)
		return nil
	})
	if err != nil {
		fmt.Println("walk:", err)
	}
}

Здесь вы видите базовую форму: один вызов WalkDir и одна функция‑обработчик. И обратите внимание: WalkDir возвращает error, то есть если вы внутри обработчика вернёте «настоящую» ошибку — обход остановится и ошибка «выйдет наружу». Это прямое следствие контракта: не магия, а обычный возврат значения.

«Только файлы» и «только директории»: простая фильтрация

Когда вы уже умеете печатать всё, следующий естественный шаг — печатать только то, что вам нужно. Например, только файлы (не директории). Это особенно удобно, когда вы делаете анализ: «найти все .tmp», «найти все .log», «найти все файлы больше N» (размер — позже, сегодня без фанатизма).

package main

import (
	"fmt"
	"io/fs"
	"path/filepath"
)

func main() {
	_ = filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil // пока просто пропускаем проблемные места
		}
		if d.IsDir() {
			return nil
		}
		fmt.Println("file:", path) // file: ./main.go
		return nil
	})
}

Здесь важно почувствовать идею: d.IsDir() — это быстрый «переключатель ветки». Нам не нужно os.Stat на каждый объект, потому что WalkDir даёт DirEntry. В этом и смысл «лёгких метаданных».

4. Управление обходом и обработка проблем

SkipDir и SkipAll: не ошибки, а управляющие сигналы

В обычной жизни слово «ошибка» звучит как «ой». В WalkDir есть два специальных значения, которые формально имеют тип error, но по смыслу — это команды управления.

filepath и io/fs описывают SkipDir и SkipAll как значения, которые используются как возвращаемое значение из обработчика, чтобы пропустить директорию или остановить обход.

Удобно держать это в голове в виде маленькой таблицы:

Что вернуть из fn Что произойдёт
nil
идём дальше
filepath.SkipDir
не заходим внутрь текущей директории
fs.SkipAll / filepath.SkipAll
прекращаем обход полностью
любая другая ошибка
прекращаем обход и возвращаем ошибку наружу

Сделаем практический пример: пропускаем .git, потому что в учебном проекте она обычно не нужна для анализа, а файлов там много.

package main

import (
	"io/fs"
	"path/filepath"
)

func main() {
	_ = filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() && d.Name() == ".git" {
			return filepath.SkipDir
		}
		return nil
	})
}

Заметьте важную деталь: фильтровать лучше по d.Name(), а не «магической подстроке» в path. Так меньше шансов случайно пропустить что-то не то.

Ошибки в err: часть «безопасного» поведения

Слово «безопасность» в файловой системе часто означает не только «защита от злоумышленника», но и «защита от хаоса». У файловой системы хаос — это нормальное состояние: файл мог исчезнуть, права могли измениться, директория могла стать недоступной, а антивирус мог решить, что ваш .tmp выглядит подозрительно (да, такое бывает, и он не извинится).

WalkDirFunc описывает, что WalkDir вызывает обработчик с ненулевым err как минимум в случаях проблем со Stat корня или проблем чтения директории. Причём там есть очень «педагогичная» деталь: обработчик может быть вызван до чтения директории, чтобы вы могли вернуть SkipDir и вообще избежать попытки чтения, и ещё раз — если чтение провалилось, чтобы сообщить ошибку.

Практический вывод: если вы игнорируете err, вы игнорируете реальность. А реальность потом игнорирует ваш код и устраивает вам panic… ну ладно, чаще просто неправильный результат.

Самый безопасный для новичка шаблон внутри обработчика обычно такой:

if err != nil {
    // либо return err (строго)
    // либо return nil (мягко, но результат может быть неполным)
}

И вы всегда явно выбираете стратегию.

5. Безопасность путей: symlink и «приземление» пользовательского ввода

С рекурсивным обходом связана ещё одна категория «безопасности»: что будет, если путь пришёл от пользователя? Например, ваш CLI‑инструмент принимает папку для анализа: todo scan <dir>.

Тут есть две разных проблемы.

Первая — петли и неожиданные прыжки по дереву через символические ссылки. Частично это решено самой библиотекой: filepath.WalkDir не следует символическим ссылкам. Это не абсолютная «защита от всего», но уже снижает шанс, что вы «уйдёте гулять» в неожиданное место.

Вторая — path traversal на уровне строк: когда пользователь пытается подсунуть что-то вроде ../../.. или абсолютный путь. Один из полезных «лексических фильтров» — filepath.IsLocal. Он проверяет, что путь не абсолютный, не пустой, и лексически остаётся внутри базовой директории при Join(base, path). При этом подчёркивается, что проверка чисто лексическая и не учитывает символические ссылки в файловой системе.

Вот минимальная проверка для аргумента, который вы хотите трактовать как «путь внутри рабочей директории»:

package main

import (
	"fmt"
	"os"
	"path/filepath"
)

func main() {
	p := os.Args[1]

	if !filepath.IsLocal(p) {
		fmt.Println("path must be local:", p)
		return
	}

	fmt.Println("ok:", p) // ok: data
}

Это не превращает ваш инструмент в «неуязвимую крепость», но уже не даёт пользователю случайно (или намеренно) отправить вас сканировать C:\Windows или /etc, если вы этого не планировали.

6. Мини-проект: команда scan для папки данных

Чтобы примеры были не только теорией, давайте представим, что у нас уже есть учебное CLI‑приложение для задач (условно назовём его todo). Оно хранит данные в директории ./data, а иногда (после экспериментов или падений) там могут оставаться временные файлы .tmp или резервные .bak. Команда scan будет выводить подозрительные файлы, не трогая содержимое — только имена.

Сделаем основу диспетчера команд максимально просто (да, flag будет позже — сейчас руками, как в старые добрые времена):

package main

import (
	"fmt"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: todo <cmd>")
		return
	}

	switch os.Args[1] {
	case "scan":
		scanDataDir()
	default:
		fmt.Println("unknown command:", os.Args[1])
	}
}

Теперь сама функция обхода. Тут мы используем WalkDir, пропускаем директории .git (на случай, если кто-то запустит скан из корня репозитория), и печатаем файлы с расширением .tmp или .bak.

package main

import (
	"fmt"
	"io/fs"
	"path/filepath"
	"strings"
)

func scanDataDir() {
	root := "data"

	_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return nil // мягко: пропустим проблемное место
		}
		if d.IsDir() && d.Name() == ".git" {
			return filepath.SkipDir
		}
		if d.IsDir() {
			return nil
		}

		if strings.HasSuffix(d.Name(), ".tmp") || strings.HasSuffix(d.Name(), ".bak") {
			fmt.Println("suspicious:", path) // suspicious: data/tasks.bak
		}
		return nil
	})
}

Здесь вы видите «скелет» реального обхода: ранние возвраты, проверка err, проверка директории, затем простая фильтрация по имени. Трюк в том, что этот код легко расширяется: позже вы сможете не только печатать, но и собирать результаты в срез, сортировать, красиво выводить таблицей. Но сегодня мы не прыгаем в следующую лекцию — наша цель именно понять механику WalkDir.

7. Типичные ошибки при работе с filepath.WalkDir

Ошибка №1: игнорировать err в обработчике и использовать d как будто он всегда валидный.
Параметр err приходит не «для галочки». Он означает, что с этим path что-то пошло не так: нет прав, объект исчез, директория не читается. Если вы продолжаете работать так, будто ошибки нет, вы получаете либо некорректный результат, либо странные падения уже в вашем коде, а не в стандартной библиотеке.

Ошибка №2: возвращать обычную ошибку там, где хотели просто «не заходить в папку».
Новички иногда пишут return fmt.Errorf("skip") в надежде, что обход просто пропустит директорию. Но по контракту это означает «остановить обход и вернуть ошибку наружу». Если вы хотите именно пропустить директорию, нужно вернуть filepath.SkipDir (или fs.SkipDir), потому что это специальный управляющий сигнал.

Ошибка №3: проверять, что директория «не нужна», по подстроке в path, а не по имени d.Name().
Проверки вида strings.Contains(path, "git") почти всегда приводят к сюрпризам: вы случайно пропустите my-git-notes.txt, или не пропустите .git из-за особенностей путей на Windows. Проверка по d.Name() обычно проще и точнее, потому что имя — это именно имя текущего элемента, без «шумных» частей пути.

Ошибка №4: путать «OS‑пути» и «пути внутри абстрактной FS», а потом удивляться слешам.
filepath.WalkDir работает с путями в формате операционной системы (разделитель зависит от платформы). Это поведение прямо отмечено в документации: в отличие от io/fs.WalkDir, где пути всегда со слешами. Если вы начнёте склеивать пути «вручную» через /, на Windows вас ждёт культурный шок.

Ошибка №5: считать, что WalkDir — это «безопасная песочница» для пользовательского пути.
То, что WalkDir не следует symlink’ам, уже полезно, но это не означает, что можно принимать любой путь от пользователя и бездумно обходить весь диск. Если путь должен быть «локальным» относительно вашей рабочей директории, используйте лексическую проверку вроде filepath.IsLocal, и помните, что она не учитывает символические ссылки и работает именно как лексический фильтр.

1
Задача
Go SELF, 41 уровень, 2 лекция
Недоступна
Журнал обхода
Журнал обхода
1
Задача
Go SELF, 41 уровень, 2 лекция
Недоступна
Счётчики обхода
Счётчики обхода
1
Задача
Go SELF, 41 уровень, 2 лекция
Недоступна
Охота за расширением
Охота за расширением
1
Задача
Go SELF, 41 уровень, 2 лекция
Недоступна
Безопасный подкаталог
Безопасный подкаталог
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ