package credential import ( "strings" "testing" "unicode/utf8" ) // Набор символов логина администратора закреплён ФАКТИЧЕСКИМ множеством. // // Прежняя запись класса `[a-zA-Z0-9!@#$%^&*()_+-=]` содержала неэкранированный // дефис, из-за чего `+-=` образовывал диапазон и впускал `, - . / 0-9 : ; < =`. // Действующая запись перечисляет эти символы явно и НЕ сужает множество: имя // администратора приходит из HY2XS_ADMIN_USER в hy2xs.env, и сужение правила // означало бы, что установка с логином вроде `admin.ops` перестаёт пускать // оператора в панель. // // Тест существует, чтобы это решение было явным: попытка «навести порядок» в // классе символов уронит его, а не вход администратора на живом сервере. func TestAdminUsernameCharsetIsUnchanged(t *testing.T) { const historical = "abcXYZ019" + "!@#$%^&*()_" + "+,-./:;<=" for _, symbol := range strings.Split(historical, "") { candidate := "admin" + symbol if !IsValidAdminUsername(candidate) { t.Errorf("символ %q больше не принимается логином: сужение набора ломает вход существующей установки", symbol) } } for _, rejected := range []string{ "", // не задано "admi", // короче шести символов strings.Repeat("a", 33), // длиннее тридцати двух "admin пробел", // пробел "админ1", // кириллица "admin\n1", // перевод строки "admin'1", // апостроф вне набора } { if IsValidAdminUsername(rejected) { t.Errorf("значение %q принято логином, ожидался отказ", rejected) } } } // Границы длины логина принадлежат ОДНОМУ правилу, и человекочитаемый набор // обязан описывать тот же класс, что и регексп. // // Расхождение здесь тихо превращает сообщение об отказе в дезинформацию: // оператор читает набор, которого правило не принимает. func TestAdminUsernameBoundsAgreeWithRule(t *testing.T) { if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength-1)) { t.Errorf("логин длиной %d принят при минимуме %d", AdminUsernameMinLength-1, AdminUsernameMinLength) } if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMinLength)) { t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMinLength) } if !IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength)) { t.Errorf("логин длиной ровно %d отклонён", AdminUsernameMaxLength) } if IsValidAdminUsername(strings.Repeat("a", AdminUsernameMaxLength+1)) { t.Errorf("логин длиной %d принят при максимуме %d", AdminUsernameMaxLength+1, AdminUsernameMaxLength) } // Человекочитаемый набор перечисляет ровно те же знаки препинания, что и // класс регекспа. Пробелы в нём — разделители групп, а не допустимый символ. punctuation := strings.Fields(AdminUsernameCharset) for _, symbol := range strings.Split(punctuation[len(punctuation)-1], "") { if !IsValidAdminUsername("admin" + symbol) { t.Errorf("набор из сообщения обещает символ %q, но правило его не принимает", symbol) } } } // Длина пароля считается В СИМВОЛАХ, а предел bcrypt — В БАЙТАХ, и это разные // границы. // // Здесь был дефект. Верхняя граница в 64 символа объявлялась «заведомо ниже 72 // байт», и это верно ТОЛЬКО для ASCII. Тест, закреплявший поведение, вдобавок // требовал приёма 64 кириллических букв — то есть 128 байт — и тем самым // фиксировал как ожидаемое ровно то значение, на котором продукт ломался: // оркестратор его пропускал, DTO пропускало, а bcrypt отвечал // ErrPasswordTooLong уже при создании учётной записи. func TestAdminPasswordBoundaries(t *testing.T) { // Проверка самой таблицы: если «я» перестанет быть двухбайтовой, а «😀» — // четырёхбайтовым, случаи ниже проверяли бы не то, что написано. if got := len(strings.Repeat("я", 36)); got != AdminPasswordMaxBytes { t.Fatalf("подготовка: 36 x «я» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes) } if got := len(strings.Repeat("😀", 18)); got != AdminPasswordMaxBytes { t.Fatalf("подготовка: 18 x «😀» = %d байт, ожидалось %d", got, AdminPasswordMaxBytes) } cases := []struct { name string password string accepted bool }{ {"ровно минимум", strings.Repeat("a", AdminPasswordMinLength), true}, {"на символ короче минимума", strings.Repeat("a", AdminPasswordMinLength-1), false}, {"ровно максимум в ASCII", strings.Repeat("a", AdminPasswordMaxLength), true}, {"на символ длиннее максимума", strings.Repeat("a", AdminPasswordMaxLength+1), false}, // Граница bcrypt проходит ВНУТРИ разрешённого диапазона символов. {"36 x «я» = 72 байта", strings.Repeat("я", 36), true}, {"37 x «я» = 74 байта", strings.Repeat("я", 37), false}, {"18 x «😀» = 72 байта", strings.Repeat("😀", 18), true}, {"19 x «😀» = 76 байт", strings.Repeat("😀", 19), false}, // Исходный дефект: 64 символа, 128 байт. Прежний тест требовал ПРИЁМА. {"64 x «я» = 128 байт", strings.Repeat("я", AdminPasswordMaxLength), false}, // Пробелы по краям — часть пароля, а не мусор ввода. {"пробел на конце", "abcde ", true}, {"пробел в начале", " abcde", true}, {"только пробелы", strings.Repeat(" ", AdminPasswordMinLength), true}, // Набор символов не ограничен ничем, кроме управляющих. {"кириллица", "пароль-администратора", true}, {"знаки препинания", `p@$$w0rd"\'#;`, true}, {"перевод строки", "abcde\n", false}, {"возврат каретки", "abcde\r", false}, {"табуляция", "abcde\t", false}, {"нулевой байт", "abcde\x00", false}, {"DEL", "abcde\x7f", false}, // C1: раньше документация обещала «без управляющих символов», а код // проверял только C0 и DEL — то есть обещание было шире проверки. {"C1 NEL", "abcde" + string(rune(0x85)), false}, {"C1 верхняя граница", "abcde" + string(rune(0x9F)), false}, // Домен systemd. Каждое из этих значений — шесть символов, ни одного // управляющего, укладывается в 72 байта, — и при этом делает // /etc/hy2xs/hy2xs.env НЕзагружаемым: `check_utf8ness_and_warn` // отвечает -EINVAL, и юнит hy2xs-admin не стартует. {"noncharacter U+FDD0", "abcde" + string(rune(0xFDD0)), false}, {"noncharacter U+FDEF", "abcde" + string(rune(0xFDEF)), false}, {"noncharacter U+FFFE", "abcde" + string(rune(0xFFFE)), false}, {"noncharacter U+FFFF", "abcde" + string(rune(0xFFFF)), false}, {"noncharacter U+1FFFF", "abcde" + string(rune(0x1FFFF)), false}, {"noncharacter U+10FFFF", "abcde" + string(rune(0x10FFFF)), false}, {"невалидный UTF-8", "abcde\xff", false}, // Соседи noncharacters приниматься обязаны: правило описывает ровно // множество systemd, а не «всё подозрительное рядом». {"U+FDCF рядом с диапазоном", "abcde" + string(rune(0xFDCF)), true}, {"U+FDF0 рядом с диапазоном", "abcde" + string(rune(0xFDF0)), true}, {"U+FFFD (replacement)", "abcde" + string(rune(0xFFFD)), true}, // U+FEFF отвергается документированным транспортным доменом systemd. {"U+FEFF", "abcde" + string(rune(0xFEFF)), false}, } for _, testCase := range cases { t.Run(testCase.name, func(t *testing.T) { if got := IsValidAdminPassword(testCase.password); got != testCase.accepted { t.Errorf( "IsValidAdminPassword = %v, ожидалось %v (%d символов, %d байт)", got, testCase.accepted, utf8.RuneCountInString(testCase.password), len(testCase.password), ) } }) } } // Предел в байтах не выводится из предела в символах, и обратное неверно тоже. // // Проверка защищает от «упрощения», при котором одну из двух констант объявят // производной от другой: у 64 символов длина от 64 до 256 байт. func TestAdminPasswordLimitsAreIndependent(t *testing.T) { if AdminPasswordMaxBytes != 72 { t.Fatalf( "предел bcrypt изменён на %d: GenerateFromPassword отвечает ErrPasswordTooLong строго при len > 72", AdminPasswordMaxBytes, ) } // Пароль, укладывающийся в символы и не укладывающийся в байты, обязан // существовать — иначе байтовая граница недостижима и ничего не проверяет. long := strings.Repeat("я", AdminPasswordMaxLength) if utf8.RuneCountInString(long) > AdminPasswordMaxLength { t.Fatal("подготовка: значение длиннее предела в символах, проверяется не та граница") } if IsValidAdminPassword(long) { t.Fatal("пароль в 128 байт принят: байтовая граница не проверяется") } } // Домен транспорта повторяет публично документированное множество systemd. // // Шире означало бы собственный запрет сверх публичного контракта; уже — что // значение проходит все двери продукта вопреки документации EnvironmentFile. // Второе и было дефектом: контракт знал про управляющие символы и не знал про // noncharacters. // // Границы взяты из `unichar_is_valid` (systemd, src/basic/utf8.c) и проверяются // вместе с соседями: правило обязано отвергать ровно диапазон, а не окрестность. func TestEnvTransportDomainMatchesDocumentedSystemdContract(t *testing.T) { rejected := []rune{ 0x0000, 0xFEFF, // NUL и BOM запрещены публичным контрактом 0xFDD0, 0xFDEF, // U+FDD0..U+FDEF 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF, } for _, r := range rejected { if IsEnvTransportableText(string(r)) { t.Errorf("U+%04X принят вопреки документированному контракту EnvironmentFile", r) } } accepted := []rune{ 0x0020, 0x007F, 0x0085, // управляющие формат НЕСЁТ; их запрещает политика продукта 0xFDCF, 0xFDF0, // соседи зарезервированного диапазона 0xFFFD, // replacement character — обычный символ 0x10FFFD, // последний scalar value, не noncharacter 0x1F600, // 😀 } for _, r := range accepted { if !IsEnvTransportableText(string(r)) { t.Errorf("U+%04X отвергнут: правило шире документированного множества systemd", r) } } // Невалидный UTF-8 — не «строка со странными символами», а байты, которые // systemd отвергает первой же проверкой. for _, invalid := range []string{"\xff", "\xc3", "abc\xed\xa0\x80"} { if IsEnvTransportableText(invalid) { t.Errorf("невалидный UTF-8 %q принят", invalid) } } } // Продуктовая политика и домен systemd — РАЗНЫЕ множества, и это видно. // // Пока они были склеены, невозможно было ни объяснить отказ, ни заметить, что // одно из двух правил отсутствует. func TestProductPolicyIsWiderThanTransportDomain(t *testing.T) { // Управляющие символы формат несёт — их запрещает HY2XS. for _, r := range []rune{0x0A, 0x0D, 0x09, 0x7F, 0x85, 0x9F} { password := "abcde" + string(r) if !IsEnvTransportableText(password) && r != 0x00 { t.Errorf("U+%04X: ожидалось, что формат его несёт", r) } if IsValidAdminPassword(password) { t.Errorf("U+%04X принят паролем вопреки политике продукта", r) } } if IsEnvTransportableText("abcde" + string(rune(0xFEFF))) { t.Error("U+FEFF принят вопреки документированному контракту EnvironmentFile") } } // Пароль НЕ триммится нигде, включая сам контракт. // // Пока `util.HashPassword` вёл собственную проверку через strings.TrimSpace, // значение `"abcde "` считалось корректным всеми дверями продукта и не могло // быть захешировано. func TestAdminPasswordKeepsSurroundingWhitespace(t *testing.T) { // Шесть символов, из которых последний — пробел. По длине без пробела это // пять символов, то есть прежняя проверка отказала бы. const password = "abcde " if utf8.RuneCountInString(strings.TrimSpace(password)) >= AdminPasswordMinLength { t.Fatal("подготовка: значение проходит и после тримминга, проверяется не то") } if !IsValidAdminPassword(password) { t.Error("пароль с краевым пробелом отклонён: пробел объявлен частью значения") } }