Модуль 2. Git Smart HTTP в Cloud Function

Цель модуля: Реализовать Git Smart HTTP протокол в Cloud Function: принять POST от git-клиента, распарсить CGI-заголовки, стримить packfile через io.Copy в git-http-backend, и защитить репозиторий от Directory Traversal. Понять, почему io.ReadAll убьёт функцию на 32 MB push, и как тестировать exec.Command через интерфейсы.

Стадия роста проекта: MVP / первые юзеры. Окружение развёрнуто (Модуль 1), health-check работает. Теперь Вася делает свой первый git push origin main — и Git-клиент ожидает Smart HTTP ответ. Первый вопрос: «Как Cloud Function обрабатывает Git-протокол, если у неё 32 MB лимит на тело запроса?»

Перед началом: Модуль 1 (Setup), git (понимание packfile, refs, Smart HTTP)

Инструменты: Go 1.22+, Cloud Functions, git-http-backend, os/exec, io.Copy

Трудоёмкость: ~6 ч (теория 1.5 ч · практика 4 ч · тестирование 0.5 ч)

🔥 Инцидент: push на 40 MB убил функцию

Вася делает git push origin main. В репозитории — 40 MB packfile (большой бинарный файл). Cloud Function получает POST с телом 40 MB. Код написан «как обычно»:

go
body, err := io.ReadAll(r.Body) // ❌ Читает ВСЁ в память

Функция падает с OOM: лимит памяти 512 MB, но Yandex Cloud убивает функцию при превышении 32 MB на тело запроса. Push не прошёл. Вася видит 502 Bad Gateway.

Вторая проблема: другой разработчик делает push одновременно. Оба запроса пишут в один bare-репозиторий на NFS. Без блокировки refs обновляются конкурентно — один push «перезаписывает» другой.

Третья проблема: кто-то отправляет запрос на /git/../../etc/passwd.git/info/refs. Без проверки пути git-http-backend получает доступ к файлам вне /var/git.

Три задачи этого модуля:

  1. Стриминг — io.Copy вместо io.ReadAll: не читаем всё в память, а стримим в stdin git-http-backend.
  2. Безопасность — assertSafeRepo: проверяем, что путь не выходит за пределы /var/git.
  3. Тестируемость — exec.Command через интерфейс, чтобы мокать в тестах.

Сквозной User Story: «Вася делает git push origin main. В этом модуле мы смотрим, что происходит на этапе Git Gateway: как Cloud Function принимает POST на /repo.git/git-receive-pack, парсит CGI-заголовки, стримит packfile в git-http-backend, и отвечает статусом. В Модуле 3 — как метаданные push сохраняются в YDB. В Модуле 4 — как событие RepositoryPushed доставляется в CI/CD.»

📖 Теория: Git Smart HTTP протокол

Глоссарий модуля:

Smart HTTP — протокол Git поверх HTTP: клиент отправляет POST с packfile, сервер отвечает ACK/NAK. Два эндпоинта: git-upload-pack (clone/fetch) и git-receive-pack (push).

CGI (Common Gateway Interface) — стандарт: HTTP-сервер передаёт запрос внешней программе через переменные окружения и stdin/stdout. Git использует CGI для git-http-backend.

git-http-backend — CGI-программа из поставки Git: принимает HTTP-запрос, обрабатывает Smart HTTP протокол, пишет в bare-репозиторий.

Packfile — бинарный формат Git: сжатые объекты (blobs, trees, commits) в одном файле. Push отправляет packfile.

io.Copy vs io.ReadAll — io.Copy стримит данные (O(1) память), io.ReadAll читает всё в память (O(n) память). Для 32 MB push — только io.Copy.

exec.Command — запуск внешнего процесса в Go. Требует context.Context для таймаута и cmd.Wait() для освобождения ресурсов.

assertSafeRepo — проверка, что путь к репозиторию не выходит за пределы базовой директории (защита от Directory Traversal).

2.1. Как работает Git Smart HTTP

Git-клиент общается с сервером через два эндпоинта:

ОперацияЭндпоинтМетодЧто передаётся
git clone / git fetch/repo.git/info/refs?service=git-upload-packGETСписок refs
git clone / git fetch/repo.git/git-upload-packPOSTWant/Have → packfile
git push/repo.git/info/refs?service=git-receive-packGETСписок refs
git push/repo.git/git-receive-packPOSTPackfile + команды обновления refs
100%
🖱 Перетащите • Колесо для зума

