- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
172 lines
8.0 KiB
Go
172 lines
8.0 KiB
Go
package layout
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ
|
||
// UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое
|
||
// название упирается в предел вдвое раньше латинского той же длины в знаках.
|
||
// Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что
|
||
// раскладка обязана отказать до обращения к диску (см. spec file-layout).
|
||
// Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт
|
||
// проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы
|
||
// откажем строже, чем нужно. Лечение — правка этой константы, а не настройка:
|
||
// значение, которое некому выставить осознанно, не гибкость.
|
||
const maxComponentBytes = 255
|
||
|
||
// checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем
|
||
// root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт
|
||
// оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку,
|
||
// обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину.
|
||
// Чистая функция: к диску не обращается.
|
||
func checkComponentLengths(root, dst string) error {
|
||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst))
|
||
if err != nil {
|
||
return fmt.Errorf("layout: relative target %q: %w", dst, err)
|
||
}
|
||
for c := range strings.SplitSeq(rel, string(filepath.Separator)) {
|
||
if len(c) > maxComponentBytes {
|
||
return fmt.Errorf("%w: %q — %d байт при пределе %d",
|
||
ErrNameTooLong, shorten(c), len(c), maxComponentBytes)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// errNameSample — сколько рун непомещающегося имени показать в тексте ошибки.
|
||
// Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram:
|
||
// имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран.
|
||
// Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя.
|
||
const errNameSample = 40
|
||
|
||
// shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам:
|
||
// обрыв посреди многобайтовой буквы дал бы в сообщении мусор.
|
||
func shorten(s string) string {
|
||
r := []rune(s)
|
||
if len(r) <= errNameSample {
|
||
return s
|
||
}
|
||
head := errNameSample / 2
|
||
return string(r[:head]) + "…" + string(r[len(r)-head:])
|
||
}
|
||
|
||
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
|
||
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
|
||
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
|
||
// сохраняются. Результат гарантированно не содержит '/', '\\', ".." целиком
|
||
// и не пуст (иначе ошибка у вызывающего).
|
||
func sanitizeComponent(s string) string {
|
||
var b strings.Builder
|
||
for _, r := range s {
|
||
switch {
|
||
case r < 0x20 || r == 0x7f: // управляющие
|
||
b.WriteByte(' ')
|
||
case strings.ContainsRune(`/\:*?"<>|`, r): // разделители и недопустимые в SMB/NTFS
|
||
b.WriteByte(' ')
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
out := strings.Join(strings.Fields(b.String()), " ") // схлопнуть пробелы
|
||
out = strings.Trim(out, " .") // края: ни точек, ни пробелов
|
||
return out
|
||
}
|
||
|
||
// titleYear строит базу "Название (Год)" или "Название" при year == 0.
|
||
func titleYear(title string, year int) (string, error) {
|
||
t := sanitizeComponent(title)
|
||
if t == "" {
|
||
return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
|
||
}
|
||
if year > 0 {
|
||
return fmt.Sprintf("%s (%d)", t, year), nil
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
// planBase выбирает базу имени плана: унаследованная от живого якоря
|
||
// FolderBase (правило сходимости папки) перекрывает Title/Year. База в любом
|
||
// случае санитизируется (FolderBase пришла с диска — прогон идемпотентен).
|
||
func planBase(p Plan) (string, error) {
|
||
if p.FolderBase != "" {
|
||
b := sanitizeComponent(p.FolderBase)
|
||
if b == "" {
|
||
return "", fmt.Errorf("layout: empty folder base after sanitization (%q)", p.FolderBase)
|
||
}
|
||
return b, nil
|
||
}
|
||
return titleYear(p.Title, p.Year)
|
||
}
|
||
|
||
// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]".
|
||
func folderName(base, providerTag string) string {
|
||
tag := sanitizeComponent(providerTag)
|
||
if tag == "" {
|
||
return base
|
||
}
|
||
return fmt.Sprintf("%s [%s]", base, tag)
|
||
}
|
||
|
||
// folderBase восстанавливает базу имени ("Название (Год)") из имени папки
|
||
// тайтла, снимая хвостовой provider-тег " [...]" (любой, а не только текущий:
|
||
// у переоценённого якоря тег мог остаться старым). Тег без пробела перед "["
|
||
// или незакрытый — не трогаем. Результат санитизируется; пустой → "" (вызывающий
|
||
// трактует как «нет якоря»).
|
||
func folderBase(folder string) string {
|
||
base := folder
|
||
if i := strings.LastIndex(folder, " ["); i >= 0 && strings.HasSuffix(folder, "]") {
|
||
base = folder[:i]
|
||
}
|
||
return sanitizeComponent(base)
|
||
}
|
||
|
||
// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ...
|
||
func seasonFolder(season int) string {
|
||
return fmt.Sprintf("Season %02d", season)
|
||
}
|
||
|
||
// episodeStem — "Название (Год) S01E02"; при двойной серии episodeEnd>episode
|
||
// даёт "S01E02-E03".
|
||
func episodeStem(base string, season, episode, episodeEnd int) string {
|
||
if episodeEnd > episode {
|
||
return fmt.Sprintf("%s S%02dE%02d-E%02d", base, season, episode, episodeEnd)
|
||
}
|
||
return fmt.Sprintf("%s S%02dE%02d", base, season, episode)
|
||
}
|
||
|
||
// subtitleSuffix — ".ru", ".ru.forced" и т.п. (флаги после языка).
|
||
func subtitleSuffix(lang string, flags []string) string {
|
||
var b strings.Builder
|
||
if l := sanitizeComponent(lang); l != "" {
|
||
b.WriteByte('.')
|
||
b.WriteString(strings.ToLower(l))
|
||
}
|
||
for _, f := range flags {
|
||
if f = sanitizeComponent(f); f != "" {
|
||
b.WriteByte('.')
|
||
b.WriteString(strings.ToLower(f))
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// ext возвращает расширение файла источника в нижнем регистре (с точкой).
|
||
func ext(src string) string {
|
||
return strings.ToLower(filepath.Ext(src))
|
||
}
|
||
|
||
// underRoot сообщает, лежит ли clean-путь p строго под каталогом root
|
||
// (после filepath.Clean у обоих). Защита от traversal: даже если имя
|
||
// прошло санитизацию, итог обязан остаться внутри библиотеки.
|
||
func underRoot(root, p string) bool {
|
||
root = filepath.Clean(root)
|
||
p = filepath.Clean(p)
|
||
if p == root {
|
||
return false // сам корень — не цель для файла
|
||
}
|
||
return strings.HasPrefix(p, root+string(filepath.Separator))
|
||
}
|