commit 34f52d6b1ded21c6389bf34b3dc7eb0c3ad12e4b Author: astelm Date: Fri Oct 16 21:11:50 2026 +0300 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..514147f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +*.exe +*.test diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5d3cf61 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +APP := ric930-usrinfo +VERSION := 1.3.7 +OUT := dist +MAIN := ./cmd/ric930-usrinfo +GO ?= go +LDFLAGS := -s -w -X main.version=$(VERSION) + +.PHONY: all build build-linux build-windows vet fmt clean help + +all: build + +build: build-linux build-windows + +build-linux: + @mkdir -p $(OUT) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(OUT)/$(APP)-linux-amd64 $(MAIN) + +build-windows: + @mkdir -p $(OUT) + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build -trimpath -ldflags '$(LDFLAGS)' -o $(OUT)/$(APP)-windows-amd64.exe $(MAIN) + +vet: + $(GO) vet ./... + +fmt: + $(GO) fmt ./... + +clean: + rm -rf $(OUT) + +help: + @echo 'make build Linux amd64 + Windows amd64 -> dist/' + @echo 'make build-linux Linux amd64 only' + @echo 'make build-windows Windows amd64 only' + @echo 'make clean Delete dist/' diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8dfebc --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# ric930-usrinfo + +Локальный конвертер `*.USR`: **USR → XML** и **XML → USR**. + +Полная спецификация бинарного формата: **[docs/USR_FORMAT.md](docs/USR_FORMAT.md)**. + +## Запуск без параметров + +```bash +# Linux +./ric930-usrinfo-linux-amd64 +``` + +```powershell +# Windows PowerShell +.\ric930-usrinfo-windows-amd64.exe +``` + +Программа найдёт все `.USR` в **текущем рабочем каталоге** (не рекурсивно, расширение без учёта регистра) и создаст рядом с каждым файлом **только одноимённый `.XML`**. + +## Команды и маски + +```bash +# USR -> XML +ric930-usrinfo decode 'CONS#12*.USR' +ric930-usrinfo decode A.USR B.USR +ric930-usrinfo --decode '*.USR' + +# XML -> USR +ric930-usrinfo encode 'CONS#12*.XML' +ric930-usrinfo encode A.XML B.XML +ric930-usrinfo --encode '*.XML' + +# Другой каталог +ric930-usrinfo decode -dir /data/consultant 'CONS#12*.USR' +ric930-usrinfo encode -dir /data/consultant '*.XML' + +# Запретить перезапись +ric930-usrinfo decode -no-overwrite '*.USR' +``` + +Команды `decode` / `encode` (как и флаги `--decode` / `--encode`) **обязательно** принимают один или несколько файлов либо масок. Маски `*`, `?`, `[a-z]` обрабатываются программой, включая Windows. В Linux кавычки нужны, чтобы оболочка не раскрыла маски раньше программы. Пути могут быть относительными или абсолютными; рекурсивный обход подкаталогов не предусмотрен. Параметры должны стоять до списка файлов. + +### Параметры + +```text +-dir DIR Базовый каталог (по умолчанию текущий) +-no-overwrite Не заменять уже существующие результаты +-max-xml-mb N Лимит исходного/распакованного XML: 1..30 МБ (по умолчанию 30) +-version Вывести версию +-h / --help Справка +``` + +## Алгоритм + +Формат содержит zlib-поток, к байтам которого применено смещение `33 + 50*i` по модулю 256 (индексы с нуля): + +```text +USR[i] = (zlib[i] + 33 + 50*i) mod 256 +zlib[i] = (USR[i] - 33 - 50*i) mod 256 +XML = zlib.decompress(zlib) +``` + +**USR → XML** сохраняет исходные байты документа, включая объявленную кодировку, пробелы и комментарии. При **XML → USR → XML** исходные XML-байты воспроизводятся в точности. При этом созданный USR может отличаться по байтам от "оригинального" USR из-за особенностей сжатия DEFLATE. + +Перед сохранением приложение проверяет корректность распакованного XML с учётом кодировки; неподдерживаемые и повреждённые файлы приводят к ошибке для конкретного входа. Лимит USR — 15 МБ, лимит XML — 30 МБ (настраивается вниз). Каждый файл обрабатывается независимо; ошибка одного не останавливает остальные. + +Результат сначала записывается во временный файл рядом с назначением, затем переименовывается. По умолчанию выходной файл обновляется; `-no-overwrite` позволяет пропустить существующий. + +Коды завершения: `0` — без ошибок; `1` — ошибка обработки хотя бы одного файла; `2` — неверные аргументы CLI. Исполняемые файлы не принимают XML другого формата в качестве исходного файла для `decode` и не принимают `.USR` для `encode`. + +## Сборка + +```bash +make build # Linux amd64 и Windows amd64 +make build-linux +make build-windows +make vet # анализ кода +make clean +``` + +Результаты в `dist/`: + +```text +ric930-usrinfo-linux-amd64 +ric930-usrinfo-windows-amd64.exe +``` + +## Структура проекта + +```text +cmd/ric930-usrinfo/ CLI, параметры и справка +internal/app/ Поиск, маски, операции с файлами +internal/usr/ Кодирование/декодирование USR +internal/xmlcheck/ Проверка XML, в том числе Windows-1251 +docs/USR_FORMAT.md Спецификация бинарного формата +Makefile Сборка для Linux и Windows +``` + +**Ограничения:** данный проект является результатом долгосрочной работы по альтернативному методу получения данных для собственных учетных систем из оригинальных файлов статистики программного проекта "КонсультантПлюс". + +Все действия были выполнены исключительно в познавательных целях без достижения возможности фальсификации производственных или иных данных. + +В исследовальских целях было использовано свободное ПО: Ghidra, IDA Free, ProccessExplorer, objdump и dumpbin. diff --git a/cmd/ric930-usrinfo/main.go b/cmd/ric930-usrinfo/main.go new file mode 100644 index 0000000..9d331a2 --- /dev/null +++ b/cmd/ric930-usrinfo/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + + "ric930-usrinfo/internal/app" + "ric930-usrinfo/internal/usr" +) + +var version = "0.3.0" + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + mode := app.ModeDecode + explicit := false + if len(args) > 0 && (args[0] == "decode" || args[0] == "encode") { + mode = app.Mode(args[0]) + args = args[1:] + explicit = true + } + fs := flag.NewFlagSet("ric930-usrinfo", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var dir string + var maxXMLMB int + var noOverwrite, showVersion, decodeFlag, encodeFlag bool + fs.StringVar(&dir, "dir", ".", "Базовый каталог для относительных путей и масок") + fs.IntVar(&maxXMLMB, "max-xml-mb", 30, "Максимальный размер XML после распаковки (МБ, по 1 000 000 байт)") + fs.BoolVar(&noOverwrite, "no-overwrite", false, "Не перезаписывать существующие результаты") + fs.BoolVar(&showVersion, "version", false, "Показать версию") + fs.BoolVar(&decodeFlag, "decode", false, "Режим USR -> XML (вместо команды decode)") + fs.BoolVar(&encodeFlag, "encode", false, "Режим XML -> USR (вместо команды encode)") + + fs.Usage = func() { + fmt.Fprintln(fs.Output(), "ric930-usrinfo: legacy USR <-> XML") + fmt.Fprintln(fs.Output(), "Использование:") + fmt.Fprintln(fs.Output(), " ric930-usrinfo # все *.USR в текущем каталоге") + fmt.Fprintln(fs.Output(), " ric930-usrinfo decode [опции] FILE... # USR -> XML") + fmt.Fprintln(fs.Output(), " ric930-usrinfo encode [опции] FILE... # XML -> USR") + fmt.Fprintln(fs.Output(), " ric930-usrinfo -dir DIR # автообработка из DIR") + fmt.Fprintln(fs.Output(), " ric930-usrinfo --decode FILE... # вариант с флагом") + fmt.Fprintln(fs.Output(), " ric930-usrinfo --encode FILE... # вариант с флагом") + fmt.Fprintln(fs.Output(), "Маски можно заключать в кавычки: 'CONS#12*.USR' или '*.XML'.") + fmt.Fprintln(fs.Output(), "Опции указываются до имён файлов/масок:") + fs.PrintDefaults() + } + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + if showVersion { + fmt.Printf("ric930-usrinfo %s\n", version) + return 0 + } + if (decodeFlag && encodeFlag) || (explicit && (decodeFlag || encodeFlag)) { + fmt.Fprintln(os.Stderr, "Ошибка: укажите только один режим decode или encode") + return 2 + } + if decodeFlag { + mode, explicit = app.ModeDecode, true + } + if encodeFlag { + mode, explicit = app.ModeEncode, true + } + if maxXMLMB < 1 || int64(maxXMLMB) > usr.DefaultMaxXMLBytes { + fmt.Fprintf(os.Stderr, "Ошибка: -max-xml-mb должен быть от 1 до %d\n", usr.DefaultMaxXMLBytes/1_000_000) + return 2 + } + selectors := fs.Args() + if !explicit && len(selectors) == 0 { + selectors = nil // nil requests directory auto-scan from app.RunCommand + } + if !explicit && len(selectors) != 0 { + fmt.Fprintln(os.Stderr, "Ошибка: для указания файлов используйте команду decode или encode") + return 2 + } + if explicit && len(selectors) == 0 { + fmt.Fprintf(os.Stderr, "Ошибка: команда %s требует хотя бы один файл или маску\n", mode) + return 2 + } + result, err := app.RunCommand(app.Config{ + Dir: dir, + MaxXMLBytes: int64(maxXMLMB) * 1_000_000, + NoOverwrite: noOverwrite, + Stdout: os.Stdout, + Stderr: os.Stderr, + }, mode, selectors) + if err != nil { + fmt.Fprintf(os.Stderr, "Ошибка запуска: %v\n", err) + return 1 + } + return result.ExitCode() +} diff --git a/docs/USR_FORMAT.md b/docs/USR_FORMAT.md new file mode 100644 index 0000000..79334db --- /dev/null +++ b/docs/USR_FORMAT.md @@ -0,0 +1,167 @@ +# Формат файлов USR: хранение, распаковка и обратная упаковка + +**Проект:** `ric930-usrinfo` +**Версия документа:** 1.1 +**Дата:** 16 октября 2026 г. +**Статус:** техническая спецификация, восстановленная методом реверс-инжиниринга (**неофициальная**) + +## 1. Назначение и краткое описание + +Файл `.USR` данного типа содержит XML-документ, упакованный по схеме: + +```text + КОДИРОВАНИЕ ++---------------------+ +---------------------+ +----------------------+ +| XML: исходные байты | ---> | zlib (RFC 1950) | ---> | Побайтовое сложение | ---> .USR +| Windows-1251 | | DEFLATE (RFC 1951) | | по модулю 256 | ++---------------------+ +---------------------+ +----------------------+ + + ДЕКОДИРОВАНИЕ +.USR ---> обратное побайтовое вычитание ---> zlib inflate ---> XML (те же байты) +``` + +Содержимое `.USR` **не** является непосредственно XML, архивом ZIP или GZIP. После снятия обфускации получается **один полный поток zlib** — с заголовком, DEFLATE-данными и контрольной суммой Adler-32. + +Здесь (достоверно не подтверждено) собственных полей `magic`, номера версии, длины, соли, IV, ключа, MAC либо таблицы смещений, сохранённой внутри `.USR`. Весь файл, включая zlib-заголовок и контрольную сумму, обрабатывается одной арифметической операцией. **Размер USR равен размеру обфусцированного zlib-потока.** Все исследованные образцы не содержат данных после конца zlib-потока. + +В распакованном XML находятся данные о конфигурации установки «КонсультантПлюс»: версии компонентов, сведения о системе, составе поставки и информационных банках. Это **содержимое**, а не заголовок бинарного контейнера. + +## 2. Результаты исследования + +### 2.1 Установленные факты + +1. После арифметического преобразования файлов `.USR` получаются корректные потоки zlib, начинающиеся с `78 9C`. +2. zlib распаковывает оба потока без ошибки и с корректной Adler-32; после потока нет лишних байтов. +3. Полученные XML **побайтово совпадают** с эталонными файлами, расшифрованными оригинальной утилитой. +4. `ric930-usrinfo` распаковывает образцы идентично эталонам и осуществляет обратный переход `XML → USR → XML` **без потери XML-байтов**. Сами вновь созданные USR отличаются от эталонных по DEFLATE-представлению. + +### 2.2. Что **не** полуичлось подтвердить + +- Полнота поддержки всех поколений USR. +- Побайтовая идентичность USR, созданных произвольным zlib-компрессором, с USR, созданными оригинальной программой. + +## 3. Бинарная организация USR-контейнера + +Пусть `U[0..N-1]` — байты файла `.USR`; `Z[0..N-1]` — байты потока **zlib** после снятия обфускации; `X[0..M-1]` — исходные байты XML. + +```text +СМЕЩЕНИЯ В ФАЙЛЕ .USR: +0 N-1 ++-------------------------------------------------------------+ +| U[0] U[1] U[2] ... U[N-1] | ++-------------------------------------------------------------+ + каждый байт подвергнут обфускации + | + v +СМЕЩЕНИЯ В ВОССТАНОВЛЕННОМ ZLIB: +0 1 2 N-4 N-1 ++------+------+----------------------------------------+--------+ +| CMF | FLG | DEFLATE: сжатые XML-байты |ADLER32 | ++------+------+----------------------------------------+--------+ + 2 байта переменная длина 4 байта + | + zlib inflate + v + X[0..M-1] = байты XML +``` + +- **Нет собственного 4- или 8-байтового заголовка перед zlib:** операция начинается с `U[0]`, то есть с самого первого байта файла. +- **Нет отдельного хвоста USR после zlib:** последние четыре байта zlib являются Adler-32, а в файле USR они тоже обфусцированы. +- **Нет независимого поля длины XML в контейнере:** размер результата определяется распаковкой. +- Порядок байтов самой обфускации не зависит от endian архитектуры; **Adler-32** внутри zlib хранится в big-endian (старший байт первым). +- `.USR` следует обрабатывать как бинарный файл: нулевые байты, произвольные байтовые значения и кодировки текстовых файлов здесь не имеют значения. + +Важно: `99 EF` встречается в начале **ВСЕХ** примеров файлов, поскольку обфусцирован заголовок `78 9C`. Это **не универсальная сигнатура всех USR**: другие корректные заголовки zlib могут дать другие первые два байта. + +## 4. Побайтовая обфускация: точная формальная спецификация + +### 4.1. Константы + +| Параметр | Hex | Decimal | Значение | +|---|---|---:|---| +| Начальное смещение `A` | `0x21` | 33 | Первый аддитивный байт | +| Шаг `B` | `0x32` | 50 | Приращение после каждого байта | +| Модуль | `0x100` | 256 | Арифметика беззнакового байта | +| Начальный индекс | `0x00` | 0 | Отсчёт от начала файла | + +Для индекса `i = 0, 1, ..., N-1`: + +**Декодирование (USR → zlib):** + +```text +K(i) = (0x21 + 0x32 * i) mod 256 +Z[i] = (U[i] - K(i)) mod 256 +``` + +**Кодирование (zlib → USR):** + +```text +K(i) = (0x21 + 0x32 * i) mod 256 +U[i] = (Z[i] + K(i)) mod 256 +``` + +Рекуррентная запись, удобная для потоковой реализации: + +```text +offset = 0x21 +для каждого байта: + decode: z = uint8(u - offset) + encode: u = uint8(z + offset) + offset = uint8(offset + 0x32) +``` + +**Ключевой момент:** `offset` **нельзя сбрасывать при чтении очередного буфера**; он обновляется непрерывно от начала файла до его конца. Индекс также начинается заново при обработке **следующего файла**. + +### 4.2. Период последовательности + +Поскольку `gcd(50, 256) = 2`, наименьший период равен `256 / 2 = 128`: + +```text +K(i + 128) = K(i) +``` + +Первые смещения: + +```text +21 53 85 B7 E9 1B 4D 7F B1 E3 15 47 79 AB DD 0F ... (hex) +``` + +## 5. Формат потока zlib внутри USR + +После снятия обфускации применяется **zlib** по RFC 1950, использующий **DEFLATE** по RFC 1951. + +### 5.1. Структура zlib + +```text +CMF (1 байт) | FLG (1 байт) | [DICTID при FDICT=1] | DEFLATE | ADLER32 (4 байта) +``` + +### 5.2. Содержимое DEFLATE + +DEFLATE использует повторяющиеся последовательности (LZ77) и коды Хаффмана. Он может содержать несколько блоков разных типов (без сжатия, с фиксированными или динамическими кодами). Подробный битовый формат определяется RFC 1951. + +### 5.3. Adler-32 и контроль целостности + +Последние **4 байта восстановленного zlib-потока** содержат Adler-32 **распакованных XML-байтов** в порядке big-endian: + +```text +s1 = 1 +s2 = 0 +for each x in XML bytes: + s1 = (s1 + x) % 65521 + s2 = (s2 + s1) % 65521 +Adler32 = (s2 << 16) | s1 +``` + +Adler-32 помогает обнаруживать **случайное повреждение** при хранении и передаче. + + +## 6. Использованные материалы и нормативные ссылки + +**Стандарты и справочная документация:** + +- RFC 1950, *ZLIB Compressed Data Format Specification version 3.3*: +- RFC 1951, *DEFLATE Compressed Data Format Specification version 1.3*: +- Go `compress/zlib`: +- Go `compress/flate`: +- Go `encoding/xml`: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f575bc5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ric930-usrinfo + +go 1.22 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c766254 --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/usr/decode.go b/internal/usr/decode.go new file mode 100644 index 0000000..0202b8d --- /dev/null +++ b/internal/usr/decode.go @@ -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 +} diff --git a/internal/usr/encode.go b/internal/usr/encode.go new file mode 100644 index 0000000..aeb6028 --- /dev/null +++ b/internal/usr/encode.go @@ -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 +} diff --git a/internal/xmlcheck/cp1251.go b/internal/xmlcheck/cp1251.go new file mode 100644 index 0000000..a3f2fa7 --- /dev/null +++ b/internal/xmlcheck/cp1251.go @@ -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) + } +} diff --git a/internal/xmlcheck/validate.go b/internal/xmlcheck/validate.go new file mode 100644 index 0000000..e2d343d --- /dev/null +++ b/internal/xmlcheck/validate.go @@ -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 +}