Приём/UI: пачка фиксов границ и парсинга (F7–F10, N2)
Пять независимых bugfix'ов из ревью приёма (docs/backlog/review-f7-f10-ingest-ui-fixes.md): - F7: oversized .torrent через веб отдавал 500. Введён sentinel ingest.ErrTorrentTooLarge, classifyErr транслирует его в 400. - F8: гонка fast-path attach с cancel. Пред-рид FindReingestBlockingByInfohash больше не короткозамыкает активную запись — авторитетное дедуп-решение принимает CreateDownloadIfNoActive под BEGIN IMMEDIATE; короткозамыкание оставлено только для терминальных desync-записей (target_missing/orphaned). F6-апгрейд сохранён. - F9: magnet — регистронезависимый URN-префикс xt (RFC 2141); tgbot.ParseMessage срезает хвостовую пунктуацию, приклеенную жадным matchем. - F10: cap контекста до 16 KiB в ingest.Ingest (единственное место слияния — покрывает все транспорты), рунобезопасная обрезка + маркер. - N2: httpapi.shorten режет по рунам, не байтам — кириллица не рвётся в U+FFFD. Добавлены юнит-тесты на каждое исправленное поведение. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -140,3 +140,23 @@ func TestIndexCardMeta(t *testing.T) {
|
|||||||
t.Error("контекст всё ещё показан в карточке списка")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"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 {
|
func shorten(s string, n int) string {
|
||||||
if len(s) <= n {
|
if utf8.RuneCountInString(s) <= n {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:n] + "…"
|
return string([]rune(s)[:n]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
// pathID валидирует {id} из URL как ULID и нормализует к lowercase — до
|
||||||
@@ -761,7 +765,8 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
// classifyErr транслирует доменную ошибку в HTTP-статус и нейтральное
|
||||||
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
// человекочитаемое сообщение публичного канала (без сырого err.Error() и
|
||||||
// деталей реализации): ErrNotFound → 404; валидация источника
|
// деталей реализации): ErrNotFound → 404; валидация источника
|
||||||
// (magnet.ErrNotMagnet) и некорректный ввод команды (worker.ErrInvalidInput) →
|
// (magnet.ErrNotMagnet), oversized `.torrent` (ingest.ErrTorrentTooLarge) и
|
||||||
|
// некорректный ввод команды (worker.ErrInvalidInput) →
|
||||||
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
// 400; недокачанный источник (worker.ErrNotReady), коллизия цели
|
||||||
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
// (layout.ErrCollision) и конфликт состояния (worker.ErrConflict) → 409; прочее
|
||||||
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
// → 500. Полная ошибка уже в логах на доменной границе — наружу отдаём только
|
||||||
@@ -772,6 +777,9 @@ func classifyErr(err error) (int, string) {
|
|||||||
return http.StatusNotFound, "не найдено"
|
return http.StatusNotFound, "не найдено"
|
||||||
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
case errors.Is(err, magnet.ErrNotMagnet), errors.Is(err, torrent.ErrNotTorrent):
|
||||||
return http.StatusBadRequest, "некорректный источник"
|
return http.StatusBadRequest, "некорректный источник"
|
||||||
|
case errors.Is(err, ingest.ErrTorrentTooLarge):
|
||||||
|
// Промах ввода (файл больше лимита), не сбой сервера — 400, а не 500.
|
||||||
|
return http.StatusBadRequest, "файл .torrent слишком большой"
|
||||||
case errors.Is(err, worker.ErrInvalidInput):
|
case errors.Is(err, worker.ErrInvalidInput):
|
||||||
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
|
// Промах пользователя (пустая подсказка, неизвестный тип/провайдер, …),
|
||||||
// не сбой сервера.
|
// не сбой сервера.
|
||||||
|
|||||||
@@ -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) {
|
func TestAPIList(t *testing.T) {
|
||||||
reader := &fakeReader{list: []store.Download{
|
reader := &fakeReader{list: []store.Download{
|
||||||
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
|
{ID: tid2, SourceType: store.SourceMagnet, State: store.StateCompleted,
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ package ingest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/magnet"
|
"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])
|
log := s.log.With("capability", capIngest, "infohash", src.infohashes[0])
|
||||||
ctx = logctx.With(ctx, log)
|
ctx = logctx.With(ctx, log)
|
||||||
|
|
||||||
// Быстрый дедуп-чек по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
|
// Быстрый пред-рид по ЛЮБОМУ из хешей источника (гибридный несёт и v1, и v2):
|
||||||
// активная задача ЛИБО удерживающая источник desync-запись
|
// активная задача ЛИБО удерживающая источник desync-запись
|
||||||
// (target_missing/orphaned) блокируют повторный приём. Для активной
|
// (target_missing/orphaned) блокируют повторный приём. Здесь короткозамыкаем
|
||||||
// авторитетная (атомарная) проверка — внутри CreateDownloadIfNoActive ниже;
|
// ТОЛЬКО desync-запись (терминальную): её CreateDownloadIfNoActive не увидит
|
||||||
// desync-ветка сюда и завершается (в active-гард desync не заводим, чтобы не
|
// (тот проверяет лишь активных), а состояния она не меняет. Активную же НЕ
|
||||||
// размыть инвариант «≤1 активной»).
|
// короткозамыкаем — этот чек без транзакции, и в гонке с параллельным cancel
|
||||||
|
// вернул бы stale «уже в работе» при пустом активном множестве. Авторитетное
|
||||||
|
// дедуп-решение по активной примет CreateDownloadIfNoActive под BEGIN IMMEDIATE.
|
||||||
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
|
if existing, err := s.store.FindReingestBlockingByInfohash(ctx, src.infohashes...); err != nil {
|
||||||
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
// Инфраструктурный сбой (БД) — операция приёма не выполнена: ERROR.
|
||||||
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
|
log.Error("ingest failed", "stage", "lookup-blocking", "error", err)
|
||||||
return Result{}, fmt.Errorf("ingest: lookup blocking: %w", 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)
|
log.Info("download attached", "download_id", existing.ID, "state", existing.State)
|
||||||
return s.attached(ctx, src, existing), nil
|
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{
|
d := &store.Download{
|
||||||
SourceType: src.sourceType,
|
SourceType: src.sourceType,
|
||||||
SourceRef: src.sourceRef,
|
SourceRef: src.sourceRef,
|
||||||
Context: mergeContext(req.Context, src.synthContext),
|
Context: capContext(mergeContext(req.Context, src.synthContext)),
|
||||||
State: store.StateCatched,
|
State: store.StateCatched,
|
||||||
}
|
}
|
||||||
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
// Все хеши источника (гибрид несёт v1 и v2); kind store выведет по длине.
|
||||||
@@ -144,6 +150,12 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
// файлов → много piece-хешей) отсекаются здесь.
|
// файлов → много piece-хешей) отсекаются здесь.
|
||||||
const MaxTorrentSize = 8 << 20 // 8 MiB
|
const MaxTorrentSize = 8 << 20 // 8 MiB
|
||||||
|
|
||||||
|
// ErrTorrentTooLarge — принятый `.torrent` превышает MaxTorrentSize. Это промах
|
||||||
|
// ввода пользователя (норма, не сбой сервера), поэтому транспорт транслирует его
|
||||||
|
// в 400, а не 500 (веб MaxBytesReader пропускает файлы чуть больше лимита —
|
||||||
|
// отсекает уже приём). Проверяется через errors.Is.
|
||||||
|
var ErrTorrentTooLarge = errors.New("torrent too large")
|
||||||
|
|
||||||
// parsedSource — нормализованный источник приёма (magnet или .torrent).
|
// parsedSource — нормализованный источник приёма (magnet или .torrent).
|
||||||
type parsedSource struct {
|
type parsedSource struct {
|
||||||
sourceType store.SourceType
|
sourceType store.SourceType
|
||||||
@@ -159,7 +171,7 @@ type parsedSource struct {
|
|||||||
func (s *Service) parse(req Request) (parsedSource, error) {
|
func (s *Service) parse(req Request) (parsedSource, error) {
|
||||||
if len(req.TorrentData) > 0 {
|
if len(req.TorrentData) > 0 {
|
||||||
if len(req.TorrentData) > MaxTorrentSize {
|
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)
|
info, err := torrent.Parse(req.TorrentData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,6 +207,38 @@ func (s *Service) parse(req Request) (parsedSource, error) {
|
|||||||
}, nil
|
}, 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:
|
// mergeContext склеивает контекст от транспорта с синтезом из полей magnet:
|
||||||
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
// пользовательский текст идёт первым, затем факты из ссылки. Пустые части
|
||||||
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
// опускаются; при пустых обеих — пустая строка (пустой контекст допустим).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ingest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -53,6 +54,30 @@ func (f *fakeStore) UpgradeCatchedMagnetToTorrent(_ context.Context, id string,
|
|||||||
return f.upgradeUp, nil
|
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 {
|
func newService(st Store) *Service {
|
||||||
return New(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
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) {
|
func TestIngestRejectsNonMagnet(t *testing.T) {
|
||||||
fs := &fakeStore{}
|
fs := &fakeStore{}
|
||||||
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
if _, err := newService(fs).Ingest(context.Background(), Request{Source: "https://example.com/x.torrent"}); err == nil {
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ type Info struct {
|
|||||||
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
|
// ErrNotMagnet возвращается, если строка не является magnet-ссылкой.
|
||||||
var ErrNotMagnet = errors.New("not a magnet link")
|
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 или
|
// Parse разбирает magnet-ссылку. Поддерживаются btih (v1: 40-hex или
|
||||||
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
|
// 32-символьный base32) и btmh (v2: sha256-multihash). При нескольких xt
|
||||||
// предпочитается v1.
|
// предпочитается v1.
|
||||||
@@ -43,12 +56,12 @@ func Parse(raw string) (Info, error) {
|
|||||||
var v1, v2 string
|
var v1, v2 string
|
||||||
for _, xt := range vals["xt"] {
|
for _, xt := range vals["xt"] {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(xt, "urn:btih:"):
|
case hasURNPrefix(xt, prefixBTIH):
|
||||||
if h, err := normalizeBTIH(strings.TrimPrefix(xt, "urn:btih:")); err == nil && v1 == "" {
|
if h, err := normalizeBTIH(xt[len(prefixBTIH):]); err == nil && v1 == "" {
|
||||||
v1 = h
|
v1 = h
|
||||||
}
|
}
|
||||||
case strings.HasPrefix(xt, "urn:btmh:"):
|
case hasURNPrefix(xt, prefixBTMH):
|
||||||
if h, err := normalizeBTMH(strings.TrimPrefix(xt, "urn:btmh:")); err == nil && v2 == "" {
|
if h, err := normalizeBTMH(xt[len(prefixBTMH):]); err == nil && v2 == "" {
|
||||||
v2 = h
|
v2 = h
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ func TestParse(t *testing.T) {
|
|||||||
raw: "magnet:?xt=urn:btmh:12200123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
raw: "magnet:?xt=urn:btmh:12200123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||||
infohash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
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 {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ var (
|
|||||||
parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
|
parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// magnetTrailingPunct — хвостовая пунктуация, которую жадный magnetRe (`[^\s]+`)
|
||||||
|
// приклеивает к ссылке, когда magnet стоит в конце предложения или в скобках
|
||||||
|
// («…magnet:?xt=…).»). Такой хвост сдвигает последний xt и ломает разбор
|
||||||
|
// (длина btih становится 41 → отказ). TrimRight трактует cutset как множество
|
||||||
|
// рун, поэтому многобайтовая «»» отсекается корректно.
|
||||||
|
const magnetTrailingPunct = ".,;:)]}>»"
|
||||||
|
|
||||||
// noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём.
|
// noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём.
|
||||||
var noisePrefixes = []string{
|
var noisePrefixes = []string{
|
||||||
"Открыть magnet", "или получить .torrent", "Оценить", "Следить",
|
"Открыть magnet", "или получить .torrent", "Оценить", "Следить",
|
||||||
@@ -29,6 +36,8 @@ func ParseMessage(text string) (source, context string, ok bool) {
|
|||||||
if m == "" {
|
if m == "" {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
// Срезаем хвостовую пунктуацию, приклеенную жадным матчем (см. константу).
|
||||||
|
m = strings.TrimRight(m, magnetTrailingPunct)
|
||||||
return m, cleanContext(text, m), true
|
return m, cleanContext(text, m), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,30 @@ func TestParseMessage_NoMagnet(t *testing.T) {
|
|||||||
t.Error("ожидалось ok=false без magnet")
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user