Ключевой момент: Cloud Function не обрабатывает Git-протокол сама. Она передаёт запрос в git-http-backend через CGI-переменные окружения и stdin/stdout. Функция — «клей» между HTTP и CGI.

2.2. Мини-гайд по os/exec в Cloud Functions

Для новичков в Go: exec.Command — это запуск внешнего процесса. Три критических правила:

go
// ❌ Антипаттерн: нет контекста, нет Wait
cmd := exec.Command("git", "http-backend")
cmd.Start() // Процесс запущен, но никто не ждёт завершения

// ✅ Правильно: контекст + Wait
func runGitBackend(ctx context.Context, env []string, stdin io.Reader, stdout io.Writer) error {
    cmd := exec.CommandContext(ctx, "git", "http-backend")
    cmd.Env = env
    cmd.Stdin = stdin   // io.Copy стримит сюда
    cmd.Stdout = stdout // Ответ git-http-backend
    cmd.Stderr = os.Stderr // Логи ошибок

    if err := cmd.Start(); err != nil {
        return fmt.Errorf("start git-http-backend: %w", err)
    }

    // Обязательно: ждём завершения процесса
    // Без Wait() — zombie-процессы, утечка файловых дескрипторов
    if err := cmd.Wait(); err != nil {
        return fmt.Errorf("git-http-backend exited: %w", err)
    }
    return nil
}

Правила:

  1. Всегда exec.CommandContext(ctx, ...) — если Cloud Function убивают по таймауту, процесс тоже умрёт.
  2. Всегда cmd.Wait() — иначе zombie-процессы. В Cloud Function каждый zombie = утечка файловых дескрипторов.
  3. cmd.Stdin = io.Reader, cmd.Stdout = io.Writer — не читаем в память, стримим.

2.3. Стриминг: io.Copy вместо io.ReadAll

Лимит Cloud Function: 32 MB на тело запроса, 1 GB RAM. Push на 30 MB — это нормально. Но io.ReadAll читает всё в память:

go
// ❌ Антипаттерн: читает 30 MB в память
body, err := io.ReadAll(r.Body)
cmd.Stdin = bytes.NewReader(body)

Правильно — стримить напрямую:

go
// ✅ Правильно: стримим из HTTP body в stdin git-http-backend
func (h *GitHandler) handleReceivePack(w http.ResponseWriter, r *http.Request) {
    // r.Body — io.ReadCloser, стримит из сети
    // cmd.Stdin — io.Reader, git-http-backend читает из него
    // io.Copy копирует байт за байтом, O(1) память

    ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) // Таймаут Cloud Function
    defer cancel()

    cmd := exec.CommandContext(ctx, "git", "http-backend")
    cmd.Env = h.buildCGIEnv(r)
    cmd.Stdin = r.Body  // Стриминг: не читаем в память
    cmd.Stdout = w      // Ответ git-http-backend → HTTP response
    cmd.Stderr = os.Stderr

    if err := cmd.Start(); err != nil {
        http.Error(w, "failed to start git-http-backend", 500)
        return
    }

    // git-http-backend сам читает из stdin и пишет в stdout
    // io.Copy не нужен здесь — git-http-backend делает это сам
    if err := cmd.Wait(); err != nil {
        http.Error(w, "git-http-backend failed", 500)
        return
    }
}

Почему это работает: r.Body — это io.ReadCloser, который стримит данные из сети. cmd.Stdin = r.Body передаёт этот стрим напрямую в процесс git-http-backend. Данные не задерживаются в памяти функции.

2.4. CGI-переменные окружения

git-http-backend ожидает CGI-переменные. Строим их из HTTP-запроса:

go
func (h *GitHandler) buildCGIEnv(r *http.Request) []string {
    repoPath := h.extractRepoPath(r.URL.Path) // /repo.git

    return []string{
        "GATEWAY_INTERFACE=CGI/1.1",
        "SERVER_PROTOCOL=HTTP/1.1",
        "SERVER_SOFTWARE=CloudFunction",
        "REQUEST_METHOD=" + r.Method,
        "QUERY_STRING=" + r.URL.RawQuery,
        "CONTENT_TYPE=" + r.Header.Get("Content-Type"),
        "CONTENT_LENGTH=" + r.Header.Get("Content-Length"),
        "PATH_INFO=" + repoPath,
        "GIT_PROJECT_ROOT=/var/git",      // Базовая директория NFS
        "GIT_HTTP_EXPORT_ALL=1",          // Разрешить все репозитории
        "REMOTE_USER=" + r.Header.Get("X-User-Id"), // Из JWT (Модуль 7)
    }
}

