layout: непомещающееся целевое имя уводит задачу в review вместо failed

- предел длины компонента (255 байт) проверяется в BuildLinks до первой
  операции с ФС: ни каталога, ни ссылки при отказе не создаётся
- причина пустого предпросмотра считается на показе (ReviewData.PreviewError)
  и печатается в панели действий и в карточке Telegram: у задачи без
  записанной причины взять её больше неоткуда
This commit is contained in:
av
2026-08-10 12:16:44 +03:00
parent 1710e5a9d5
commit b9f0929d0c
31 changed files with 1511 additions and 17 deletions
+47
View File
@@ -6,6 +6,53 @@ import (
"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 знаки, схлопывает
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри