Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
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))
}