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
+62
View File
@@ -0,0 +1,62 @@
package store
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
)
const (
// SerialLength — длина серийного номера eToken PASS в символах.
SerialLength = 14
// DefaultSerialPrefix — префикс серийных номеров eToken PASS.
DefaultSerialPrefix = "000400"
// SeedBytes — длина вектора инициализации в байтах.
SeedBytes = 20
)
// NewSeed генерирует криптографически стойкий вектор инициализации.
func NewSeed(n int) ([]byte, error) {
if n <= 0 {
n = SeedBytes
}
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("не удалось получить случайные данные: %w", err)
}
return b, nil
}
// NewSerial генерирует серийный номер: заданный префикс и случайный
// шестнадцатеричный хвост до общей длины SerialLength.
func NewSerial(prefix string) (string, error) {
prefix = strings.ToUpper(strings.TrimSpace(prefix))
if err := ValidateSerial(prefix + "0"); err != nil {
return "", fmt.Errorf("некорректный префикс серийного номера: %w", err)
}
suffix := SerialLength - len(prefix)
if suffix < 4 {
suffix = 4
}
b := make([]byte, (suffix+1)/2)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("не удалось получить случайные данные: %w", err)
}
return prefix + strings.ToUpper(hex.EncodeToString(b))[:suffix], nil
}
// NewUniqueSerial подбирает серийный номер, которого ещё нет в хранилище.
func (s *Store) NewUniqueSerial(prefix string) (string, error) {
for i := 0; i < 100; i++ {
serial, err := NewSerial(prefix)
if err != nil {
return "", err
}
if s.file.Section(serial) == nil {
return serial, nil
}
}
return "", fmt.Errorf("не удалось подобрать свободный серийный номер с префиксом %q", prefix)
}
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"fmt"
"os"
"strings"
"time"
)
const (
// lockTimeout — сколько ждём освобождения хранилища другим процессом.
lockTimeout = 5 * time.Second
// lockStaleAfter — после этого возраста замок считается брошенным
// (процесс убит, не успев его снять).
lockStaleAfter = 60 * time.Second
)
// Lock — межпроцессная блокировка хранилища на основе файла, создаваемого
// с O_EXCL. В отличие от flock работает одинаково на Unix и Windows.
type Lock struct {
path string
warn func(string)
}
// acquireLock захватывает замок для файла хранилища path.
// warn получает предупреждения (обычно печатается в stderr); может быть nil.
func acquireLock(path string, warn func(string)) (*Lock, error) {
if warn == nil {
warn = func(string) {}
}
l := &Lock{path: path + ".lock", warn: warn}
deadline := time.Now().Add(lockTimeout)
delay := 10 * time.Millisecond
brokeStale := false
for {
f, err := os.OpenFile(l.path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
fmt.Fprintf(f, "pid %d, %s\n", os.Getpid(), time.Now().Format(time.RFC3339))
f.Close()
return l, nil
}
if !os.IsExist(err) {
return nil, fmt.Errorf("не удалось создать файл блокировки %s: %w", l.path, err)
}
// Замок занят: возможно, его владелец давно умер.
if !brokeStale {
if fi, statErr := os.Stat(l.path); statErr == nil && time.Since(fi.ModTime()) > lockStaleAfter {
l.warn(fmt.Sprintf("снимаю брошенный файл блокировки %s (возраст %s)",
l.path, time.Since(fi.ModTime()).Round(time.Second)))
os.Remove(l.path)
brokeStale = true
continue
}
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("хранилище занято другим процессом: файл блокировки %s.\n"+
"Если процесс уже завершён, удалите этот файл вручную", l.path)
}
time.Sleep(delay)
if delay < 200*time.Millisecond {
delay *= 2
}
}
}
// release снимает блокировку.
func (l *Lock) release() error {
if l == nil {
return nil
}
if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("не удалось снять блокировку %s: %w", l.path, err)
}
return nil
}
// IsLockPath сообщает, является ли путь служебным файлом блокировки.
func IsLockPath(p string) bool { return strings.HasSuffix(p, ".lock") }
+350
View File
@@ -0,0 +1,350 @@
// Package store хранит токены и их состояние в ini-файле.
package store
import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"hotpcalc/internal/ini"
"hotpcalc/internal/otp"
)
// Типы токенов.
const (
TypeHOTP = "hotp"
TypeTOTP = "totp"
)
// EnvStore — переменная окружения, задающая путь к хранилищу.
const EnvStore = "HOTPCALC_STORE"
// FileName — имя файла хранилища по умолчанию.
const FileName = "tokens.ini"
// Token — токен и его текущее состояние.
type Token struct {
Serial string
Type string
Secret []byte
Digits int
Alg otp.Algorithm
Period time.Duration // только TOTP
Counter uint64 // HOTP: счётчик СЛЕДУЮЩЕГО значения
LastStep uint64 // TOTP: последний выданный временной шаг
Label string
Created time.Time
LastUsed time.Time
}
// IsTOTP сообщает, является ли токен временным.
func (t Token) IsTOTP() bool { return t.Type == TypeTOTP }
// Config строит параметры расчёта для пакета otp.
func (t Token) Config() otp.Config {
step := t.Period
if step <= 0 {
step = otp.DefaultStep
}
return otp.Config{Secret: t.Secret, Digits: t.Digits, Alg: t.Alg, Step: step}
}
// Validate проверяет токен перед записью в хранилище.
func (t Token) Validate() error {
if err := ValidateSerial(t.Serial); err != nil {
return err
}
if t.Type != TypeHOTP && t.Type != TypeTOTP {
return fmt.Errorf("токен %s: неизвестный тип %q (допустимо: hotp, totp)", t.Serial, t.Type)
}
if strings.ContainsAny(t.Label, "\r\n") {
return fmt.Errorf("токен %s: метка содержит перевод строки", t.Serial)
}
return t.Config().Validate()
}
// ValidateSerial проверяет серийный номер: он становится именем секции ini.
func ValidateSerial(s string) error {
if s == "" {
return fmt.Errorf("пустой серийный номер")
}
if len(s) > 64 {
return fmt.Errorf("серийный номер длиннее 64 символов: %q", s)
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z',
r == '-', r == '_', r == '.':
default:
return fmt.Errorf("недопустимый символ %q в серийном номере %q "+
"(разрешены буквы, цифры, -, _ и точка)", r, s)
}
}
return nil
}
// DefaultPath возвращает путь к хранилищу по умолчанию: переменная окружения
// HOTPCALC_STORE, иначе tokens.ini рядом с исполняемым файлом.
func DefaultPath() string {
if p := os.Getenv(EnvStore); p != "" {
return p
}
if exe, err := os.Executable(); err == nil {
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return filepath.Join(filepath.Dir(exe), FileName)
}
return FileName
}
// Store — открытое хранилище. Пока оно открыто, файл заблокирован
// для других процессов, поэтому закрывать его нужно всегда.
type Store struct {
path string
file *ini.File
lock *Lock
}
var preamble = []string{
"; hotpcalc — хранилище токенов",
"; Файл содержит секреты в открытом виде: защищайте его как пароль.",
"; Одна секция — один токен, имя секции — серийный номер.",
}
// Open открывает хранилище, захватывая блокировку. Отсутствующий файл —
// не ошибка: он будет создан при первом Commit.
func Open(path string, warn func(string)) (*Store, error) {
if path == "" {
return nil, fmt.Errorf("не задан путь к хранилищу")
}
lock, err := acquireLock(path, warn)
if err != nil {
return nil, err
}
s := &Store{path: path, lock: lock}
data, err := os.ReadFile(path)
switch {
case os.IsNotExist(err):
s.file = ini.New()
s.file.SetPreamble(preamble)
case err != nil:
lock.release()
return nil, fmt.Errorf("не удалось прочитать хранилище %s: %w", path, err)
default:
s.file, err = ini.Parse(strings.NewReader(string(data)))
if err != nil {
lock.release()
return nil, fmt.Errorf("хранилище %s повреждено: %w", path, err)
}
}
return s, nil
}
// Close снимает блокировку. Несохранённые изменения теряются.
func (s *Store) Close() error {
if s == nil {
return nil
}
return s.lock.release()
}
// Path возвращает путь к файлу хранилища.
func (s *Store) Path() string { return s.path }
// Serials возвращает серийные номера в порядке их появления в файле.
func (s *Store) Serials() []string { return s.file.Sections() }
// Get читает токен по серийному номеру.
func (s *Store) Get(serial string) (Token, bool, error) {
sec := s.file.Section(serial)
if sec == nil {
return Token{}, false, nil
}
t, err := tokenFromSection(serial, sec)
if err != nil {
return Token{}, true, err
}
return t, true, nil
}
// List возвращает все токены хранилища.
func (s *Store) List() ([]Token, error) {
var out []Token
for _, serial := range s.file.Sections() {
t, _, err := s.Get(serial)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, nil
}
// Put добавляет или обновляет токен.
func (s *Store) Put(t Token) error {
if err := t.Validate(); err != nil {
return err
}
sec := s.file.AddSection(t.Serial)
sec.Set("type", t.Type)
sec.Set("seed", strings.ToUpper(hex.EncodeToString(t.Secret)))
sec.Set("digits", strconv.Itoa(t.Digits))
sec.Set("algorithm", strings.ToLower(t.Alg.String()))
if t.IsTOTP() {
sec.Unset("counter")
sec.Set("period", strconv.Itoa(int(t.Config().Step/time.Second)))
sec.Set("last_step", strconv.FormatUint(t.LastStep, 10))
} else {
sec.Unset("period")
sec.Unset("last_step")
sec.Set("counter", strconv.FormatUint(t.Counter, 10))
}
setOrUnset(sec, "label", t.Label)
setOrUnset(sec, "created", formatTime(t.Created))
setOrUnset(sec, "last_used", formatTime(t.LastUsed))
return nil
}
// Remove удаляет токен; возвращает true, если он был.
func (s *Store) Remove(serial string) bool { return s.file.RemoveSection(serial) }
// Commit атомарно записывает хранилище на диск.
func (s *Store) Commit() error {
data, err := s.file.Bytes()
if err != nil {
return err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("не удалось создать каталог %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, filepath.Base(s.path)+".tmp-*")
if err != nil {
return fmt.Errorf("не удалось создать временный файл в %s: %w", dir, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // не сработает после успешного Rename
if err := tmp.Chmod(0o600); err != nil && !os.IsPermission(err) {
tmp.Close()
return fmt.Errorf("не удалось выставить права на %s: %w", tmpName, err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("не удалось записать %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("не удалось сбросить на диск %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("не удалось закрыть %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("не удалось заменить %s: %w", s.path, err)
}
return nil
}
func setOrUnset(sec *ini.Section, key, value string) {
if value == "" {
sec.Unset(key)
return
}
sec.Set(key, value)
}
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func tokenFromSection(serial string, sec *ini.Section) (Token, error) {
fail := func(format string, args ...any) (Token, error) {
return Token{}, fmt.Errorf("токен %s: %s", serial, fmt.Sprintf(format, args...))
}
t := Token{Serial: serial, Type: sec.Get("type")}
if t.Type == "" {
t.Type = TypeHOTP
}
if t.Type != TypeHOTP && t.Type != TypeTOTP {
return fail("неизвестный тип %q", t.Type)
}
seed := sec.Get("seed")
if seed == "" {
return fail("не задан seed")
}
parsed, err := otp.ParseSeed(seed, otp.FormatAuto)
if err != nil {
return fail("%v", err)
}
t.Secret = parsed.Secret
t.Digits = 6
if v := sec.Get("digits"); v != "" {
if t.Digits, err = strconv.Atoi(v); err != nil {
return fail("некорректное значение digits %q", v)
}
}
if t.Alg, err = otp.ParseAlgorithm(sec.Get("algorithm")); err != nil {
return fail("%v", err)
}
if t.IsTOTP() {
t.Period = otp.DefaultStep
if v := sec.Get("period"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fail("некорректное значение period %q", v)
}
t.Period = time.Duration(n) * time.Second
}
if t.LastStep, err = parseUint(sec.Get("last_step")); err != nil {
return fail("некорректное значение last_step %q", sec.Get("last_step"))
}
} else if t.Counter, err = parseUint(sec.Get("counter")); err != nil {
return fail("некорректное значение counter %q", sec.Get("counter"))
}
t.Label = sec.Get("label")
if t.Created, err = parseTime(sec.Get("created")); err != nil {
return fail("некорректное значение created %q", sec.Get("created"))
}
if t.LastUsed, err = parseTime(sec.Get("last_used")); err != nil {
return fail("некорректное значение last_used %q", sec.Get("last_used"))
}
if err := t.Config().Validate(); err != nil {
return fail("%v", err)
}
return t, nil
}
func parseUint(s string) (uint64, error) {
if s == "" {
return 0, nil
}
return strconv.ParseUint(s, 10, 64)
}
func parseTime(s string) (time.Time, error) {
if s == "" {
return time.Time{}, nil
}
return time.Parse(time.RFC3339, s)
}
+321
View File
@@ -0,0 +1,321 @@
package store
import (
"encoding/hex"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"hotpcalc/internal/otp"
)
const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D"
func testSeed(t *testing.T) []byte {
t.Helper()
b, err := hex.DecodeString(testSeedHex)
if err != nil {
t.Fatal(err)
}
return b
}
func openTemp(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.ini")
s, err := Open(path, nil)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s, path
}
func sampleToken(t *testing.T) Token {
return Token{
Serial: "0004000ABCDEF1", Type: TypeHOTP, Secret: testSeed(t),
Digits: 6, Alg: otp.SHA1, Counter: 42, Label: "Иванов И.И.",
Created: time.Now().Truncate(time.Second),
}
}
func TestPutGetCommitReopen(t *testing.T) {
s, path := openTemp(t)
want := sampleToken(t)
if err := s.Put(want); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Commit(); err != nil {
t.Fatalf("Commit: %v", err)
}
s.Close()
s2, err := Open(path, nil)
if err != nil {
t.Fatalf("повторное открытие: %v", err)
}
defer s2.Close()
got, ok, err := s2.Get(want.Serial)
if err != nil || !ok {
t.Fatalf("Get: %v, найден=%v", err, ok)
}
if got.Type != want.Type || got.Digits != want.Digits || got.Counter != want.Counter ||
got.Label != want.Label || got.Alg != want.Alg {
t.Errorf("получено %+v, ожидалось %+v", got, want)
}
if hex.EncodeToString(got.Secret) != hex.EncodeToString(want.Secret) {
t.Errorf("seed не совпал: %X", got.Secret)
}
if !got.Created.Equal(want.Created) {
t.Errorf("created = %v, ожидалось %v", got.Created, want.Created)
}
if _, ok, _ := s2.Get("НЕТ-ТАКОГО"); ok {
t.Error("несуществующий токен не должен находиться")
}
}
func TestTOTPFieldsRoundTrip(t *testing.T) {
s, path := openTemp(t)
want := Token{
Serial: "TOTP01", Type: TypeTOTP, Secret: testSeed(t), Digits: 8,
Alg: otp.SHA256, Period: 60 * time.Second, LastStep: 58907525,
}
if err := s.Put(want); err != nil {
t.Fatal(err)
}
if err := s.Commit(); err != nil {
t.Fatal(err)
}
s.Close()
s2, _ := Open(path, nil)
defer s2.Close()
got, _, err := s2.Get("TOTP01")
if err != nil {
t.Fatal(err)
}
if got.Period != 60*time.Second || got.LastStep != want.LastStep ||
got.Digits != 8 || got.Alg != otp.SHA256 {
t.Errorf("получено %+v", got)
}
// Для TOTP counter не пишется, для HOTP не пишутся period/last_step.
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "counter") {
t.Errorf("в TOTP-секции не должно быть counter:\n%s", data)
}
}
func TestSwitchTypeClearsForeignKeys(t *testing.T) {
s, path := openTemp(t)
tok := sampleToken(t)
if err := s.Put(tok); err != nil {
t.Fatal(err)
}
tok.Type = TypeTOTP
tok.Period = 30 * time.Second
if err := s.Put(tok); err != nil {
t.Fatal(err)
}
if err := s.Commit(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
if strings.Contains(string(data), "counter") {
t.Errorf("counter должен исчезнуть при смене типа на totp:\n%s", data)
}
}
func TestListAndRemove(t *testing.T) {
s, _ := openTemp(t)
a := sampleToken(t)
b := sampleToken(t)
b.Serial = "0004000ABCDEF2"
if err := s.Put(a); err != nil {
t.Fatal(err)
}
if err := s.Put(b); err != nil {
t.Fatal(err)
}
list, err := s.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 2 || list[0].Serial != a.Serial || list[1].Serial != b.Serial {
t.Errorf("List вернул %+v", list)
}
if !s.Remove(a.Serial) || s.Remove(a.Serial) {
t.Error("Remove работает неверно")
}
if got := s.Serials(); len(got) != 1 || got[0] != b.Serial {
t.Errorf("после удаления Serials() = %v", got)
}
}
func TestFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("на Windows права доступа задаются иначе")
}
s, path := openTemp(t)
if err := s.Put(sampleToken(t)); err != nil {
t.Fatal(err)
}
if err := s.Commit(); err != nil {
t.Fatal(err)
}
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("права на хранилище = %04o, ожидалось 0600", perm)
}
}
func TestValidation(t *testing.T) {
s, _ := openTemp(t)
bad := []struct {
name string
alter func(*Token)
}{
{"пустой серийник", func(t *Token) { t.Serial = "" }},
{"пробел в серийнике", func(t *Token) { t.Serial = "AB CD" }},
{"скобка в серийнике", func(t *Token) { t.Serial = "A]B" }},
{"неизвестный тип", func(t *Token) { t.Type = "ocra" }},
{"пустой seed", func(t *Token) { t.Secret = nil }},
{"разрядность 4", func(t *Token) { t.Digits = 4 }},
{"перевод строки в метке", func(t *Token) { t.Label = "две\nстроки" }},
}
for _, tc := range bad {
tok := sampleToken(t)
tc.alter(&tok)
if err := s.Put(tok); err == nil {
t.Errorf("%s: ожидалась ошибка", tc.name)
}
}
}
func TestOpenBrokenStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.ini")
os.WriteFile(path, []byte("это не ini\n"), 0o600)
if _, err := Open(path, nil); err == nil {
t.Error("битый файл должен давать ошибку")
}
// Замок при неудачном открытии не должен оставаться.
if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
t.Error("файл блокировки остался после неудачного Open")
}
}
func TestBrokenTokenSection(t *testing.T) {
path := filepath.Join(t.TempDir(), "tokens.ini")
os.WriteFile(path, []byte("[AAA]\nseed = не-hex-и-не-base32!\n"), 0o600)
s, err := Open(path, nil)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, _, err := s.Get("AAA"); err == nil {
t.Error("некорректный seed должен давать ошибку при чтении токена")
}
}
func TestLockIsExclusive(t *testing.T) {
s, path := openTemp(t)
_ = s
start := time.Now()
if _, err := Open(path, nil); err == nil {
t.Fatal("второе открытие должно завершиться ошибкой")
}
if d := time.Since(start); d < lockTimeout {
t.Errorf("ожидание длилось %s, ожидалось не меньше %s", d, lockTimeout)
}
// После Close хранилище снова открывается.
s.Close()
s2, err := Open(path, nil)
if err != nil {
t.Fatalf("после Close открыть не удалось: %v", err)
}
s2.Close()
}
func TestStaleLockIsBroken(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tokens.ini")
lockPath := path + ".lock"
os.WriteFile(lockPath, []byte("pid 1\n"), 0o600)
old := time.Now().Add(-2 * lockStaleAfter)
os.Chtimes(lockPath, old, old)
var warned string
s, err := Open(path, func(m string) { warned = m })
if err != nil {
t.Fatalf("брошенный замок должен сниматься: %v", err)
}
defer s.Close()
if !strings.Contains(warned, "брошенный") {
t.Errorf("предупреждение не выдано: %q", warned)
}
}
func TestNewSeedAndSerial(t *testing.T) {
a, err := NewSeed(0)
if err != nil || len(a) != SeedBytes {
t.Fatalf("NewSeed: %v, %d байт", err, len(a))
}
b, _ := NewSeed(0)
if hex.EncodeToString(a) == hex.EncodeToString(b) {
t.Error("два вызова NewSeed дали одинаковый результат")
}
serial, err := NewSerial(DefaultSerialPrefix)
if err != nil {
t.Fatal(err)
}
if len(serial) != SerialLength || !strings.HasPrefix(serial, DefaultSerialPrefix) {
t.Errorf("NewSerial = %q", serial)
}
if err := ValidateSerial(serial); err != nil {
t.Errorf("сгенерированный серийник не проходит проверку: %v", err)
}
if _, err := NewSerial("плохой префикс"); err == nil {
t.Error("некорректный префикс должен отвергаться")
}
}
func TestNewUniqueSerial(t *testing.T) {
s, _ := openTemp(t)
seen := map[string]bool{}
for i := 0; i < 20; i++ {
serial, err := s.NewUniqueSerial(DefaultSerialPrefix)
if err != nil {
t.Fatal(err)
}
if seen[serial] {
t.Fatalf("серийник %s выдан дважды", serial)
}
seen[serial] = true
tok := sampleToken(t)
tok.Serial = serial
if err := s.Put(tok); err != nil {
t.Fatal(err)
}
}
}
func TestDefaultPathHonoursEnv(t *testing.T) {
t.Setenv(EnvStore, "/tmp/особый/путь.ini")
if got := DefaultPath(); got != "/tmp/особый/путь.ini" {
t.Errorf("DefaultPath = %q", got)
}
t.Setenv(EnvStore, "")
if got := DefaultPath(); filepath.Base(got) != FileName {
t.Errorf("DefaultPath = %q, ожидалось имя %s", got, FileName)
}
}