Критично: PATH_INFO должен содержать только путь к репозиторию, без ... Это проверяет assertSafeRepo.

2.5. Защита от Directory Traversal: assertSafeRepo

Атакующий отправляет запрос на /git/../../etc/passwd.git/info/refs. Без проверки PATH_INFO содержит ../../etc/passwd.git, и git-http-backend получает доступ к /etc/passwd.

go
// assertSafeRepo проверяет, что путь не выходит за пределы baseDir
func assertSafeRepo(baseDir, repoPath string) error {
    // 1. filepath.Clean нормализует путь (убирает .., .)
    cleaned := filepath.Clean(repoPath)

    // 2. filepath.Join строит полный путь
    fullPath := filepath.Join(baseDir, cleaned)

    // 3. filepath.Rel вычисляет относительный путь от baseDir
    rel, err := filepath.Rel(baseDir, fullPath)
    if err != nil {
        return fmt.Errorf("invalid repo path: %w", err)
    }

    // 4. Если rel начинается с ".." — путь выходит за пределы baseDir
    if strings.HasPrefix(rel, "..") {
        return fmt.Errorf("directory traversal detected: %s", repoPath)
    }

    return nil
}

Примеры:

  • /repo.git → filepath.Rel("/var/git", "/var/git/repo.git") = repo.git → OK
  • /../../etc/passwd.git → filepath.Rel("/var/git", "/etc/passwd.git") = ../../etc/passwd.git → REJECT

2.6. Тестирование: моки для os/exec

Как тестировать exec.Command без реального git? Через интерфейс:

