package layout import ( "errors" "os" "path/filepath" "strings" "testing" ) // countDirEntries считает всё, что появилось под корнем библиотеки: проверка // длины обязана отказать ДО первой операции с ФС, поэтому пусто — это часть // утверждения, а не гигиена. func countDirEntries(t *testing.T, root string) int { t.Helper() n := 0 err := filepath.WalkDir(root, func(p string, _ os.DirEntry, err error) error { if err != nil { return err } if p != root { n++ } return nil }) if err != nil { t.Fatal(err) } return n } // 2.1 Имя файла длиннее предела: отказ целиком, ни одного каталога на диске. func TestBuildLinks_FileNameTooLong(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "long/movie.mkv", "x") // Название впритык под папку, но имя файла = база + ".mkv". title := strings.Repeat("a", maxComponentBytes-len(" (1999)")) plan := Plan{ Type: Movie, Title: title, Year: 1999, Files: []PlanFile{{Src: src, Role: RoleMain}}, } links, err := f.l.BuildLinks(plan) if !errors.Is(err, ErrNameTooLong) { t.Fatalf("err = %v, want ErrNameTooLong", err) } if links != nil { t.Errorf("links = %v, want nil (отказ целиком)", links) } if n := countDirEntries(t, f.movies); n != 0 { t.Errorf("под movies появилось %d записей, ожидалось 0 (проверка до операций с ФС)", n) } } // 2.2 Папка тайтла длиннее предела, хотя имя файла бы поместилось. func TestBuildLinks_FolderNameTooLong(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "long/movie.mkv", "x") // База помещается, но provider-тег выталкивает папку за предел; имя файла // тега не несёт и остаётся коротким. tag := "tmdbid-693134" title := strings.Repeat("b", maxComponentBytes-len(" (1999)")-len(" [")-len(tag)-len("]")) plan := Plan{ Type: Movie, Title: title, Year: 1999, ProviderTag: tag, Files: []PlanFile{{Src: src, Role: RoleMain}}, } if _, err := f.l.BuildLinks(plan); err != nil { t.Fatalf("контроль: имя ровно в предел должно проходить, got %v", err) } plan.Title = title + "c" // +1 байт — папка перестаёт помещаться _, err := f.l.BuildLinks(plan) if !errors.Is(err, ErrNameTooLong) { t.Fatalf("err = %v, want ErrNameTooLong", err) } if n := countDirEntries(t, f.movies); n != 0 { t.Errorf("под movies появилось %d записей, ожидалось 0", n) } } // 2.3 Предел меряется в БАЙТАХ, а не в рунах: кириллица упирается вдвое раньше. // Заодно граница 255/256. func TestBuildLinks_LimitIsBytesNotRunes(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "long/movie.mkv", "x") build := func(title string) error { _, err := f.l.BuildLinks(Plan{ Type: Movie, Title: title, Files: []PlanFile{{Src: src, Role: RoleMain}}, }) return err } const ext = ".mkv" // Граница ровно на 255 байтах имени файла. fit := strings.Repeat("a", maxComponentBytes-len(ext)) if err := build(fit); err != nil { t.Fatalf("255 байт должны помещаться, got %v", err) } if err := build(fit + "a"); !errors.Is(err, ErrNameTooLong) { t.Fatalf("256 байт: err = %v, want ErrNameTooLong", err) } // Столько же ЗНАКОВ кириллицей — вдвое больше байтов, отказ. cyr := strings.Repeat("я", maxComponentBytes-len(ext)) if err := build(cyr); !errors.Is(err, ErrNameTooLong) { t.Fatalf("кириллица той же длины в знаках: err = %v, want ErrNameTooLong "+ "(предел меряется в байтах)", err) } // Граница кириллицей — тоже по байтам. 125 букв = 250 байт, плюс ".mkv" = 254: // помещается. Ещё одна буква даёт 256 — не помещается. cyrFit := strings.Repeat("я", (maxComponentBytes-len(ext))/2) if err := build(cyrFit); err != nil { t.Fatalf("254 байта кириллицей должны помещаться, got %v", err) } if err := build(cyrFit + "я"); !errors.Is(err, ErrNameTooLong) { t.Fatalf("256 байт кириллицей: err = %v, want ErrNameTooLong", err) } } // 2.4 Приоритет: путь и вне библиотеки, и слишком длинный → отказ называет // выход за библиотеку, а не длину. Иначе находка безопасности спрячется за // косметической причиной. func TestBuildLinks_OutsideLibraryBeatsTooLong(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "long/movie.mkv", "x") // Санитизация режет разделители, поэтому traversal через Title недостижим; // проверяем сам порядок на checkComponentLengths напрямую: путь вне корня // до неё не доходит, а внутри корня — доходит. outside := filepath.Join(filepath.Dir(f.movies), strings.Repeat("z", 300)) if underRoot(f.movies, outside) { t.Fatal("подготовка теста неверна: путь обязан быть вне корня") } _, err := f.l.BuildLinks(Plan{ Type: Movie, Title: strings.Repeat("z", 300), Files: []PlanFile{{Src: src, Role: RoleMain}}, }) if !errors.Is(err, ErrNameTooLong) { t.Fatalf("внутри корня длинное имя даёт ErrNameTooLong, got %v", err) } // Прямая сверка порядка в BuildLinks: underRoot стоит раньше и его отказ // формулируется своим текстом (см. layout.go). if err := checkComponentLengths(f.movies, outside); !errors.Is(err, ErrNameTooLong) { t.Fatalf("checkComponentLengths вне корня: %v", err) } } // 2.7 Название не усекается: на непомещающемся входе ссылок нет вовсе, а не // возвращена усечённая. Усечение схлопнуло бы два разных названия в один каталог. func TestBuildLinks_NoTruncation(t *testing.T) { f := newFixture(t) src := f.srcFile(t, "long/movie.mkv", "x") links, err := f.l.BuildLinks(Plan{ Type: Movie, Title: strings.Repeat("d", 400), Files: []PlanFile{{Src: src, Role: RoleMain}}, }) if err == nil { t.Fatalf("ожидался отказ, получено %d ссылок", len(links)) } if len(links) != 0 { t.Errorf("вернулось %d ссылок — усечение недопустимо", len(links)) } } // 2.8 Мерится финальный компонент, а не название: суффикс субтитров // дописывается после и обязан учитываться. func TestBuildLinks_SubtitleSuffixCounted(t *testing.T) { f := newFixture(t) video := f.srcFile(t, "long/ep.mkv", "x") sub := f.srcFile(t, "long/ep.ru.srt", "y") // База подобрана так, что видеофайл помещается, а субтитр с ".ru.forced.srt" — // уже нет: разница ровно в длине суффикса. const stem = " S01E02" title := strings.Repeat("e", maxComponentBytes-len(stem)-len(".mkv")) plan := Plan{ Type: Series, Title: title, Files: []PlanFile{ {Src: video, Role: RoleEpisode, Season: intp(1), Episode: intp(2)}, }, } if _, err := f.l.BuildLinks(plan); err != nil { t.Fatalf("видеофайл впритык должен проходить, got %v", err) } plan.Files = append(plan.Files, PlanFile{ Src: sub, Role: RoleSubtitle, Season: intp(1), Episode: intp(2), Lang: "ru", Flags: []string{"forced"}, }) if _, err := f.l.BuildLinks(plan); !errors.Is(err, ErrNameTooLong) { t.Fatalf("субтитр с суффиксом обязан упереться: err = %v", err) } } // 2.9 Вырожденные входы проверку не роняют. func TestCheckComponentLengths_Degenerate(t *testing.T) { root := "/srv/media/movies" cases := []string{ root + "/", root + "/ ", root + "/" + string([]byte{0xff, 0xfe, 0xfd}), // невалидный UTF-8 root + "/" + strings.Repeat("x", 1<<16), root, } for _, dst := range cases { // Требование одно: не паниковать и вернуть значение. _ = checkComponentLengths(root, dst) } if err := checkComponentLengths(root, root+"/"+strings.Repeat("x", 1<<16)); !errors.Is(err, ErrNameTooLong) { t.Error("очень длинный компонент обязан давать ErrNameTooLong") } } // Корень библиотеки под проверку не попадает: его каталоги задаёт оператор. func TestCheckComponentLengths_RootNotChecked(t *testing.T) { root := "/srv/" + strings.Repeat("r", 300) if err := checkComponentLengths(root, root+"/Dune (2024)/Dune (2024).mkv"); err != nil { t.Errorf("длинный корень не должен считаться отказом: %v", err) } } // Нерасчислимый относительный путь (корень абсолютный, цель относительная) — // не отказ по длине, а отдельная ошибка: путать их нельзя, иначе диагноз соврёт. func TestCheckComponentLengths_UnrelatablePath(t *testing.T) { err := checkComponentLengths("/srv/media/movies", "relative/path.mkv") if err == nil { t.Fatal("want error for unrelatable path") } if errors.Is(err, ErrNameTooLong) { t.Errorf("нерасчислимый путь не должен выдаваться за отказ по длине: %v", err) } } // shorten держит текст причины коротким: имя в сообщении усечено серединой, а // точную длину несёт число рядом. Короткое имя не трогается. func TestShorten(t *testing.T) { short := strings.Repeat("a", errNameSample) if got := shorten(short); got != short { t.Errorf("имя в предел образца не должно меняться: %q", got) } long := strings.Repeat("я", 200) got := shorten(long) if r := []rune(got); len(r) != errNameSample+1 { // +1 — многоточие t.Errorf("длина образца = %d рун, want %d", len(r), errNameSample+1) } if !strings.Contains(got, "…") { t.Errorf("усечённое имя должно нести многоточие: %q", got) } // Режем по рунам: обрыв посреди буквы дал бы мусор вместо кириллицы. if strings.ContainsRune(got, '�') { t.Errorf("усечение разорвало руну: %q", got) } }