Netpulse_SasS/server/internal/gitstore/mirror.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

624 lines
27 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Дзеркалення локального репозиторію на зовнішній Git.
//
// Що це таке в одному реченні: `git push --all` на Forgejo/Gitea,
// GitHub або GitLab, зроблений чистим Go (go-git), без виклику
// системного git і без cgo — образ і далі збирається без C-тулчейна.
//
// Три рішення, які тут зафіксовані.
//
// ПЕРШЕ: push ніколи не входить у шлях збору конфігу. Цей файл не
// викликається ані зі StoreConfig, ані з диспетчера завдань. Його
// смикає окремий фоновий такт (grpcapi/ncm_mirror.go), і недоступний
// віддалений сервер коштує рівно нічого — бекап уже ліг у базу й у
// локальний Git до того, як хтось згадав про дзеркало.
//
// ДРУГЕ: без -f. Refspec навмисно без плюса, тобто push або
// перемотується вперед, або відмовляється. Дзеркало заводять як копію
// на випадок втрати; механізм, який уміє мовчки переписати віддалену
// історію, — це не копія, а другий спосіб її втратити. Перезапис
// існує, але окремою свідомою дією людини (Remote.Force).
//
// ТРЕТЄ: ключ SSH-сервера звіряється завжди. Для мережевого заліза в
// цьому продукті ухвалено протилежне (agent/internal/ncmx/transport.go),
// і там воно правильне: сотня коробок, кожна перегенеровує ключ після
// прошивки. Тут один сервер, і везе він за периметр архів конфігів із
// ключами та хешами паролів. Довіритись першому, хто відповів на порту
// 22, означало б віддати цей архів тому, хто перехопив DNS.
package gitstore
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"github.com/go-git/go-git/v5/storage/memory"
cssh "golang.org/x/crypto/ssh"
)
// Remote — куди й чим пушити.
//
// Secret ніколи не потрапляє ні в структуру відповіді API, ні в журнал,
// ні в текст помилки: усе, що йде назовні з цього файлу, проходить
// через Scrub.
type Remote struct {
URL string
// Auth: "https" — токен у Secret; "ssh" — приватний ключ у Secret;
// "none" — анонімно (буває лише для перевірки читання).
Auth string
User string
Secret string
HostKey string
// Force дозволяє перезаписати віддалену гілку, яка розійшлася з
// локальною. Ніколи не вмикається само: див. коментар до пакета.
Force bool
Timeout time.Duration
}
const (
AuthNone = "none"
AuthHTTPS = "https"
AuthSSH = "ssh"
)
// PushStat — підсумок такту дзеркалення.
type PushStat struct {
// Refs — скільки гілок поїхало (або вже там було).
Refs int
// StateHash — відбиток локальних посилань на момент push. Поки він
// не змінився, у мережу ходити немає по що.
StateHash string
UpToDate bool
Took time.Duration
}
// ProbeStat — що видно на тому кінці до будь-якого запису.
type ProbeStat struct {
// Refs — скільки посилань уже є у віддаленому репозиторії. Нуль —
// порожній репозиторій, найчастіший і найпростіший випадок.
Refs int
Empty bool
// HostKey — ключ, який показав SSH-сервер. Порожній для HTTPS.
HostKey string
// HostKeyKnown — чи збігся він із закріпленим.
HostKeyKnown bool
}
var (
ErrNoRepo = errors.New("локального репозиторію ще немає — жодного конфігу не зібрано")
ErrHostKeyUnset = errors.New("ключ SSH-сервера не закріплено: спершу виконайте перевірку з'єднання")
ErrDiverged = errors.New("віддалене дерево розійшлося з локальним")
ErrNoRemoteURL = errors.New("не задано адресу віддаленого репозиторію")
ErrPlainHTTP = errors.New("http дозволено лише до адрес усередині мережі")
ErrBadRemoteURL = errors.New("адресу репозиторію не розібрано")
ErrUnsupportedTr = errors.New("підтримуються лише https:// і ssh:// (або git@хост:шлях)")
)
// mirrorRefSpec — усі локальні гілки в однойменні віддалені.
//
// Шаблон, а не перелік. Це і є відповідь на питання «що дзеркалити»:
// не «конфіги», а «все, що лежить у репозиторії тенанта». Гілка
// device/<uuid> сьогодні одна за видом, але наступний вид даних, який
// хтось покладе в цей самий репозиторій, поїде без жодної правки тут —
// саме тому перелік гілок не зашитий.
//
// refs/heads/*, а не refs/*: службові простори (refs/notes,
// refs/pull/*, які створює сам Forgejo) дзеркало не чіпає.
const mirrorRefSpec = "refs/heads/*:refs/heads/*"
// Mirror пушить усі гілки репозиторію тенанта на віддалений сервер.
//
// Блокування репозиторію НЕ береться. Push читає об'єкти й посилання,
// а lockFor охороняє запис; тримати його на весь час мережевої операції
// означало б, що повільний Forgejo зупиняє нічний збір конфігів — рівно
// та залежність, якої весь цей модуль уникає.
func (s *Store) Mirror(ctx context.Context, repoName string, r Remote) (PushStat, error) {
var stat PushStat
if s.root == "" {
return stat, ErrDisabled
}
started := time.Now()
full := filepath.Join(s.root, repoName)
repo, err := git.PlainOpen(full)
if err != nil {
if errors.Is(err, git.ErrRepositoryNotExists) {
return stat, ErrNoRepo
}
return stat, fmt.Errorf("gitstore: відкриття %s: %w", repoName, err)
}
refs, hash, err := localRefs(repo)
if err != nil {
return stat, err
}
stat.Refs, stat.StateHash = len(refs), hash
if len(refs) == 0 {
// Порожній репозиторій пушити нічим, і це не помилка: так
// виглядає інсталяція, де дзеркало ввімкнули до першого бекапу.
stat.UpToDate = true
stat.Took = time.Since(started)
return stat, nil
}
auth, err := r.authMethod()
if err != nil {
return stat, err
}
spec := mirrorRefSpec
if r.Force {
spec = "+" + spec
}
remote := git.NewRemote(repo.Storer, &config.RemoteConfig{
Name: "mirror",
URLs: []string{r.URL},
})
ctx, cancel := context.WithTimeout(ctx, r.timeout())
defer cancel()
err = remote.PushContext(ctx, &git.PushOptions{
RemoteName: "mirror",
RefSpecs: []config.RefSpec{config.RefSpec(spec)},
Auth: auth,
Force: r.Force,
// Atomic: віддалений сервер або приймає весь набір гілок, або
// жодної. Наполовину доїхане дзеркало виглядає як ціле — і саме
// так його й прочитають у момент, коли воно знадобиться.
Atomic: true,
})
stat.Took = time.Since(started)
switch {
case err == nil:
case errors.Is(err, git.NoErrAlreadyUpToDate):
stat.UpToDate = true
default:
return stat, r.explain(err)
}
return stat, nil
}
// LocalState — відбиток посилань локального репозиторію.
//
// Потрібен фоновому такту, щоб не ходити в мережу, коли нічого не
// змінилось: дзеркалення не має бути постійним трафіком на чужий
// сервер, а нічний збір міняє репозиторій раз на добу.
func (s *Store) LocalState(repoName string) (refs int, hash string, err error) {
if s.root == "" {
return 0, "", ErrDisabled
}
repo, err := git.PlainOpen(filepath.Join(s.root, repoName))
if err != nil {
if errors.Is(err, git.ErrRepositoryNotExists) {
return 0, "", ErrNoRepo
}
return 0, "", err
}
list, h, err := localRefs(repo)
return len(list), h, err
}
// localRefs повертає гілки й відбиток їхнього стану.
func localRefs(repo *git.Repository) ([]string, string, error) {
iter, err := repo.References()
if err != nil {
return nil, "", err
}
defer iter.Close()
var lines []string
err = iter.ForEach(func(ref *plumbing.Reference) error {
if ref.Type() != plumbing.HashReference || !ref.Name().IsBranch() {
return nil
}
lines = append(lines, ref.Name().String()+" "+ref.Hash().String())
return nil
})
if err != nil {
return nil, "", err
}
// Порядок обходу посилань не гарантований, а відбиток мусить
// залежати лише від вмісту: інакше такт «бачив би зміну» щоразу й
// ходив би в мережу кожну хвилину.
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return lines, hex.EncodeToString(sum[:]), nil
}
// Probe перевіряє з'єднання, не змінюючи нічого — ні тут, ні там.
//
// Окрема дія існує через дуже конкретний сценарій: людина вводить
// адресу й токен і має дізнатись про помилку зараз, а не завтра з
// журналу. Читання посилань (ls-remote) для цього достатньо: воно
// проходить ту саму автентифікацію, що й push.
//
// Що воно НЕ доводить: право на запис. Токен «лише читання» проходить
// перевірку й падає на першому push. Сказати про це чесно дешевше, ніж
// створювати сміттєву гілку заради доведення.
func Probe(ctx context.Context, r Remote) (ProbeStat, error) {
var stat ProbeStat
if strings.TrimSpace(r.URL) == "" {
return stat, ErrNoRemoteURL
}
if err := ValidateRemoteURL(r.URL); err != nil {
return stat, err
}
// Для SSH спершу дивимось на ключ сервера: людині треба показати
// його ДО того, як вона щось збереже, інакше закріплювати нічого.
if r.transportKind() == AuthSSH {
key, err := ScanHostKey(ctx, r.URL, r.timeout())
if err != nil {
return stat, Scrub(err, r.Secret)
}
stat.HostKey = key
stat.HostKeyKnown = r.HostKey != "" && sameHostKey(r.HostKey, key)
if r.HostKey == "" {
// Перший контакт: ключ показано, закріплення — за людиною.
// Пробувати автентифікуватись до нього нема сенсу, бо саме
// цей крок вона ще не підтвердила.
return stat, nil
}
if !stat.HostKeyKnown {
return stat, fmt.Errorf(
"ключ сервера змінився: закріплено %s, показано %s",
Fingerprint(r.HostKey), Fingerprint(key))
}
}
auth, err := r.authMethod()
if err != nil {
return stat, err
}
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
Name: "probe",
URLs: []string{r.URL},
})
ctx, cancel := context.WithTimeout(ctx, r.timeout())
defer cancel()
refs, err := remote.ListContext(ctx, &git.ListOptions{Auth: auth})
switch {
case err == nil:
stat.Refs = len(refs)
case errors.Is(err, transport.ErrEmptyRemoteRepository):
// Свіжостворений репозиторій у Forgejo. Найчастіший і
// найкращий випадок — саме в такий і треба пушити.
stat.Empty = true
default:
return stat, r.explain(err)
}
return stat, nil
}
// ScanHostKey відкриває TCP-з'єднання й повертає ключ, який показує
// SSH-сервер, у форматі authorized_keys.
//
// Аналог `ssh-keyscan`. Ключ не приймається автоматично: він
// повертається людині, і закріплює його вона, натиснувши «зберегти».
// Автоматичне TOFU виглядало б так само зручно й нічого б не
// перевіряло — довіра тоді дістається тому, хто відповів першим.
func ScanHostKey(ctx context.Context, rawURL string, timeout time.Duration) (string, error) {
ep, err := transport.NewEndpoint(rawURL)
if err != nil {
return "", ErrBadRemoteURL
}
port := ep.Port
if port == 0 {
port = 22
}
addr := net.JoinHostPort(ep.Host, fmt.Sprint(port))
if timeout <= 0 {
timeout = 15 * time.Second
}
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return "", fmt.Errorf("з'єднання з %s: %w", addr, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
var seen cssh.PublicKey
cfg := &cssh.ClientConfig{
User: "netpulse-keyscan",
HostKeyCallback: func(_ string, _ net.Addr, key cssh.PublicKey) error {
seen = key
return nil
},
Timeout: timeout,
}
// Рукостискання свідомо доводиться до відмови в автентифікації:
// ключ сервера показується ДО неї, а користувача з таким іменем на
// сервері немає й не має бути.
c, _, _, err := cssh.NewClientConn(conn, addr, cfg)
if c != nil {
_ = c.Close()
}
if seen == nil {
if err != nil {
return "", fmt.Errorf("не вдалося прочитати ключ %s: %w", addr, err)
}
return "", fmt.Errorf("сервер %s не показав ключа", addr)
}
return strings.TrimSpace(string(cssh.MarshalAuthorizedKey(seen))), nil
}
// Fingerprint — короткий відбиток ключа для показу людині.
func Fingerprint(authorizedKey string) string {
pub, _, _, _, err := cssh.ParseAuthorizedKey([]byte(authorizedKey))
if err != nil {
return "невідомий ключ"
}
return pub.Type() + " " + cssh.FingerprintSHA256(pub)
}
func sameHostKey(a, b string) bool {
pa, _, _, _, ea := cssh.ParseAuthorizedKey([]byte(a))
pb, _, _, _, eb := cssh.ParseAuthorizedKey([]byte(b))
if ea != nil || eb != nil {
return false
}
return subtle.ConstantTimeCompare(pa.Marshal(), pb.Marshal()) == 1
}
// ValidateRemoteURL відсіює адреси, якими дзеркало бути не може.
//
// file:// і локальні шляхи заборонені окремо й навмисно: адресу задає
// користувач тенанта, а процес сервера має право писати на свій диск.
// «Дзеркало» в /var/lib було б не копією за периметром, а способом
// покласти дані туди, куди попросили.
func ValidateRemoteURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return ErrNoRemoteURL
}
ep, err := transport.NewEndpoint(raw)
if err != nil {
return ErrBadRemoteURL
}
switch ep.Protocol {
case "https", "ssh":
return nil
case "http":
// Конфіги везуть community-рядки, ключі й хеші паролів. Віддати
// їх у відкритому вигляді через інтернет не можна; всередині
// своєї мережі — питання власника мережі, і саме там стоїть
// більшість самостійно піднятих Forgejo.
host := ep.Host
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("не вдалося розв'язати %s", host)
}
for _, ip := range ips {
if !(ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast()) {
return ErrPlainHTTP
}
}
return nil
default:
return ErrUnsupportedTr
}
}
// --- автентифікація ---------------------------------------------------
func (r Remote) transportKind() string {
ep, err := transport.NewEndpoint(r.URL)
if err != nil {
return AuthNone
}
if ep.Protocol == "ssh" {
return AuthSSH
}
return AuthHTTPS
}
func (r Remote) timeout() time.Duration {
if r.Timeout > 0 {
return r.Timeout
}
// Півгодини. Перший push інсталяції, яка рік збирала конфіги, —
// це вся історія за раз: на стенді один ZTE дає 32 тисячі рядків
// на версію, і сотня хостів за рік — гігабайти. Хвилинний таймаут
// зробив би перше вмикання неможливим саме там, де воно найпотрібніше.
return 30 * time.Minute
}
func (r Remote) authMethod() (transport.AuthMethod, error) {
switch r.transportKind() {
case AuthSSH:
if strings.TrimSpace(r.Secret) == "" {
return nil, errors.New("для SSH потрібен приватний ключ")
}
if strings.TrimSpace(r.HostKey) == "" {
return nil, ErrHostKeyUnset
}
user := r.User
if user == "" {
if ep, err := transport.NewEndpoint(r.URL); err == nil && ep.User != "" {
user = ep.User
}
}
if user == "" {
user = "git"
}
keys, err := gitssh.NewPublicKeys(user, []byte(r.Secret), "")
if err != nil {
// Текст go-ssh про розбір ключа не містить самого ключа,
// але Scrub тут стоїть як правило, а не як виняток.
return nil, Scrub(fmt.Errorf("приватний ключ: %w", err), r.Secret)
}
cb, algos, err := pinnedHostKey(r.HostKey)
if err != nil {
return nil, err
}
keys.HostKeyCallback = cb
keys.HostKeyAlgorithms = algos
return keys, nil
default:
if strings.TrimSpace(r.Secret) == "" {
return nil, nil
}
user := r.User
if user == "" {
if ep, err := transport.NewEndpoint(r.URL); err == nil && ep.User != "" {
user = ep.User
}
}
if user == "" {
// GitHub і Forgejo приймають будь-який непорожній логін,
// коли пароль — токен. Порожній логін вони відхиляють, і
// повідомлення при цьому нічого не пояснює.
user = "netpulse"
}
return &githttp.BasicAuth{Username: user, Password: r.Secret}, nil
}
}
// pinnedHostKey — перевірка ключа сервера за закріпленим значенням.
func pinnedHostKey(line string) (cssh.HostKeyCallback, []string, error) {
pub, _, _, _, err := cssh.ParseAuthorizedKey([]byte(line))
if err != nil {
return nil, nil, fmt.Errorf("закріплений ключ сервера не розібрано: %w", err)
}
want := pub.Marshal()
algos := []string{pub.Type()}
if pub.Type() == cssh.KeyAlgoRSA {
// Сервер із RSA-ключем підписує рукостискання алгоритмом
// rsa-sha2-*; сам ключ при цьому той самий. Без цього переліку
// сучасний OpenSSH просто не запропонував би закріплений ключ.
algos = []string{cssh.KeyAlgoRSASHA512, cssh.KeyAlgoRSASHA256, cssh.KeyAlgoRSA}
}
return func(hostname string, _ net.Addr, key cssh.PublicKey) error {
if subtle.ConstantTimeCompare(key.Marshal(), want) == 1 {
return nil
}
return fmt.Errorf("ключ сервера %s не збігається із закріпленим: показано %s, очікували %s",
hostname, cssh.FingerprintSHA256(key), cssh.FingerprintSHA256(pub))
}, algos, nil
}
// --- помилки ----------------------------------------------------------
// userInfoRe знаходить «схема://щось@» — саме туди go-git і http.Client
// вкладають логін із токеном, коли адресу ввели у вигляді
// https://user:token@forgejo.example/repo.git.
var userInfoRe = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.\-]*://)[^/@\s]*@`)
// Scrub прибирає з тексту помилки все, що не має потрапити ні в журнал,
// ні в базу, ні на екран.
//
// Дві різні витоки, і друга неочевидна. Перша — сам токен, якщо він
// потрапив у повідомлення. Друга — URL із логіном усередині: go-git
// охоче вкладає повну адресу в текст помилки транспорту, а люди так
// само охоче вставляють у поле адреси рядок, скопійований із інструкції
// «git clone https://токен@…». Тому чиститься і те, і те.
func Scrub(err error, secrets ...string) error {
if err == nil {
return nil
}
msg := err.Error()
for _, s := range secrets {
s = strings.TrimSpace(s)
// Коротке значення замінювати небезпечно: воно збіглося б із
// випадковим підрядком і перетворило повідомлення на кашу.
if len(s) < 8 {
continue
}
msg = strings.ReplaceAll(msg, s, "«секрет»")
// Приватний ключ багаторядковий: у повідомленні від нього
// зазвичай лишається лише перший рядок.
for _, line := range strings.Split(s, "\n") {
if line = strings.TrimSpace(line); len(line) >= 16 {
msg = strings.ReplaceAll(msg, line, "«секрет»")
}
}
}
msg = userInfoRe.ReplaceAllString(msg, "$1")
return errors.New(msg)
}
// explain перекладає типові відмови транспорту зрозумілою мовою.
//
// Не косметика: «authentication required» нічого не каже людині, яка
// щойно вставила токен і не знає, чи він не той, чи його не бачать, чи
// репозиторію взагалі немає. Кожен із трьох випадків лікується різним
// рухом.
func (r Remote) explain(err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, transport.ErrAuthenticationRequired):
// RedactURL, а не r.URL: у поле адреси регулярно вставляють
// рядок виду https://токен@forgejo/… з чужої інструкції, і без
// цього виклику текст помилки поніс би токен у базу й на екран.
return fmt.Errorf("сервер вимагає автентифікації: для %s потрібні логін і токен",
RedactURL(r.URL))
// Закріплений ключ звужує перелік алгоритмів до типу самого ключа —
// інакше сучасний OpenSSH запропонував би ed25519 там, де ми
// закріпили rsa, і збіг був би неможливий. Зворотний бік: коли ключ
// сервера СПРАВДІ замінили на інший тип, рукостискання падає ще до
// нашої перевірки, і go-ssh каже про алгоритми. Перевірено наживо на
// Gitea з rsa-ключем і закріпленим ed25519 — саме цей текст.
case strings.Contains(err.Error(), "no common algorithm for host key"):
return errors.New("сервер показав ключ іншого типу, ніж закріплений — " +
"найімовірніше, ключ сервера замінили. Виконайте перевірку з'єднання " +
"й звірте відбиток, перш ніж закріплювати новий")
case errors.Is(err, transport.ErrAuthorizationFailed):
return errors.New("доступ відхилено: токен не має права запису в цей репозиторій")
case errors.Is(err, transport.ErrRepositoryNotFound):
return fmt.Errorf("репозиторію за адресою %s немає — створіть його на сервері",
RedactURL(r.URL))
// ErrForceNeeded і текстове «non-fast-forward update: <ref>» — це
// два боки однієї відмови: перший приходить із перевірки набору
// команд, другий із відповіді сервера на конкретну гілку.
case errors.Is(err, git.ErrForceNeeded) ||
strings.Contains(err.Error(), "non-fast-forward"):
return fmt.Errorf("%w: у ньому є коміти, яких немає тут. "+
"Дзеркало не переписує чужу історію само — або вкажіть порожній репозиторій, "+
"або підтвердьте перезапис вручну", ErrDiverged)
}
return Scrub(err, r.Secret)
}
// RedactURL — адреса без логіна й пароля, придатна для журналу.
func RedactURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.User != nil {
u.User = nil
return u.String()
}
return userInfoRe.ReplaceAllString(raw, "$1")
}