First commit

This commit is contained in:
2026-10-16 21:11:50 +03:00
commit 34f52d6b1d
11 changed files with 915 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
package app
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"ric930-usrinfo/internal/usr"
"ric930-usrinfo/internal/xmlcheck"
)
type Mode string
const (
ModeDecode Mode = "decode"
ModeEncode Mode = "encode"
)
type Config struct {
Dir string
MaxXMLBytes int64
NoOverwrite bool
Stdout io.Writer
Stderr io.Writer
}
type Result struct {
Found int
Succeeded int
Skipped int
Failed int
}
func (r Result) ExitCode() int {
if r.Failed > 0 {
return 1
}
return 0
}
func Run(cfg Config) (Result, error) {
return RunCommand(cfg, ModeDecode, nil)
}
func RunCommand(cfg Config, mode Mode, selectors []string) (Result, error) {
var result Result
if cfg.Dir == "" {
cfg.Dir = "."
}
if cfg.Stdout == nil {
cfg.Stdout = io.Discard
}
if cfg.Stderr == nil {
cfg.Stderr = io.Discard
}
if cfg.MaxXMLBytes <= 0 {
return result, fmt.Errorf("некорректный лимит размера XML")
}
if mode != ModeDecode && mode != ModeEncode {
return result, fmt.Errorf("неизвестная команда %q", mode)
}
if mode == ModeEncode && len(selectors) == 0 {
return result, fmt.Errorf("для encode необходимо указать XML-файлы или маски")
}
var files []string
if selectors == nil && mode == ModeDecode {
entries, err := os.ReadDir(cfg.Dir)
if err != nil {
return result, fmt.Errorf("не удалось прочитать каталог %q: %w", cfg.Dir, err)
}
for _, entry := range entries {
if !strings.EqualFold(filepath.Ext(entry.Name()), ".usr") {
continue
}
if entry.Type().IsRegular() {
files = append(files, filepath.Join(cfg.Dir, entry.Name()))
} else if entry.Type()&os.ModeType == 0 {
info, err := entry.Info()
if err != nil {
fmt.Fprintf(cfg.Stderr, "[ERROR] %s: невозможно получить атрибуты: %v\n", entry.Name(), err)
result.Failed++
} else if info.Mode().IsRegular() {
files = append(files, filepath.Join(cfg.Dir, entry.Name()))
}
}
}
} else {
if len(selectors) == 0 {
return result, fmt.Errorf("для %s необходимо указать хотя бы один файл или маску", mode)
}
seen := make(map[string]bool)
for _, selector := range selectors {
if selector == "" {
fmt.Fprintln(cfg.Stderr, "[ERROR] пустая маска")
result.Failed++
continue
}
pattern := selector
if !filepath.IsAbs(selector) {
pattern = filepath.Join(cfg.Dir, selector)
}
var matches []string
var err error
if strings.ContainsAny(selector, "*?[") {
matches, err = filepath.Glob(pattern)
} else {
matches = []string{pattern}
}
if err != nil {
fmt.Fprintf(cfg.Stderr, "[ERROR] неверная маска %q: %v\n", selector, err)
result.Failed++
continue
}
if len(matches) == 0 {
fmt.Fprintf(cfg.Stderr, "[ERROR] по маске %q файлы не найдены\n", selector)
result.Failed++
continue
}
for _, match := range matches {
clean := filepath.Clean(match)
if !seen[clean] {
seen[clean] = true
files = append(files, clean)
}
}
}
}
result.Found = len(files)
if len(files) == 0 {
if result.Failed == 0 {
fmt.Fprintln(cfg.Stdout, "Подходящие файлы не найдены.")
}
return result, nil
}
fmt.Fprintf(cfg.Stdout, "Выбрано файлов (%s): %d\n", mode, result.Found)
outputs := make(map[string]int)
for _, path := range files {
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if base == "" {
continue
}
stem := filepath.Join(filepath.Dir(path), base)
outputs[strings.ToLower(filepath.Clean(stem))]++
}
for _, path := range files {
ext := filepath.Ext(path)
base := strings.TrimSuffix(filepath.Base(path), ext)
stem := filepath.Join(filepath.Dir(path), base)
if base == "" || outputs[strings.ToLower(filepath.Clean(stem))] > 1 {
fmt.Fprintf(cfg.Stderr, "[ERROR] %s: несколько входных файлов дают одинаковое имя результата\n", path)
result.Failed++
continue
}
if (mode == ModeDecode && !strings.EqualFold(ext, ".usr")) ||
(mode == ModeEncode && !strings.EqualFold(ext, ".xml")) {
fmt.Fprintf(cfg.Stderr, "[ERROR] %s: неподдерживаемое расширение для %s\n", path, mode)
result.Failed++
continue
}
info, err := os.Lstat(path)
if err != nil {
fmt.Fprintf(cfg.Stderr, "[ERROR] %s: %v\n", path, err)
result.Failed++
continue
}
if !info.Mode().IsRegular() {
fmt.Fprintf(cfg.Stderr, "[ERROR] %s: требуется обычный файл (не каталог и не символьная ссылка)\n", path)
result.Failed++
continue
}
if mode == ModeDecode {
err = decodeOne(cfg, path, stem)
} else {
err = encodeOne(cfg, path, stem)
}
if err != nil {
if errors.Is(err, errSkip) {
fmt.Fprintf(cfg.Stdout, "[SKIP] %s: результат уже существует (-no-overwrite)\n", path)
result.Skipped++
} else {
fmt.Fprintf(cfg.Stderr, "[ERROR] %s: %v\n", path, err)
result.Failed++
}
continue
}
result.Succeeded++
if mode == ModeDecode {
fmt.Fprintf(cfg.Stdout, "[OK] %s -> %s.XML\n", path, stem)
} else {
fmt.Fprintf(cfg.Stdout, "[OK] %s -> %s.USR\n", path, stem)
}
}
fmt.Fprintf(cfg.Stdout, "Итого: готово %d, пропущено %d, ошибок %d\n", result.Succeeded, result.Skipped, result.Failed)
return result, nil
}
var errSkip = errors.New("выходной файл уже существует")
func checkOutputs(paths ...string) error {
for _, path := range paths {
if _, err := os.Lstat(path); err == nil {
return errSkip
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("не удалось проверить %s: %w", path, err)
}
}
return nil
}
func decodeOne(cfg Config, path, stem string) error {
output := stem + ".XML"
if cfg.NoOverwrite {
if err := checkOutputs(output); err != nil {
return err
}
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("проверка USR: %w", err)
}
if info.Size() > usr.MaxInputBytes {
return fmt.Errorf("USR превышает предел %d байт", usr.MaxInputBytes)
}
encoded, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("чтение USR: %w", err)
}
xmlData, err := usr.Decode(encoded, cfg.MaxXMLBytes)
if err != nil {
return fmt.Errorf("декодирование: %w", err)
}
if err := xmlcheck.Validate(xmlData); err != nil {
return fmt.Errorf("распакованные данные не являются корректным XML: %w", err)
}
return writeFile(output, xmlData)
}
func encodeOne(cfg Config, path, stem string) error {
output := stem + ".USR"
if cfg.NoOverwrite {
if err := checkOutputs(output); err != nil {
return err
}
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("проверка XML: %w", err)
}
if info.Size() > cfg.MaxXMLBytes {
return fmt.Errorf("XML превышает предел %d байт", cfg.MaxXMLBytes)
}
xmlData, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("чтение XML: %w", err)
}
if err := xmlcheck.Validate(xmlData); err != nil {
return fmt.Errorf("некорректный исходный XML: %w", err)
}
encoded, err := usr.Encode(xmlData, cfg.MaxXMLBytes)
if err != nil {
return fmt.Errorf("кодирование USR: %w", err)
}
return writeFile(output, encoded)
}
func writeFile(path string, data []byte) error {
temp, err := createTempFile(filepath.Dir(path), filepath.Base(path), data)
if err != nil {
return fmt.Errorf("подготовка файла %s: %w", path, err)
}
defer os.Remove(temp)
if err := os.Rename(temp, path); err != nil {
return fmt.Errorf("сохранение файла %s: %w", path, err)
}
return nil
}
func createTempFile(dir, name string, data []byte) (string, error) {
f, err := os.CreateTemp(dir, "."+name+"-*")
if err != nil {
return "", err
}
path := f.Name()
if _, err = f.Write(data); err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err == nil {
err = closeErr
}
if err != nil {
os.Remove(path)
return "", err
}
return path, nil
}
+65
View File
@@ -0,0 +1,65 @@
package usr
import (
"bytes"
"compress/zlib"
"errors"
"fmt"
"io"
)
const (
MaxInputBytes int64 = 15_000_000
DefaultMaxXMLBytes int64 = 30_000_000
)
var ErrUnsupportedFormat = errors.New("неподдерживаемый формат USR")
// encoded[i] = zlib[i] + 0x21 + 0x32*i (mod 256).
func Decode(encoded []byte, maxXMLBytes int64) ([]byte, error) {
if len(encoded) == 0 {
return nil, errors.New("пустой файл USR")
}
if int64(len(encoded)) > MaxInputBytes {
return nil, fmt.Errorf("входной USR слишком большой: %d > %d байт", len(encoded), MaxInputBytes)
}
if maxXMLBytes <= 0 {
return nil, errors.New("лимит распаковки должен быть положительным")
}
compressed := make([]byte, len(encoded))
offset := byte(0x21)
for i, value := range encoded {
compressed[i] = value - offset
offset += 0x32
}
if len(compressed) < 2 || compressed[0]&0x0f != 8 ||
(compressed[0]>>4) > 7 ||
(int(compressed[0])<<8|int(compressed[1]))%31 != 0 ||
compressed[1]&0x20 != 0 {
return nil, ErrUnsupportedFormat
}
input := bytes.NewReader(compressed)
zr, err := zlib.NewReader(input)
if err != nil {
return nil, fmt.Errorf("не удалось открыть поток zlib: %w", err)
}
plain, readErr := io.ReadAll(io.LimitReader(zr, maxXMLBytes+1))
closeErr := zr.Close()
if int64(len(plain)) > maxXMLBytes {
return nil, fmt.Errorf("превышен лимит распаковки %d байт", maxXMLBytes)
}
if readErr != nil {
return nil, fmt.Errorf("ошибка распаковки/контрольной суммы zlib: %w", readErr)
}
if closeErr != nil {
return nil, fmt.Errorf("ошибка закрытия zlib: %w", closeErr)
}
if input.Len() != 0 {
return nil, fmt.Errorf("после потока zlib обнаружены лишние байты (%d)", input.Len())
}
return plain, nil
}
+37
View File
@@ -0,0 +1,37 @@
package usr
import (
"bytes"
"compress/zlib"
"fmt"
)
func Encode(xmlData []byte, maxXMLBytes int64) ([]byte, error) {
if len(xmlData) == 0 {
return nil, fmt.Errorf("пустой XML")
}
if maxXMLBytes <= 0 || int64(len(xmlData)) > maxXMLBytes {
return nil, fmt.Errorf("XML превышает допустимый размер (%d байт)", maxXMLBytes)
}
var buf bytes.Buffer
writer, err := zlib.NewWriterLevel(&buf, 6)
if err != nil {
return nil, fmt.Errorf("создание zlib: %w", err)
}
if _, err := writer.Write(xmlData); err != nil {
return nil, fmt.Errorf("сжатие XML: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("завершение сжатия XML: %w", err)
}
if int64(buf.Len()) > MaxInputBytes {
return nil, fmt.Errorf("размер сжатых данных превышает предел USR (%d байт)", MaxInputBytes)
}
encoded := append([]byte(nil), buf.Bytes()...)
offset := byte(0x21)
for i := range encoded {
encoded[i] += offset
offset += 0x32
}
return encoded, nil
}
+54
View File
@@ -0,0 +1,54 @@
package xmlcheck
import (
"bytes"
"fmt"
"io"
"unicode/utf8"
)
var cp1251Table = [128]rune{
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, // 80-87
0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, // 88-8F
0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, // 90-97
0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, // 98-9F
0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, // A0-A7
0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, // A8-AF
0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, // B0-B7
0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, // B8-BF
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, // C0-C7
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, // C8-CF
0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, // D0-D7
0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, // D8-DF
0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, // E0-E7
0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, // E8-EF
0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, // F0-F7
0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D, 0x044E, 0x044F, // F8-FF
}
func charsetReader(name string, r io.Reader) (io.Reader, error) {
switch name {
case "windows-1251", "Windows-1251", "WINDOWS-1251", "cp1251", "CP1251", "windows1251":
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
result := make([]byte, 0, len(data)*2)
for i, b := range data {
if b < 0x80 {
result = append(result, b)
continue
}
runeValue := cp1251Table[b-0x80]
if runeValue == utf8.RuneError {
return nil, fmt.Errorf("неопределённый байт Windows-1251 0x%02X по смещению %d", b, i)
}
result = utf8.AppendRune(result, runeValue)
}
return bytes.NewReader(result), nil
case "utf-8", "UTF-8", "utf8", "UTF8":
return r, nil
default:
return nil, fmt.Errorf("неподдерживаемая кодировка XML %q", name)
}
}
+45
View File
@@ -0,0 +1,45 @@
package xmlcheck
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"strings"
)
func Validate(source []byte) error {
decoder := xml.NewDecoder(bytes.NewReader(source))
decoder.CharsetReader = charsetReader
decoder.Strict = true
depth, roots := 0, 0
for {
token, err := decoder.Token()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("некорректный XML: %w", err)
}
switch node := token.(type) {
case xml.StartElement:
if depth == 0 {
roots++
if roots > 1 {
return fmt.Errorf("найдено более одного корневого XML-элемента")
}
}
depth++
case xml.EndElement:
depth--
case xml.CharData:
if depth == 0 && strings.TrimSpace(string(node)) != "" {
return fmt.Errorf("текст за пределами корневого XML-элемента")
}
}
}
if roots != 1 || depth != 0 {
return fmt.Errorf("нет единственного завершённого корневого XML-элемента")
}
return nil
}