package main import ( "encoding/hex" "fmt" "io" "strings" "text/tabwriter" "time" "hotpcalc/internal/otp" "hotpcalc/internal/store" ) // entry — одно рассчитанное значение. type entry struct { Index int `json:"index"` Counter uint64 `json:"counter"` Code string `json:"code"` ValidFrom time.Time `json:"valid_from,omitzero"` ValidTo time.Time `json:"valid_to,omitzero"` Current bool `json:"current,omitempty"` } // position — найденная позиция токена и рассчитанные от неё значения. type position struct { Counter uint64 `json:"counter"` // позиция первого введённого кода ValidFrom time.Time `json:"valid_from,omitzero"` ValidTo time.Time `json:"valid_to,omitzero"` DriftSteps int64 `json:"drift_steps,omitempty"` DriftSeconds int64 `json:"drift_seconds,omitempty"` Codes []entry `json:"codes"` } // result — полный результат работы программы. type result struct { Mode string `json:"mode"` Algorithm string `json:"algorithm"` Digits int `json:"digits"` StepSeconds int `json:"step_seconds,omitempty"` SeedBytes int `json:"seed_bytes"` SeedShown string `json:"seed"` SeedForm string `json:"seed_format"` Label string `json:"label,omitempty"` OTP1 string `json:"otp1,omitempty"` OTP2 string `json:"otp2,omitempty"` Direct bool `json:"direct,omitempty"` Ambiguous bool `json:"ambiguous"` Positions []position `json:"positions"` } const tsLayout = "2006-01-02 15:04:05" func printText(w io.Writer, r result, totpMode bool) { unit, unitGen := "счётчик", "счётчика" if totpMode { unit, unitGen = "шаг", "шага" } fmt.Fprintf(w, "Seed: %s (%d байт, %s)\n", r.SeedShown, r.SeedBytes, r.SeedForm) if r.Label != "" { fmt.Fprintf(w, "Токен: %s\n", r.Label) } fmt.Fprintf(w, "Режим: %s, %s, %d разрядов", upper(r.Mode), r.Algorithm, r.Digits) if totpMode { fmt.Fprintf(w, ", шаг %d с", r.StepSeconds) } fmt.Fprintln(w) if r.SeedBytes != 20 { fmt.Fprintf(w, "Внимание: длина seed %d байт; для eToken PASS ожидается 20 байт.\n", r.SeedBytes) } for i, p := range r.Positions { if len(r.Positions) > 1 { fmt.Fprintf(w, "\n=== Совпадение %d из %d ===\n", i+1, len(r.Positions)) } if r.Direct { fmt.Fprintf(w, "Расчёт: начиная с %s %d (поиск не выполнялся)\n", unitGen, p.Counter) } else { fmt.Fprintf(w, "Позиция: код %s → %s %d, код %s → %s %d\n", r.OTP1, unit, p.Counter, r.OTP2, unit, p.Counter+1) if totpMode { fmt.Fprintf(w, "Часы: %s\n", driftText(p.DriftSteps, p.DriftSeconds)) } } fmt.Fprintf(w, "\nСледующие значения (%d):\n\n", len(p.Codes)) // Каждая ячейка завершается табуляцией, иначе tabwriter не выравнивает // последнюю колонку строки. tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', tabwriter.AlignRight) if totpMode { fmt.Fprintf(tw, " #\t%s\tКод\t Действителен\n", upperFirst(unit)) } else { fmt.Fprintf(tw, " #\t%s\tКод\t\n", upperFirst(unit)) } for _, e := range p.Codes { if totpMode { mark := "" if e.Current { mark = " ← действует сейчас" } fmt.Fprintf(tw, " %d\t%d\t%s\t %s – %s%s\n", e.Index, e.Counter, e.Code, e.ValidFrom.Local().Format(tsLayout), e.ValidTo.Local().Format("15:04:05"), mark) } else { fmt.Fprintf(tw, " %d\t%d\t%s\t\n", e.Index, e.Counter, e.Code) } } tw.Flush() } if r.Ambiguous { fmt.Fprintln(w, "\nВнимание: введённой паре кодов соответствует несколько позиций.") if len(r.Positions) == 1 { fmt.Fprintln(w, "Показана первая из них; полный список — с флагом -all.") } fmt.Fprintln(w, "Сузьте диапазон перебора или снимите с токена ещё одно значение.") } } func driftText(steps, seconds int64) string { switch { case steps == 0: return "совпадают с этой машиной" case steps > 0: return fmt.Sprintf("токен опережает эту машину примерно на %d с (%d %s)", seconds, steps, plural(steps, "шаг", "шага", "шагов")) default: return fmt.Sprintf("токен отстаёт от этой машины примерно на %d с (%d %s)", -seconds, -steps, plural(-steps, "шаг", "шага", "шагов")) } } // plural подбирает форму русского существительного для числа n. func plural(n int64, one, few, many string) string { if n < 0 { n = -n } switch { case n%100 >= 11 && n%100 <= 14: return many case n%10 == 1: return one case n%10 >= 2 && n%10 <= 4: return few } return many } func upper(s string) string { return strings.ToUpper(s) } func upperFirst(s string) string { r := []rune(s) if len(r) == 0 { return s } return strings.ToUpper(string(r[0])) + string(r[1:]) } // printTokenDetails печатает параметры и состояние одного токена. func printTokenDetails(w io.Writer, t store.Token, showSeed bool) { seed := otp.Fingerprint(t.Secret) if showSeed { seed = strings.ToUpper(hex.EncodeToString(t.Secret)) } fmt.Fprintf(w, "\n %s", t.Serial) if t.Label != "" { fmt.Fprintf(w, " (%s)", t.Label) } fmt.Fprintln(w) tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0) fmt.Fprintf(tw, " тип\t%s, %s, %d разрядов", upper(t.Type), t.Alg, t.Digits) if t.IsTOTP() { fmt.Fprintf(tw, ", шаг %d с", int(t.Config().Step/time.Second)) } fmt.Fprintln(tw) fmt.Fprintf(tw, " seed\t%s (%d байт)\n", seed, len(t.Secret)) if t.IsTOTP() { if t.LastStep > 0 { fmt.Fprintf(tw, " последний шаг\t%d\n", t.LastStep) } } else { fmt.Fprintf(tw, " счётчик\t%d (значение следующего нажатия)\n", t.Counter) } if !t.Created.IsZero() { fmt.Fprintf(tw, " выпущен\t%s\n", t.Created.Local().Format(tsLayout)) } if !t.LastUsed.IsZero() { fmt.Fprintf(tw, " использован\t%s\n", t.LastUsed.Local().Format(tsLayout)) } tw.Flush() } // printTokenList печатает таблицу токенов хранилища. func printTokenList(w io.Writer, tokens []store.Token, path string) { if len(tokens) == 0 { fmt.Fprintf(w, "Хранилище %s пусто.\nСоздайте токен командой: hotpcalc new\n", path) return } fmt.Fprintf(w, "Хранилище: %s\n\n", path) tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, " СЕРИЙНЫЙ НОМЕР\tТИП\tРАЗР.\tСЧЁТЧИК\tИСПОЛЬЗОВАН\tМЕТКА") for _, t := range tokens { used := "—" if !t.LastUsed.IsZero() { used = t.LastUsed.Local().Format(tsLayout) } label := t.Label if label == "" { label = "—" } fmt.Fprintf(tw, " %s\t%s\t%d\t%s\t%s\t%s\n", t.Serial, upper(t.Type), t.Digits, counterString(t), used, label) } tw.Flush() fmt.Fprintf(w, "\nВсего токенов: %d\n", len(tokens)) }