diff --git a/internal/httpapi/format_test.go b/internal/httpapi/format_test.go index 89d8261..e4f294d 100644 --- a/internal/httpapi/format_test.go +++ b/internal/httpapi/format_test.go @@ -140,3 +140,23 @@ func TestIndexCardMeta(t *testing.T) { t.Error("контекст всё ещё показан в карточке списка") } } + +// N2: shorten режет по рунам, не байтам — кириллица (2 байта/руна) не рвётся +// посреди символа в U+FFFD. +func TestShortenRuneSafe(t *testing.T) { + // 50 кириллических рун (100 байт). Обрезка до 40 рун раньше резала s[:40] + // посреди руны. + s := strings.Repeat("я", 50) + got := shorten(s, 40) + if strings.ContainsRune(got, '�') { + t.Errorf("обрезка порвала руну: %q", got) + } + // 40 рун + многоточие. + if want := strings.Repeat("я", 40) + "…"; got != want { + t.Errorf("shorten = %q, want %q", got, want) + } + // Короткая строка (по рунам) возвращается как есть, без многоточия. + if got := shorten("привет", 40); got != "привет" { + t.Errorf("короткая строка изменена: %q", got) + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index f57af2f..7512bd5 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -18,6 +18,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -724,11 +725,14 @@ func desyncNote(s store.State) string { } } +// shorten обрезает строку до n рун (не байт), добавляя многоточие. Рунобезопасно: +// кириллица (2 байта/руна) иначе резалась бы посреди руны в U+FFFD — частый случай +// для source_ref-заголовков. func shorten(s string, n int) string { - if len(s) <= n { + if utf8.RuneCountInString(s) <= n { return s } - return s[:n] + "…" + return string([]rune(s)[:n]) + "…" } // pathID валидирует {id} из URL как ULID и нормализует к lowercase — до @@ -761,7 +765,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) { // classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное // человекочитаемое сообщение публичного канала (без сырого err.Error() и // деталей реализации): ErrNotFound → 404; валидация источника -// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput) → +// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и +// некорректный ввод команды (worker.ErrInvalidInput) → // 400; недокачанный источник (worker.ErrNotReady), коллизия цели // (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее // → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только @@ -772,6 +777,9 @@ func classifyErr(err error) (int, string) { return http.StatusNotFound, "не найдено" case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent): return http.StatusBadRequest, "некорректный источник" + case errors.Is(err, ingest.ErrTorrentTooLarge): + // Промах ввода (файл больше лимита), не сбой сервера — 400, а не 500. + return http.StatusBadRequest, "файл .torrent слишком большой" case errors.Is(err, worker.ErrInvalidInput): // Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …), // не сбой сервера. diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index f5f735f..e390852 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -148,6 +148,22 @@ func TestAPIAddBadInput(t *testing.T) { } } +// F7: oversized `.torrent` — ingest.ErrTorrentTooLarge классифицируется как 400 +// (промах ввода), а не 500. +func TestAPIAddTorrentTooLarge(t *testing.T) { + ing := &fakeIngestor{err: fmt.Errorf("ingest: torrent too large: %w", ingest.ErrTorrentTooLarge)} + srv := newServer(t, httpapi.Deps{Ingestor: ing, Commander: &fakeCommander{}, Reader: &fakeReader{}}) + + resp, err := http.Post(srv.URL+"/api/downloads", "application/json", strings.NewReader(`{"source":"x"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + func TestAPIList(t *testing.T) { reader := &fakeReader{list: []store.Download{ {ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted, diff --git a/internal/ingest/ingest.go b/internal/ingest/ingest.go index 1b74bd1..f7bee5c 100644 --- a/internal/ingest/ingest.go +++ b/internal/ingest/ingest.go @@ -8,9 +8,11 @@ package ingest import ( "context" + "errors" "fmt" "log/slog" "strings" + "unicode/utf8" "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/magnet" @@ -89,17 +91,21 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { log := s.log.With("capability", capIngest, "infohash", src.infohashes[0]) ctx = logctx.With(ctx, log) - // Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2): + // Быстрый пред-рид по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2): // активная задача ЛИБО удерживающая источник desync-запись - // (target_missing/orphaned) блокируют повторный приём. Для активной - // авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже; - // desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не - // размыть инвариант «≤1 активной»). + // (target_missing/orphaned) блокируют повторный приём. Здесь короткозамыкаем + // ТОЛЬКО desync-запись (терминальную): её CreateDownloadIfNoActive не увидит + // (тот проверяет лишь активных), а состояния она не меняет. Активную же НЕ + // короткозамыкаем — этот чек без транзакции, и в гонке с параллельным cancel + // вернул бы stale «уже в работе» при пустом активном множестве. Авторитетное + // дедуп-решение по активной примет CreateDownloadIfNoActive под BEGIN IMMEDIATE. if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil { // Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR. log.Error("ingest failed", "stage", "lookup-blocking", "error", err) return Result{}, fmt.Errorf("ingest: lookup blocking: %w", err) - } else if existing != nil { + } else if existing != nil && existing.State.IsTerminal() { + // FindReingestBlockingByInfohash отдаёт терминальную запись только из + // удерживающих desync-состояний (target_missing/orphaned) — присоединяемся. log.Info("download attached", "download_id", existing.ID, "state", existing.State) return s.attached(ctx, src, existing), nil } @@ -111,7 +117,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { d := &store.Download{ SourceType: src.sourceType, SourceRef: src.sourceRef, - Context: mergeContext(req.Context, src.synthContext), + Context: capContext(mergeContext(req.Context, src.synthContext)), State: store.StateCatched, } // Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине. @@ -144,6 +150,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) { // файлов → много piece-хешей) отсекаются здесь. const MaxTorrentSize = 8 << 20 // 8 MiB +// ErrTorrentTooLarge — принятый `.torrent` превышает MaxTorrentSize. Это промах +// ввода пользователя (норма, не сбой сервера), поэтому транспорт транслирует его +// в 400, а не 500 (веб MaxBytesReader пропускает файлы чуть больше лимита — +// отсекает уже приём). Проверяется через errors.Is. +var ErrTorrentTooLarge = errors.New("torrent too large") + // parsedSource — нормализованный источник приёма (magnet или .torrent). type parsedSource struct { sourceType store.SourceType @@ -159,7 +171,7 @@ type parsedSource struct { func (s *Service) parse(req Request) (parsedSource, error) { if len(req.TorrentData) > 0 { if len(req.TorrentData) > MaxTorrentSize { - return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes", len(req.TorrentData), MaxTorrentSize) + return parsedSource{}, fmt.Errorf("ingest: torrent too large: %d > %d bytes: %w", len(req.TorrentData), MaxTorrentSize, ErrTorrentTooLarge) } info, err := torrent.Parse(req.TorrentData) if err != nil { @@ -195,6 +207,38 @@ func (s *Service) parse(req Request) (parsedSource, error) { }, nil } +// MaxContextSize — предел размера контекста распознавания (пользовательский текст +// + синтез из полей источника). Кап здесь, на единственном месте слияния, +// покрывает все транспорты: REST ограничен телом (64 KiB), Telegram — лимитом +// подписи, но веб-форма (multipart-бюджет на всё тело) иначе пропустила бы +// мегабайты в поле context → в БД, рендер карточки и LLM-промпты. +const MaxContextSize = 16 << 10 // 16 KiB + +// contextTruncMarker дописывается к усечённому контексту как явный маркер. +const contextTruncMarker = "\n…[контекст усечён]" + +// capContext ограничивает контекст MaxContextSize байтами, обрезая по границе +// руны (кириллица — 2 байта/руна; обрезка посреди руны дала бы U+FFFD) и добавляя +// маркер усечения. Пустой/короткий контекст возвращается как есть. +func capContext(s string) string { + if len(s) <= MaxContextSize { + return s + } + return trimToRune(s[:MaxContextSize]) + contextTruncMarker +} + +// trimToRune отбрасывает незавершённую многобайтовую руну на конце строки +// (результат обрезки по фиксированному числу байт), не трогая корректный хвост. +func trimToRune(s string) string { + for len(s) > 0 { + if r, size := utf8.DecodeLastRuneInString(s); r != utf8.RuneError || size > 1 { + break + } + s = s[:len(s)-1] + } + return s +} + // mergeContext склеивает контекст от транспорта с синтезом из полей magnet: // пользовательский текст идёт первым, затем факты из ссылки. Пустые части // опускаются; при пустых обеих — пустая строка (пустой контекст допустим). diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 590b292..f0bdf58 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -2,6 +2,7 @@ package ingest import ( "context" + "errors" "io" "log/slog" "strings" @@ -53,6 +54,30 @@ func (f *fakeStore) UpgradeCatchedMagnetToTorrent(_ context.Context, id string, return f.upgradeUp, nil } +// raceStore моделирует гонку F8: пред-рид FindReingestBlockingByInfohash видит +// активную запись (blocking), но create-гард CreateDownloadIfNoActive её уже не +// находит (в параллели отменена) и заводит свежую задачу. +type raceStore struct { + blocking *store.Download + created []store.Download +} + +func (r *raceStore) FindReingestBlockingByInfohash(_ context.Context, _ ...string) (*store.Download, error) { + return r.blocking, nil +} + +func (r *raceStore) CreateDownloadIfNoActive(_ context.Context, d *store.Download, _ []string, _ []byte) (*store.Download, error) { + d.ID = ident.NewID() + r.created = append(r.created, *d) + return nil, nil // активной уже нет — создаём новую +} + +func (r *raceStore) AddInfohashes(_ context.Context, _ string, _ []string) error { return nil } + +func (r *raceStore) UpgradeCatchedMagnetToTorrent(_ context.Context, _ string, _ []byte) (bool, error) { + return false, nil +} + func newService(st Store) *Service { return New(st, slog.New(slog.NewTextHandler(io.Discard, nil))) } @@ -207,6 +232,66 @@ func TestIngestDedupTopsUpHashes(t *testing.T) { } } +// F8: пред-рид FindReingestBlockingByInfohash увидел активную задачу, но к моменту +// создания она отменена (гонка с cancel). Активную запись пред-рид НЕ +// короткозамыкает — авторитетное дедуп-решение принимает CreateDownloadIfNoActive +// под BEGIN IMMEDIATE: активной больше нет → заводим свежую задачу, а не +// возвращаем stale Deduplicated «уже в работе». +func TestIngestActivePreReadNotShortCircuited(t *testing.T) { + stale := &store.Download{ID: "01hzzzstale00000000000000000", State: store.StateCatched} + fs := &raceStore{blocking: stale} // пред-рид видит активную; create-гард — уже нет + res, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet}) + if err != nil { + t.Fatalf("Ingest: %v", err) + } + if res.Deduplicated { + t.Errorf("активный пред-рид не должен коротко замыкать дедуп: %+v", res) + } + if res.DownloadID == stale.ID || res.State != store.StateCatched { + t.Errorf("ожидалась свежая задача, а не stale: %+v", res) + } + if len(fs.created) != 1 { + t.Errorf("должна быть создана новая задача, created=%d", len(fs.created)) + } +} + +// F7: oversized `.torrent` — доменная ошибка размера класса ErrTorrentTooLarge +// (транспорт транслирует в 400, а не 500). Задача не заводится. +func TestIngestRejectsOversizedTorrent(t *testing.T) { + fs := &fakeStore{} + big := make([]byte, MaxTorrentSize+1) + _, err := newService(fs).Ingest(context.Background(), Request{TorrentData: big}) + if !errors.Is(err, ErrTorrentTooLarge) { + t.Fatalf("err = %v, want ErrTorrentTooLarge", err) + } + if len(fs.created) != 0 { + t.Error("не должно быть записи задачи") + } +} + +// F10: контекст из веб-формы может быть огромным (multipart-бюджет на всё тело) — +// Ingest режет его до MaxContextSize по границе руны (без U+FFFD) и метит маркером. +func TestIngestCapsContext(t *testing.T) { + // «Ё» — 2 байта; ASCII-префикс сдвигает границу MaxContextSize на нечётный + // байт, чтобы обрезка s[:MaxContextSize] пришлась ВНУТРЬ двухбайтовой руны — + // тогда trimToRune реально срабатывает (иначе граница попадёт между рунами). + huge := "x" + strings.Repeat("Ё", MaxContextSize) + fs := &fakeStore{} + if _, err := newService(fs).Ingest(context.Background(), Request{Source: sampleMagnet, Context: huge}); err != nil { + t.Fatalf("Ingest: %v", err) + } + got := fs.created[0].Context + if len(got) > MaxContextSize+len(contextTruncMarker) { + t.Errorf("контекст не ограничен: %d байт", len(got)) + } + if !strings.HasSuffix(got, contextTruncMarker) { + t.Errorf("нет маркера усечения: …%q", got[max(0, len(got)-40):]) + } + if strings.ContainsRune(got, '�') { + t.Error("обрезка порвала руну (U+FFFD)") + } +} + func TestIngestRejectsNonMagnet(t *testing.T) { fs := &fakeStore{} if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil { diff --git a/internal/magnet/magnet.go b/internal/magnet/magnet.go index 46e920b..34cc407 100644 --- a/internal/magnet/magnet.go +++ b/internal/magnet/magnet.go @@ -30,6 +30,19 @@ type Info struct { // ErrNotMagnet возвращается, если строка не является magnet-ссылкой. var ErrNotMagnet = errors.New("not a magnet link") +// URN-префиксы xt-топиков. По RFC 2141 namespace-идентификатор URN +// регистронезависим, поэтому сверяем их EqualFold (magnet:?xt=URN:BTIH:… — +// валиден). Сам хеш нормализует по регистру normalizeBTIH/normalizeBTMH. +const ( + prefixBTIH = "urn:btih:" + prefixBTMH = "urn:btmh:" +) + +// hasURNPrefix сообщает, начинается ли xt с URN-префикса регистронезависимо. +func hasURNPrefix(xt, prefix string) bool { + return len(xt) >= len(prefix) && strings.EqualFold(xt[:len(prefix)], prefix) +} + // Parse разбирает magnet-ссылку. Поддерживаются btih (v1: 40-hex или // 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt // предпочитается v1. @@ -43,12 +56,12 @@ func Parse(raw string) (Info, error) { var v1, v2 string for _, xt := range vals["xt"] { switch { - case strings.HasPrefix(xt, "urn:btih:"): - if h, err := normalizeBTIH(strings.TrimPrefix(xt, "urn:btih:")); err == nil && v1 == "" { + case hasURNPrefix(xt, prefixBTIH): + if h, err := normalizeBTIH(xt[len(prefixBTIH):]); err == nil && v1 == "" { v1 = h } - case strings.HasPrefix(xt, "urn:btmh:"): - if h, err := normalizeBTMH(strings.TrimPrefix(xt, "urn:btmh:")); err == nil && v2 == "" { + case hasURNPrefix(xt, prefixBTMH): + if h, err := normalizeBTMH(xt[len(prefixBTMH):]); err == nil && v2 == "" { v2 = h } } diff --git a/internal/magnet/magnet_test.go b/internal/magnet/magnet_test.go index 12d6587..c045494 100644 --- a/internal/magnet/magnet_test.go +++ b/internal/magnet/magnet_test.go @@ -31,6 +31,13 @@ func TestParse(t *testing.T) { raw: "magnet:?xt=urn:btmh:12200123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", infohash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", }, + { + // RFC 2141: namespace URN регистронезависим — URN:BTIH: валиден. + name: "btih с URN-префиксом в верхнем регистре", + raw: "magnet:?xt=URN:BTIH:541ADCFF3B6DD5DBA7088EA83317D9D6FAC331D6&dn=Dune", + infohash: "541adcff3b6dd5dba7088ea83317d9d6fac331d6", + dn: "Dune", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/tgbot/parse.go b/internal/tgbot/parse.go index c314a41..1e6c1d0 100644 --- a/internal/tgbot/parse.go +++ b/internal/tgbot/parse.go @@ -13,6 +13,13 @@ var ( parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`) ) +// magnetTrailingPunct — хвостовая пунктуация, которую жадный magnetRe (`[^\s]+`) +// приклеивает к ссылке, когда magnet стоит в конце предложения или в скобках +// («…magnet:?xt=…).»). Такой хвост сдвигает последний xt и ломает разбор +// (длина btih становится 41 → отказ). TrimRight трактует cutset как множество +// рун, поэтому многобайтовая «»» отсекается корректно. +const magnetTrailingPunct = ".,;:)]}>»" + // noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём. var noisePrefixes = []string{ "Открыть magnet", "или получить .torrent", "Оценить", "Следить", @@ -29,6 +36,8 @@ func ParseMessage(text string) (source, context string, ok bool) { if m == "" { return "", "", false } + // Срезаем хвостовую пунктуацию, приклеенную жадным матчем (см. константу). + m = strings.TrimRight(m, magnetTrailingPunct) return m, cleanContext(text, m), true } diff --git a/internal/tgbot/parse_test.go b/internal/tgbot/parse_test.go index 19a15bd..d61dcce 100644 --- a/internal/tgbot/parse_test.go +++ b/internal/tgbot/parse_test.go @@ -75,3 +75,30 @@ func TestParseMessage_NoMagnet(t *testing.T) { t.Error("ожидалось ok=false без magnet") } } + +// F9: жадный magnetRe приклеивает хвостовую пунктуацию (magnet в конце +// предложения/в скобках) — ParseMessage её срезает, иначе последний xt сдвигается +// и длина btih ломается. +func TestParseMessage_TrimsTrailingPunct(t *testing.T) { + const want = "magnet:?xt=urn:btih:7931AA3ED6666746012F5739D099B5BC64D72A16" + cases := []struct { + name string + text string + }{ + {"точка", "Смотри: " + want + "."}, + {"скобка", "(" + want + ")"}, + {"кавычка-ёлочка", "«" + want + "»"}, + {"несколько", want + ").,"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src, _, ok := ParseMessage(tc.text) + if !ok { + t.Fatal("magnet не найден") + } + if src != want { + t.Errorf("src = %q, want %q", src, want) + } + }) + } +}