Entrypoint формат module.function: лintер ищет module.py в архиве и проверяет что def function(...) определена внутри. Если нет — сразу ошибка, не нужно ждать таймаута вызова. Фронтенд передаёт entrypoint из поля e-entry / ca-entry.
262 lines
8.8 KiB
Go
262 lines
8.8 KiB
Go
package api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// maxArchiveBytes — максимальный размер zip-архива (100 KB).
|
|
// Serverless-функции не должны быть большими — только скрипты и небольшие зависимости.
|
|
maxArchiveBytes = 100 * 1024
|
|
|
|
// maxUnzippedBytes — ограничение на суммарный распакованный размер (защита от zip bomb).
|
|
// 100 KB реального кода — достаточно для любой serverless-функции.
|
|
maxUnzippedBytes = 100 * 1024
|
|
)
|
|
|
|
// lintResult — результат проверки одного файла.
|
|
type lintResult struct {
|
|
File string `json:"file"`
|
|
OK bool `json:"ok"`
|
|
Output string `json:"output,omitempty"`
|
|
}
|
|
|
|
// handleLintArchive — POST /console/api/ai/lint-archive
|
|
// Принимает zip-архив (multipart/form-data, поле "archive"),
|
|
// прогоняет линтер по всем файлам с известным расширением,
|
|
// возвращает список результатов по файлам.
|
|
func (s *Server) handleLintArchive(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
|
|
// Hard-limit 10 MB чтобы соединение не обрывалось (MaxBytesReader с маленьким лимитом
|
|
// закрывает соединение до отправки ответа — клиент получает 000 вместо 413).
|
|
// Реальный лимит размера архива проверяется после чтения файла.
|
|
r.Body = http.MaxBytesReader(w, r.Body, 10*1024*1024)
|
|
|
|
if err := r.ParseMultipartForm(maxArchiveBytes); err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
writeJSONError(w, http.StatusRequestEntityTooLarge,
|
|
fmt.Sprintf("архив слишком большой: максимум %d KB", maxArchiveBytes/1024))
|
|
} else {
|
|
writeJSONError(w, http.StatusBadRequest, "ошибка разбора multipart: "+err.Error())
|
|
}
|
|
return
|
|
}
|
|
|
|
// Опциональный entrypoint для проверки наличия файла в архиве (формат: "module.function")
|
|
entrypoint := strings.TrimSpace(r.FormValue("entrypoint"))
|
|
|
|
f, _, err := r.FormFile("archive")
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "поле 'archive' обязательно")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
// Читаем архив в память
|
|
var buf bytes.Buffer
|
|
if _, err := buf.ReadFrom(f); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "ошибка чтения архива: "+err.Error())
|
|
return
|
|
}
|
|
if buf.Len() > maxArchiveBytes {
|
|
writeJSONError(w, http.StatusRequestEntityTooLarge,
|
|
fmt.Sprintf("архив слишком большой: %d байт, максимум %d KB", buf.Len(), maxArchiveBytes/1024))
|
|
return
|
|
}
|
|
|
|
// Открываем zip из памяти
|
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "не удалось открыть zip: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Считаем суммарный распакованный размер (защита от zip bomb)
|
|
var totalUnzipped uint64
|
|
for _, zf := range zr.File {
|
|
totalUnzipped += zf.UncompressedSize64
|
|
}
|
|
if totalUnzipped > maxUnzippedBytes {
|
|
writeJSONError(w, http.StatusRequestEntityTooLarge,
|
|
fmt.Sprintf("суммарный размер распакованных файлов превышает %d KB", maxUnzippedBytes/1024))
|
|
return
|
|
}
|
|
|
|
// Карта линтеров по расширению
|
|
type linterCfg struct {
|
|
cmd []string
|
|
stdinOk bool // поддерживает stdin
|
|
stdinArg string // аргумент для stdin-режима (имя "файла")
|
|
}
|
|
linters := map[string]linterCfg{
|
|
".py": {cmd: []string{"python3", "-m", "py_compile"}, stdinOk: false},
|
|
".js": {cmd: []string{"node", "--check"}, stdinOk: false},
|
|
".rb": {cmd: []string{"ruby", "-c"}, stdinOk: true},
|
|
".php": {cmd: []string{"php", "-l"}, stdinOk: false},
|
|
}
|
|
|
|
var results []lintResult
|
|
var hasError bool
|
|
|
|
for _, zf := range zr.File {
|
|
if zf.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(zf.Name))
|
|
cfg, supported := linters[ext]
|
|
if !supported {
|
|
continue
|
|
}
|
|
|
|
// Читаем содержимое файла из zip в память
|
|
rc, err := zf.Open()
|
|
if err != nil {
|
|
results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + err.Error()})
|
|
hasError = true
|
|
continue
|
|
}
|
|
var content bytes.Buffer
|
|
_, readErr := content.ReadFrom(rc)
|
|
rc.Close()
|
|
if readErr != nil {
|
|
results = append(results, lintResult{File: zf.Name, OK: false, Output: "ошибка чтения: " + readErr.Error()})
|
|
hasError = true
|
|
continue
|
|
}
|
|
|
|
var res lintResult
|
|
res.File = zf.Name
|
|
|
|
// Записываем во временный файл в /dev/shm (RAM) если доступен, иначе os.TempDir()
|
|
tmpDir := "/dev/shm"
|
|
if _, statErr := os.Stat(tmpDir); statErr != nil {
|
|
tmpDir = ""
|
|
}
|
|
tmpf, tmpErr := os.CreateTemp(tmpDir, "fission-lint-*"+ext)
|
|
if tmpErr != nil {
|
|
res.OK = false
|
|
res.Output = "не удалось создать tmp: " + tmpErr.Error()
|
|
results = append(results, res)
|
|
hasError = true
|
|
continue
|
|
}
|
|
tmpName := tmpf.Name()
|
|
_, writeErr := tmpf.Write(content.Bytes())
|
|
tmpf.Close()
|
|
|
|
if writeErr != nil {
|
|
os.Remove(tmpName)
|
|
res.OK = false
|
|
res.Output = "ошибка записи tmp: " + writeErr.Error()
|
|
results = append(results, res)
|
|
hasError = true
|
|
continue
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
args := append(cfg.cmd, tmpName) //nolint:gocritic — намеренно создаём новый слайс
|
|
//nolint:gosec — cmd содержит только захардкоженные команды из linters
|
|
out, lintErr := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
|
|
cancel()
|
|
os.Remove(tmpName)
|
|
|
|
outStr := strings.TrimSpace(string(out))
|
|
// Скрываем путь к tmp-файлу от пользователя
|
|
outStr = strings.ReplaceAll(outStr, tmpName, zf.Name)
|
|
|
|
if lintErr == nil {
|
|
res.OK = true
|
|
} else {
|
|
res.OK = false
|
|
hasError = true
|
|
if outStr != "" {
|
|
res.Output = outStr
|
|
} else {
|
|
res.Output = fmt.Sprintf("ошибка синтаксиса (%v)", lintErr)
|
|
}
|
|
}
|
|
results = append(results, res)
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
writeJSONError(w, http.StatusBadRequest, "архив не содержит файлов поддерживаемых языков (.py, .js, .rb, .php)")
|
|
return
|
|
}
|
|
|
|
// Проверяем entrypoint: формат "module.function" → файл "module.py" должен быть в архиве
|
|
if entrypoint != "" {
|
|
parts := strings.SplitN(entrypoint, ".", 2)
|
|
if len(parts) == 2 {
|
|
module := parts[0]
|
|
funcName := parts[1]
|
|
// Ищем файл module.py в архиве (поддерживаем вложенные пути)
|
|
foundFile := false
|
|
funcDefined := false
|
|
for _, zf := range zr.File {
|
|
base := strings.TrimSuffix(filepath.Base(zf.Name), ".py")
|
|
if base == module && strings.HasSuffix(zf.Name, ".py") {
|
|
foundFile = true
|
|
// Читаем содержимое и ищем определение функции
|
|
rc, openErr := zf.Open()
|
|
if openErr == nil {
|
|
var content bytes.Buffer
|
|
content.ReadFrom(rc) //nolint:errcheck
|
|
rc.Close()
|
|
for _, line := range strings.Split(content.String(), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "def "+funcName+"(") || trimmed == "def "+funcName+":" {
|
|
funcDefined = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !foundFile {
|
|
results = append(results, lintResult{
|
|
File: entrypoint,
|
|
OK: false,
|
|
Output: fmt.Sprintf("файл '%s.py' не найден в архиве — проверьте Entrypoint", module),
|
|
})
|
|
hasError = true
|
|
} else if !funcDefined {
|
|
results = append(results, lintResult{
|
|
File: entrypoint,
|
|
OK: false,
|
|
Output: fmt.Sprintf("функция '%s' не найдена в '%s.py' — проверьте Entrypoint", funcName, module),
|
|
})
|
|
hasError = true
|
|
} else {
|
|
results = append(results, lintResult{
|
|
File: entrypoint,
|
|
OK: true,
|
|
Output: fmt.Sprintf("entrypoint '%s' найден", entrypoint),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": !hasError,
|
|
"results": results,
|
|
})
|
|
}
|