go
// Интерфейс для мокирования
type CommandRunner interface {
    Run(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error
}

// Реальная реализация
type RealRunner struct{}

func (r *RealRunner) Run(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error {
    cmd := exec.CommandContext(ctx, name, args...)
    cmd.Stdin = stdin
    cmd.Stdout = stdout
    cmd.Env = env
    return cmd.Run()
}

// Мок для тестов
type MockRunner struct {
    RunFunc func(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error
}

func (m *MockRunner) Run(ctx context.Context, name string, args []string, stdin io.Reader, stdout io.Writer, env []string) error {
    return m.RunFunc(ctx, name, args, stdin, stdout, env)
}

Тест parseCGIHeaders:

go
func TestParseCGIHeaders(t *testing.T) {
    // CGI-ответ git-http-backend: заголовки + пустая строка + тело
    input := "Status: 200 OK\r\nContent-Type: application/x-git-receive-pack-result\r\n\r\n000eunpack ok\n"

    headers, body, err := parseCGIHeaders(strings.NewReader(input))
    if err != nil {
        t.Fatal(err)
    }

    if headers["Status"] != "200 OK" {
        t.Errorf("expected Status 200 OK, got %s", headers["Status"])
    }
    if body != "000eunpack ok\n" {
        t.Errorf("expected body '000eunpack ok\\n', got %q", body)
    }
}

// Критично: пустая строка между заголовками и телом
func TestParseCGIHeaders_EmptyLineSeparator(t *testing.T) {
    // ❌ Без пустой строки — парсер не найдёт тело
    input := "Status: 200 OK\r\nContent-Type: text/plain\r\nbody"
    _, _, err := parseCGIHeaders(strings.NewReader(input))
    if err == nil {
        t.Error("expected error: no empty line separator")
    }
}

Тест assertSafeRepo:

go
func TestAssertSafeRepo(t *testing.T) {
    tests := []struct {
        path    string
        wantErr bool
    }{
        {"/repo.git", false},
        {"/repo.git/info/refs", false},
        {"/../../etc/passwd.git", true},
        {"/repo.git/../../../etc/shadow", true},
        {"/repo.git/./info/refs", false},
    }

    for _, tt := range tests {
        err := assertSafeRepo("/var/git", tt.path)
        if (err != nil) != tt.wantErr {
            t.Errorf("assertSafeRepo(%q) error = %v, wantErr %v", tt.path, err, tt.wantErr)
        }
    }
}

2.7. Этот паттерн в архетипах

Smart HTTP проявляется по-разному в четырёх архетипах проектов:

АрхетипПротоколСтримингПочему критично
ФинтехgRPC (protobuf)Нет (малые payload)Strong consistency, нет больших бинарных данных
Git-хостингSmart HTTP (CGI)io.Copy, packfile до 32 MBPush большого репо = стриминг обязателен
МессенджерWebSocketLong-lived соединенияReal-time доставка, Serverless не подходит
ЛентаREST + CDNНет (JSON < 1 MB)Чтение, не запись; кэш снимает нагрузку

Общий принцип: стриминг нужен там, где payload > RAM. Для Git push — packfile может быть 30 MB, а RAM функции 512 MB. io.ReadAll на 30 MB — это 6% RAM, но при 10 одновременных push — 60%. io.Copy — O(1) память.

2.8. Компромиссы (Trade-offs)

РешениеПлюсМинусКогда выбирать
io.Copy (стриминг)O(1) память; push до 32 MBСложнее отлаживать (нет полного тела в логах)Всегда для Smart HTTP
io.ReadAllПросто; тело в памяти для логовOOM при push > 32 MBАнтипаттерн для Git
exec.Command + git-http-backendНе реализуем Git-протокол самиЗависимость от git в образеВсегда (не пишем свой Smart HTTP)
assertSafeRepoЗащита от Directory TraversalДоп. проверка на каждый запросВсегда для путей из URL
Моки для exec.CommandТестируемость без gitДоп. абстракция (интерфейс)Всегда для CI/CD

Вывод: для Git Smart HTTP в Cloud Function — только io.Copy + exec.Command + assertSafeRepo. См. ADR-SV2.

🛠 Практические задания

Сценарий: Вася делает git push origin main с packfile 15 MB. Цель: push проходит за < 30 сек, функция не падает, репозиторий обновлён.

Задача 1: Git Smart HTTP handler

Реализовать handler для /git/{repo}.git/{service}:

  • Извлечь repoPath из URL
  • Проверить assertSafeRepo
  • Построить CGI-переменные
  • Запустить git-http-backend через exec.CommandContext
  • Стримить stdin/stdout

✅ Готово, когда: [ ] GET /repo.git/info/refs?service=git-upload-pack возвращает список refs [ ] POST /repo.git/git-upload-pack стримит packfile (clone/fetch) [ ] POST /repo.git/git-receive-pack стримит packfile (push) [ ] assertSafeRepo отклоняет ../../etc/passwd.git [ ] exec.CommandContext с таймаутом 10 мин [ ] cmd.Wait() вызывается всегда

Задача 2: Тестирование

Написать тесты:

  • TestParseCGIHeaders — парсинг CGI-ответа (заголовки + пустая строка + тело)
  • TestAssertSafeRepo — табличные тесты для Directory Traversal
  • TestGitHandler — интеграционный тест с MockRunner

✅ Готово, когда: [ ] TestParseCGIHeaders проверяет пустую строку-разделитель [ ] TestAssertSafeRepo покрывает минимум 5 кейсов (нормальный путь, .., ./, вложенный) [ ] TestGitHandler использует MockRunner (не реальный git) [ ] go test ./... -race проходит без ошибок

Задача 3: Локальная проверка

Поднять local-ydb и NFS-эмулятор (Модуль 1). Создать bare-репозиторий. Сделать git push через curl:

bash
# Создаём bare-репозиторий
git init --bare /var/git/test-repo.git

# Проверяем info/refs
curl -v "http://localhost:8080/git/test-repo.git/info/refs?service=git-receive-pack"

# Делаем push (упрощённо, через git)
cd /tmp && git init test && cd test
echo "hello" > file.txt && git add . && git commit -m "init"
git remote add origin http://localhost:8080/git/test-repo.git
git push origin main

✅ Готово, когда: [ ] git push проходит успешно [ ] В bare-репозитории появился коммит [ ] Функция не падает при push 15 MB

⚠️ Антипаттерн для разбора

go
// Go Cloud Function (Антипаттерн)
func (h *GitHandler) Push(w http.ResponseWriter, r *http.Request) {
    // ❌ 1. Читаем всё в память
    body, _ := io.ReadAll(r.Body)

    // ❌ 2. Нет проверки пути
    repoPath := r.URL.Path // Может содержать ../../etc/passwd

    // ❌ 3. Нет контекста, нет Wait
    cmd := exec.Command("git", "http-backend")
    cmd.Stdin = bytes.NewReader(body)
    cmd.Start()
    // cmd.Wait() не вызывается → zombie-процесс

    w.WriteHeader(200)
}

Вопросы студенту:

  1. Что произойдёт при push на 40 MB? (OOM, функция упадёт)
  2. Что произойдёт, если отправить запрос на /../../etc/passwd.git? (Directory Traversal)
  3. Что произойдёт, если cmd.Wait() не вызывается? (Zombie-процессы, утечка FD)

🧩 Проверь себя

Q: Почему io.ReadAll — антипаттерн для Git push в Cloud Function?

  • io.ReadAll медленнее io.Copy
  • Лимит 32 MB на тело запроса; io.ReadAll читает всё в память → OOM*
  • io.ReadAll не поддерживает стриминг

Q: Как защитить git-http-backend от Directory Traversal?

  • Проверить Content-Type
  • assertSafeRepo: filepath.Clean + filepath.Rel, отклонить если путь начинается с ".."*
  • Использовать HTTPS

Q: Зачем cmd.Wait() после exec.Command?

  • Для логирования
  • Освобождает ресурсы процесса; без Wait() — zombie-процессы и утечка FD*
  • Это не обязательно

Q: Что передаётся в cmd.Stdin для git-http-backend?

  • Полное тело запроса ([]byte)
  • r.Body (io.ReadCloser) — стриминг из сети*
  • JSON с метаданными

Q: Как тестировать exec.Command без реального git?

  • Не тестировать
  • Интерфейс CommandRunner + MockRunner*
  • Поднять реальный git в тесте

Q: Пустая строка в CGI-ответе — зачем?

  • Для красоты
  • Разделяет заголовки и тело; без неё парсер не найдёт тело*
  • Это требование HTTP/2

🤖 Инструкция для LLM (Coding Plan Assistant) — Режим Модуля 2

Привет! Я начинаю Модуль 2 курса «Git Hosting на Go: Serverless MVP на Yandex Cloud». Выступи в роли Наставника-Архитектора.

Сначала проверь понимание: дай короткий ответ (2–3 предложения) — почему io.ReadAll убьёт Cloud Function при push на 30 MB, и как io.Copy решает эту проблему.

Затем выдай задание:

  1. Реализовать Git Smart HTTP handler для Cloud Function (GET info/refs + POST git-receive-pack).
  2. Написать assertSafeRepo для защиты от Directory Traversal.
  3. Написать тесты: parseCGIHeaders (с пустой строкой-разделителем), assertSafeRepo (5+ кейсов), MockRunner для exec.Command.

Покажи мне код Антипаттерна (io.ReadAll, нет assertSafeRepo, нет cmd.Wait()) и попроси объяснить, почему каждый из трёх пунктов убьёт функцию.

НЕ ПИШИ за меня handler и тесты. Дождись моего ответа с архитектурным планом. После этого дай фидбек по чек-листу ментора и укажи на слепые зоны (например, «нет таймаута на exec.CommandContext», «assertSafeRepo не покрывает все кейсы», «тесты не проверяют пустую строку в CGI»).

📝 Чек-лист ментора

[ ] Стриминг: io.Copy или cmd.Stdin = r.Body — нет io.ReadAll. [ ] exec.Command: exec.CommandContext(ctx, ...) с таймаутом 10 мин. [ ] cmd.Wait(): вызывается всегда, даже при ошибке. [ ] assertSafeRepo: filepath.Clean + filepath.Rel, отклоняет ... [ ] CGI-переменные: PATH_INFO, GIT_PROJECT_ROOT, REQUEST_METHOD, CONTENT_TYPE. [ ] Тесты: parseCGIHeaders проверяет пустую строку-разделитель. [ ] Тесты: assertSafeRepo покрывает минимум 5 кейсов. [ ] Тесты: MockRunner для exec.Command (не реальный git в CI). [ ] Интеграционный тест: git push через curl или реальный git. [ ] ADR-SV2: «Smart HTTP в Cloud Function: стриминг vs io.ReadAll».

🔗 Мостик к Модулю 3 (YDB Serverless)

В конце ревью скажи студенту:

«Отлично, push прошёл: packfile записан в bare-репозиторий на NFS, refs обновлены. Но как мы узнаем, кто сделал push, когда, и в какой репозиторий? Метаданные (репозитории, пользователи, refs) нужно хранить в БД. В Модуле 3 мы подключим YDB Serverless: разберём YQL (не SQL!), оптимистичные транзакции (нет FOR UPDATE!), и как уложиться в 10 RU/с free-tier. А ещё — как EXPLAIN показывает, что запрос идёт по индексу.»

Сквозной User Story: «Вася сделал git push origin main. Packfile записан на NFS. В Модуле 3 мы посмотрим, как метаданные push (кто, когда, какой репозиторий, какие refs обновлены) сохраняются в YDB. В Модуле 4 — как событие RepositoryPushed доставляется в CI/CD через Outbox.»