First commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/dist/
|
||||
*.exe
|
||||
*.test
|
||||
@@ -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/'
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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*: <https://www.rfc-editor.org/rfc/rfc1950>
|
||||
- RFC 1951, *DEFLATE Compressed Data Format Specification version 1.3*: <https://www.rfc-editor.org/rfc/rfc1951>
|
||||
- Go `compress/zlib`: <https://pkg.go.dev/compress/zlib>
|
||||
- Go `compress/flate`: <https://pkg.go.dev/compress/flate>
|
||||
- Go `encoding/xml`: <https://pkg.go.dev/encoding/xml>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user