Интеграция с ОС: os/exec без инъекций, атомарный rename, advisory-блокировки, fsnotify и go:embed
os/exec со срезом аргументов и CommandContext закрывает shell-инъекции и зависших потомков; код — в ExitError. Lock-файлы через O_EXCL, атомарная замена temp + fsync + rename на одной ФС, advisory flock, штормы fsnotify при сохранении в редакторе, go:embed — один бинарник.
Агент на хосте переписывал свой конфиг одним и тем же способом каждые пять минут: os.Create(path), encode, close. Это работало два года на флоте розничных edge-боксов — ровно до регионального мигания электричества одним зимним вечером. os.Create открывает с O_TRUNC: он сначала обнуляет файл и потом пишет новые байты, и между этими двумя моментами несколько сотен машин потеряли питание. Они вернулись с конфигом ровно в ноль байт. Агент читал его на старте, не мог распарсить, выходил; супервизор перезапускал; агент снова читал тот же пустой файл. Флот магазинов крутился в boot-loop, пока полевые инженеры не зашли по ssh в каждую коробку с recovery-образом. Самая болезненная строка постмортема была самой короткой: используй писатель последовательность temp-файл, fsync, rename — этот сбой был бы физически невозможен: rename подменяет запись в каталоге атомарно, и каждая машина загрузилась бы либо со старым конфигом, либо с новым, а ничего между ними не существует.
Запуск процессов: os/exec без футганов
После этого раздела ты поймёшь, почему «правильный подход» закрывает целый класс уязвимостей, а не один небезопасный вызов — и почему сбой питания может стать не-событием, если файловые записи подключены правильно.
Вызов внешних программ — хлеб системного инструмента, и три дисциплины отделяют корректную версию от отчёта об инциденте:
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "pg_dump", "--format=custom", dbName) // argv срезом
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr // захват по отдельности — контракт урока 01
err := cmd.Run()
var exitErr *exec.ExitError
switch {
case err == nil: // вышел с кодом 0
case errors.As(err, &exitErr):
log.Printf("pg_dump exited %d: %s", exitErr.ExitCode(), stderr.String())
default: // не смог даже стартовать: не найден, права
log.Printf("exec failed: %v", err)
}Дисциплина первая: аргументы — срез, никогда не shell-строка. exec.Command("sh", "-c", "convert "+userFile) отдаёт твою строку парсеру шелла, и userFile вида x.png; rm -rf /srv/data выполнит обе команды. Срез argv уходит в exec ядра напрямую — ни шелла, ни word splitting, ни класса инъекций вообще. Поймал себя на ручном квотировании — уже проиграл; перестрой код на срез.
Дисциплина вторая: таймауты через CommandContext. Потомок, зависший на мёртвом NFS-маунте, без него вешает твою горутину навсегда. При отмене дефолт — жёсткий Kill; с Go 1.20 поля Cancel и WaitDelay позволяют сначала послать SIGTERM и эскалировать — контракт урока 02, применённый вниз по иерархии. Честная оговорка: убийство достаёт только твоего ребёнка. Если он наплодил внуков, те выживают и держат пайп открытым — Wait блокируется на унаследованных дескрипторах. Настоящий фикс для деревьев процессов — своя группа процессов (Setpgid) и сигнал всей группе.
Дисциплина третья: окружение и рабочий каталог — это входы. cmd.Env == nil наследует всё твоё окружение — включая каждый секрет в нём. Для всего чувствительного к безопасности или воспроизводимости задавай cmd.Env явным минимальным срезом и фиксируй cmd.Dir; потомок, резолвящий относительные пути от каталога, в котором случайно стартовал твой сервис, — латентный баг «на проде не как на ноуте».
Бэкап-раннер выполняет exec.Command с sh -c, конкатенируя в команду строку name из API-запроса. В чём уязвимость и каков фикс?
Файлы на уровне ОС: три примитива
Спроси себя: что самое плохое может случиться с этим файлом между двумя строками кода? Ответ определяет, за какой примитив тянуться.
Создать-если-нет: O_EXCL. os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) успешен ровно для одного вызывающего; остальные получают EEXIST. Эта атомарность даёт lock-файлы единственного экземпляра и маркеры «забираю этот элемент работы». Слабость — протухание: упавший владелец оставляет файл, и следующий старт отказывается работать из-за блокировки, которую никто не держит.
Атомарная замена: temp, fsync, rename. Единственный безопасный способ перезаписать файл, который кто-то может читать — или запись которого может оборвать сбой питания:
func atomicWrite(path string, data []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".cfg-*") // ТОТ ЖЕ каталог = та же ФС
if err != nil { return err }
defer os.Remove(tmp.Name()) // после успешного rename — no-op
if _, err := tmp.Write(data); err != nil { tmp.Close(); return err }
if err := tmp.Sync(); err != nil { tmp.Close(); return err } // байты на диске ДО подмены
if err := tmp.Close(); err != nil { return err }
return os.Rename(tmp.Name(), path) // атомарная подмена записи каталога
}Каждая строка несёт гарантию. Temp-файл живёт в каталоге цели, потому что rename(2) атомарен только в пределах одной файловой системы — между устройствами он падает с EXDEV, а fallback «скопировать и удалить», который некоторые библиотеки делают молча, — ровно та неатомарная запись, от которой ты убегал (/tmp сплошь и рядом отдельный tmpfs — классический способ отгрузить этот баг). Sync выталкивает данные на диск до подмены; пропусти его — и ядро может сделать rename долговечным раньше байтов, а сбой оставит правильно названный файл, полный мусора. Для полной паранойи сделай fsync ещё и каталогу, чтобы сама новая запись пережила отключение. Цена реальна: fsync стоит единицы миллисекунд на SSD и десятки на шпинделе — поэтому записи состояния батчуют, а не пропускают sync.
Advisory-блокировка: flock, честно. syscall.Flock(fd, LOCK_EX) (advisory lock — координационный механизм только для сотрудничающих процессов; ядро ничего не навязывает тем, кто не спрашивает) координирует сотрудничающие процессы — программа, просто открывающая и пишущая, проходит насквозь. Суперсила перед O_EXCL-файлом: блокировка умирает вместе с дескриптором процесса, краш не оставляет протухший lock. Пределы: только одна машина и исторически ненадёжно поверх NFS.
Эти три примитива покрывают все сценарии координации файлов на уровне ОС: O_EXCL — для одиночных заявок, rename — для краш-безопасных перезаписей, flock — для кооперативной координации. Взять не тот примитив не означает немедленного сбоя — он случится через месяцы, в продовой среде, во время мигания электричества.
Почему temp-запись + fsync + rename переживает сбой питания, а os.Create + Write по целевому пути — нет?
Наблюдение за файлами: fsnotify и редакторский шторм
fsnotify оборачивает inotify (Linux), kqueue (macOS/BSD) и ReadDirectoryChangesW (Windows) в один канал событий — и наивное использование ломается на только что выученном паттерне. Редакторы и воспитанные писатели конфигов сохраняют атомарно: пишут temp-файл и rename-ят его поверх цели. Watch, поставленный на файл, следует за инодом, поэтому rename заставляет твой watch выстрелить REMOVE и замолчать навсегда — имя теперь указывает на инод, за которым ты не следишь. Надёжный рецепт: следи за каталогом, фильтруй события по имени и жди вспышку — CREATE, WRITE, иногда CHMOD и RENAME в пределах нескольких миллисекунд на одно логическое сохранение. Дебаунси таймером со сбросом по событию (100 мс — здравый дефолт) и перечитывай файл один раз на вспышку. И никогда не реагируй на WRITE немедленным чтением: чтение посреди записи неатомарного писателя выдаёт тот же частичный файл, с которого грузился флот из Hook.
go:embed: один бинарник, доведённый до конца
//go:embed templates/*.tmpl migrations/*.sql
var assets embed.FSШаблоны, статика, SQL-миграции — вкомпилированы в бинарник и читаются через embed.FS как любой fs.FS. Это завершает историю деплоя из урока 01: артефакт — один файл, и режим отказа «забыли скопировать каталог миграций» больше не существует. Честные издержки: бинарник растёт на размер ассетов, любое их изменение требует пересборки, файловая система только для чтения. Острое ребро: пути в embed.FS всегда с прямыми слэшами — а это территория пакета path, не filepath.
Платформенные ветвления
Два механизма с разными задачами. Build-теги (//go:build linux) разделяют файлы, когда API не существует на другой платформе — у flock нет эквивалента на Windows (там LockFileEx с другой семантикой), поэтому реализация блокировки живёт в lock_unix.go и lock_windows.go. Ветвления по runtime.GOOS — для маленьких поведенческих развилок внутри в остальном портируемого кода: выбор дефолтного каталога конфигов, скажем. И держи два пакета путей раздельно: path/filepath говорит на языке хостовой ОС (разделители, буквы дисков) и принадлежит всему, что трогает реальную файловую систему; path говорит на вечных прямых слэшах и принадлежит URL и embed.FS. Их смешение — Windows-баг, который ты не увидишь, пока его не зарепортит пользователь.
▸Почему это работает
Почему rename — единственный атомарный примитив, а запись — нет? Потому что POSIX определяет rename(2) как атомарную замену цели в пределах файловой системы — это подмена указателя в метаданных каталога, одна запись, обновлённая одной операцией. У записи такого обещания нет: write — операция над диапазоном байтов без транзакционной группировки, которую сбой волен разорвать на любой границе блока. Файловая система выдаёт ровно одну атомарную операцию над именами — и паттерн атомарной замены существует, чтобы провести каждую перезапись через эту единственную гарантию: сделай всю ненадёжную байтовую работу на имени, которое никто не читает, и потрать один атомарный своп в самом конце.
- 01Перечисли три дисциплины os/exec и оговорку про внуков.
- 02Дай полный рецепт атомарной замены и объясни, зачем нужен каждый шаг, включая ловушку EXDEV.
Операционная система даёт Go-сервису три рычага — дочерние процессы, файлы и сам бинарник, — и у каждого один правильный хват. Потомки запускаются через os/exec с аргументами срезом, что убирает shell-инъекции как класс, а не санитизирует вокруг них; CommandContext ограничивает каждого потомка дедлайном, Cancel и WaitDelay превращают дефолтное жёсткое убийство в graceful TERM-затем-KILL, ExitError несёт код выхода при отдельно захваченном stderr, а деревьям процессов нужна группа — потому что убийство по контексту достаёт только прямого ребёнка. Окружение и рабочий каталог — входы, которыми управляют, а не дефолты, которые наследуют. Файлы на уровне ОС стоят на трёх примитивах: O_EXCL даёт атомарное создание-если-нет для локов и заявок, но оставляет протухшие файлы после крашей; flock — advisory, обязателен только для спрашивающих процессов, зато самоочищается, умирая с дескриптором; а rename — единственная атомарная операция над именами, и потому всякая безопасная перезапись — это temp-запись в том же каталоге, fsync, затем rename: последовательность, превращающая сбой питания из нулевого конфига и boot-loop флота в не-событие, по миллисекундной цене fsync, с EXDEV, поджидающим всякого, чей temp-файл живёт на другой файловой системе. Наблюдать за файлами значит наблюдать за каталогами: атомарные сохранители подставляют новые иноды на место через rename, и watch на файле умирает со старым инодом; дебаунси вспышку событий и читай один раз. go:embed сворачивает шаблоны и миграции в однофайловый артефакт, а платформенные различия расходятся по build-тегам для отсутствующих API и runtime.GOOS для мелких развилок — с filepath для настоящих дисков и path для встроенных. Теперь, когда встретишь нулевой конфиг после сбоя питания, ты знаешь: две строки кода сделали это неизбежным — и четыре строки сделали бы физически невозможным.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.