Коммит dd57fe7f создал по автору ad's avatar ad
Просмотр файлов

release: version 0.8.1

владелец 054fb3bd
......@@ -14,6 +14,10 @@ require:
- rubocop-minitest
- rubocop-performance
Naming/FileName:
Exclude:
- 'lib/obfuscator-rb.rb'
Style/StringLiterals:
Enabled: true
EnforcedStyle: single_quotes
......@@ -29,7 +33,7 @@ Layout/HashAlignment:
EnforcedColonStyle: table
Metrics/ClassLength:
Max: 140
Max: 250
Exclude:
- "test/*"
......@@ -40,7 +44,7 @@ Metrics/MethodLength:
Max: 60
Metrics/AbcSize:
Max: 40
Max: 45
Exclude:
- "test/*"
......@@ -49,3 +53,15 @@ Metrics/PerceivedComplexity:
Metrics/CyclomaticComplexity:
Max: 30
Minitest/MultipleAssertions:
Max: 6
Performance/ChainArrayAllocation:
Enabled: true
Style/MultilineBlockChain:
Enabled: true
Performance/IoReadlines:
Enabled: true
## [Unreleased]
# Changelog
All notable changes to this project will be documented in this file.
## [0.1.0] - 2025-02-03
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.1] - 2024-02-23
### Changed
- Updated RELEASE_CHECKLIST.md with Git workflow guide
## [0.8.0] - 2024-02-23
### Changed
- Enhanced top-level documentation with comprehensive NumberObfuscator examples
- Improved bilingual documentation symmetry in README
- Updated gem description to better reflect all features
- Made thread safety warnings more prominent
### Fixed
- Corrected and standardized examples across both languages in README
- Fixed missing US format examples in Russian documentation
- Fixed missing short format examples in English documentation
- Synchronized error handling examples between languages
## [0.7.0] - 2024-02-22
### Changed
- Clarified thread safety requirements in documentation
- Updated gem description to better reflect current capabilities
- Enhanced documentation with bilingual thread safety guidelines
### Fixed
- Corrected misleading thread safety claims in documentation
- Updated examples to show proper instance usage in concurrent scenarios
- Fixed DateObfuscator examples to match actual implementation
## [0.6.0] - 2024-02-22
### Added
- Support for IP-like number sequences (e.g., "21.11.234.23")
- Configurable unsigned mode for NumberObfuscator
- Improved decimal precision handling in float obfuscation
### Fixed
- Resolved repeating patterns in seeded number generation
- Fixed decimal places preservation in float obfuscation
- Corrected handling of leading zeros with format preservation
- Fixed sign handling in unsigned mode
### Changed
- Enhanced number generation algorithm for better randomization
- Improved format preservation for complex number patterns
- Optimized RNG state management for consistent results
## [0.5.0] - 2024-02-21
### Added
- Performance optimizations for NumberObfuscator with large numbers
- Added stress test mode for extreme number handling
- Improved test coverage for edge cases in number obfuscation
- Support for US date formats in DateObfuscator
- ISO full datetime format support
### Changed
- Adjusted number magnitude checks in tests for better reliability
- Made extreme number tests optional via STRESS_TEST environment variable
- Enhanced DateObfuscator format presets
### Fixed
- Performance issues with Float::MAX/MIN handling in NumberObfuscator
- DateObfuscator now properly handles seeds with different input dates
- Added combined seed calculation to ensure unique but reproducible results for different dates
- Added test coverage for seed behavior with different inputs
## [0.4.0] - 2024-02-21
### Added
- NumberObfuscator class for handling numeric content
- Support for Cyrillic alphabet in mixed content with numbers
- Format preservation for decimal and thousand separators
- Leading zeros handling with configuration option
- UTF-8 encoding support for mixed content
- Improved number format preservation
### Fixed
- Float decimal places preservation
- Character range issues with Cyrillic letters
- Length preservation in formatted numbers
- Mixed content handling with Unicode properties
### Changed
- Enhanced documentation with bilingual number obfuscation examples
- More robust number format preservation
- Improved mixed content handling
## [0.3.2] - 2025-02-07
### Added
- Test coverage for the Naturalizer class core functionality
- Test for sequential deterministic behavior in DateObfuscator
### Fixed
- Restored proper deterministic behavior for DateObfuscator and Naturalizer when using seed
- Fixed test naming to follow Ruby conventions (ASCII identifiers)
- Renamed the `test_obfuscator.rb` file into `obfuscator_test.rb` to follow Ruby conventions
## [0.3.1] - 2025-02-07
### Fixed
- Restored proper deterministic behavior for sequential obfuscation calls when using seed
- Fixed language detection for capitalized words
- Added test coverage for sequential determinism with seeds
## [0.3.0] - 2025-02-07
### Changed
- Renamed gem from 'obfuscator' to 'obfuscator-rb' for RubyGems.org publication
- Restructured main entry point for better gem compatibility
- Updated documentation to reflect new gem name
## [0.2.0] - 2025-02-06
### Added
- DateObfuscator class for handling date obfuscation
- Support for various date formats (EU, ISO, Russian)
- Configurable date constraints (year range, month/weekday preservation)
- Random number generation, array and range sampling helper methods
### Changed
- Refactored internal RNG handling for better maintainability and consistency
- Fixed seed handling to ensure proper reproducibility of results
## [0.1.0] - 2025-02-03
### Added
- Initial release
......@@ -3,7 +3,7 @@
source 'https://rubygems.org'
# Specify your gem's dependencies in obfuscator.gemspec
gemspec name: 'obfuscator'
gemspec name: 'obfuscator-rb'
gem 'rake', '~> 13.0'
......
# Obfuscator
[![Gem Version](https://badge.fury.io/rb/obfuscator-rb.svg)](https://badge.fury.io/rb/obfuscator-rb)
[Русский](#русский) | [English](#english)
## Русский
Ruby-гем для обфускации текста. Сохраняет структуру, заменяя содержимое бессмысленными словами, сохраняющими при этом
естественный вид исходного текста. Поддерживает русский и английский языки.
Ruby-гем для обфускации данных с сохранением их структуры и формата. Поддерживает:
- Текст на русском и английском языках
- Числа (включая IP-адреса и смешанный контент)
- Даты в различных форматах
- Воспроизводимые результаты через seed
### Установка
Добавьте эту строку в Gemfile вашего приложения:
```ruby
gem 'obfuscator', git: 'https://hub.mos.ru/ad/obfuscator.git'
gem 'obfuscator-rb'
```
И выполните:
......@@ -24,45 +29,124 @@ $ bundle install
Или установите самостоятельно:
```bash
$ gem install obfuscator
$ gem install obfuscator-rb
```
Установка из репозитория [hub.mos.ru](https://hub.mos.ru/ad/obfuscator):
```bash
$ gem install obfuscator-rb --source https://hub.mos.ru/ad/obfuscator.git
```
Код также доступен на [GitHub](https://github.com/amdest/obfuscator) и [Gitverse](https://gitverse.ru/ad-it/obfuscator).
### Возможности
- Сохраняет структуру текста (пунктуация, пробелы, регистр)
- Сохраняет длину слов (если не включена натурализация)
- Поддерживает несколько режимов обфускации
- Опциональная натурализация текста по некоторым простым правилам
- Необратимая трансформация текста
- Детерминированный вывод при использовании сида
- Полная поддержка UTF-8
- Обеспечена обработка ошибок определённых типов данных
#### Обфускация чисел
- Сохраняет формат и структуру чисел (десятичные, тысячные разделители)
- Обрабатывает IP-подобные последовательности (например, "21.11.234.23")
- Поддерживает сохранение ведущих нулей (настраиваемо)
- Предоставляет режим для работы только с положительными числами
- Сохраняет точность десятичных дробей
- Обрабатывает смешанный контент с числами и текстом
- Поддерживает латинский и кириллический алфавиты
#### Обфускация дат
- Поддерживает множество форматов дат через пресеты (ISO, EU, US, русский)
- Сохраняет структуру и валидность формата
- Настраиваемые ограничения:
- Диапазон допустимых лет
- Сохранение месяца
- Сохранение дня недели
- Обрабатывает полный формат ISO с временной зоной
#### Общие возможности
- Детерминированный вывод с опциональным сидом
- Обфускация с сохранением формата
- Поддержка кодировки UTF-8
- Комплексная обработка ошибок
- Эффективное использование памяти
#### Многопоточность
Отдельные экземпляры обфускаторов не являются потокобезопасными. Для многопоточных операций:
- Создавайте отдельный экземпляр для каждого потока
- Не используйте один экземпляр в разных потоках
- Каждый экземпляр поддерживает свое собственное состояние RNG
### Использование
```ruby
require 'obfuscator'
require 'obfuscator-rb'
# Базовое использование (режим :direct по умолчанию)
# Обфускация текста (режим :direct по умолчанию)
obfuscator = Obfuscator::Multilang.new
text = "Hello, Привет! This is a TEST текст."
result = obfuscator.obfuscate(text)
# => Каждое слово обфусцируется с использованием исходного алфавита
# => "Idise, Кющэшэ! Izib oq g MUGU дипяд."
# Смешанный режим с обоими алфавитами
obfuscator = Obfuscator::Multilang.new(mode: :mixed)
result = obfuscator.obfuscate(text)
# => Слова могут содержать и латинские, и кириллические символы
# => "Fаyef, Фeфeгю! Muci лi r HЫЛO ицижё."
# С натурализацией по простым правилам для более естественного вывода
obfuscator = Obfuscator::Multilang.new(mode: :mixed, naturalize: true)
result = obfuscator.obfuscate(text)
# => Вывод обрабатывается для более естественного вида
# => "Ohеsion, Wорыой! Наvы мe л ЛУНI yeзing."
# С сидом для воспроизводимых результатов
obfuscator = Obfuscator::Multilang.new(seed: 12345)
result = obfuscator.obfuscate(text)
# => Одинаковый ввод + одинаковый сид = одинаковый вывод
# => "Cumic, Фяцёне! Okac ub h POWO щюзёс."
# Обфускация чисел
obfuscator = Obfuscator::NumberObfuscator.new
obfuscator.obfuscate(123.45) # => 567.89
obfuscator.obfuscate("1 234,56") # => "5 678,91"
obfuscator.obfuscate("192.168.1.1") # => "234.567.8.9"
# С настройками
obfuscator = Obfuscator::NumberObfuscator.new(
preserve_leading_zeros: false, # Не сохранять ведущие нули
unsigned: true, # Убрать знаки минус
seed: 12345 # Для воспроизводимых результатов
)
obfuscator.obfuscate("0042") # => "7391"
obfuscator.obfuscate("-123") # => "456"
# Обфускация даты
obfuscator = Obfuscator::DateObfuscator.new
obfuscator.obfuscate('2024-03-15') # => "2025-08-23"
# Разные форматы дат
eu = Obfuscator::DateObfuscator.new(format: :eu) # Европейский формат
eu.obfuscate('15.03.2024') # => "23.08.2025"
us = Obfuscator::DateObfuscator.new(format: :us) # Американский формат
us.obfuscate('03/15/2024') # => "08/23/2025"
rus_short = Obfuscator::DateObfuscator.new(format: :rus_short) # Короткий формат
rus_short.obfuscate('15.03.24') # => "23.08.25"
# С ограничениями
constrained = Obfuscator::DateObfuscator.new(
format: :rus,
constraints: {
min_year: 2020, # Не генерировать даты раньше 2020
max_year: 2025, # Не генерировать даты позже 2025
preserve_month: true, # Сохранять месяц
preserve_weekday: true # Сохранять день недели
}
)
constrained.obfuscate('15.03.2024') # => "14.03.2025" (тот же месяц, тот же день недели)
# С сидом для воспроизводимых результатов
seeded = Obfuscator::DateObfuscator.new(format: :rus, seed: 12345)
seeded.obfuscate('15.03.2024') # => "21.07.2025" (постоянный результат)
```
### Доступные режимы
......@@ -71,30 +155,24 @@ result = obfuscator.obfuscate(text)
- `:eng_to_eng` - только английский в английский
- `:rus_to_rus` - только русский в русский
- `:swapped` - английский в русский и наоборот
- `:mixed` - использует оба алфавита (просто ради прикола)
### Обработка входных данных
- `:mixed` - использует оба алфавита
Обфускатор обрабатывает разлличные типы данных:
### Доступные пресеты для даты
- `nil` → возвращает nil
- Числа → возвращаются без изменений
- Объекты с методом `to_s` → обрабатываются нормально
- Объекты без базовых методов Ruby → вызывают `InputError`
- Неверные кодировки → вызывают `EncodingError`
- `:eu` => `'%d.%m.%Y'` - европейский формат (31.12.2023)
- `:eu_short` => `'%d.%m.%y'` - короткий европейский формат (31.12.23)
- `:rus` => `'%d.%m.%Y'` - русский формат (31.12.2023)
- `:rus_short` => `'%d.%m.%y'` - короткий русский формат (31.12.23)
- `:iso` => `'%Y-%m-%d'` - формат ISO (2023-12-31)
- `:us` => `'%m/%d/%Y'` - американский формат (12/31/2023)
- `:us_short` => `'%m/%d/%y'` - короткий американский формат (12/31/23)
- `:iso_full` => `'%Y-%m-%dT%H:%M:%S%z'` - полный формат ISO (2023-12-31T00:00:00+00:00)
#### Типы ошибок
- `Obfuscator::Error` - Базовый класс ошибок гема
- `Obfuscator::InputError` - Возникает при неверном типе входных данных
- `Obfuscator::EncodingError` - Возникает при проблемах с кодировкой
#### Пример использования с обработкой ошибок
### Обработка ошибок
```ruby
begin
obfuscator.obfuscate(текст)
obfuscator.obfuscate(text)
rescue Obfuscator::InputError => e
# Обработка неверного типа входных данных
puts "Неверный тип данных: #{e.message}"
......@@ -109,15 +187,18 @@ end
## English
A Ruby gem for text obfuscation that preserves text structure while replacing content with meaningless but
natural-looking words. Supports both English and Russian languages.
A Ruby gem for data obfuscation that preserves structure and format. Supports:
- Text in both English and Russian
- Numbers (including IP addresses and mixed content)
- Dates in various formats
- Reproducible results via seeding
### Installation
Add this line to your application's Gemfile:
```ruby
gem 'obfuscator', git: 'https://hub.mos.ru/ad/obfuscator.git'
gem 'obfuscator-rb'
```
And then execute:
......@@ -129,45 +210,124 @@ $ bundle install
Or install it yourself as:
```bash
$ gem install obfuscator
$ gem install obfuscator-rb
```
Install from [hub.mos.ru](https://hub.mos.ru/ad/obfuscator):
```bash
$ gem install obfuscator-rb --source https://hub.mos.ru/ad/obfuscator.git
```
The code is also available on [GitHub](https://github.com/amdest/obfuscator) and [Gitverse](https://gitverse.ru/ad-it/obfuscator).
### Features
- Preserves text structure (punctuation, spacing, capitalization)
- Maintains word lengths (unless naturalization is enabled)
- Supports multiple obfuscation modes
- Optional text naturalization according to some basic rules
- Irreversible transformation
- Deterministic output with seeds
- Full UTF-8 support
- Comprehensive error handling with specific error types
#### Number Obfuscation
- Preserves number format and structure (decimals, thousand separators)
- Handles IP-like sequences (e.g., "21.11.234.23")
- Supports leading zeros preservation (configurable)
- Provides unsigned mode for positive-only output
- Maintains consistent decimal precision
- Processes mixed content with numbers and text
- Supports both Latin and Cyrillic alphabets
#### Date Obfuscation
- Supports multiple date formats through presets (ISO, EU, US, Russian)
- Preserves format structure and validity
- Configurable constraints:
- Year range limits
- Month preservation
- Weekday preservation
- Handles full ISO datetime format with timezone
#### General Features
- Deterministic output with optional seeding
- Format-preserving obfuscation
- UTF-8 encoding support
- Comprehensive error handling
- Memory-efficient processing
#### Thread Safety
Individual obfuscator instances are NOT thread-safe. For concurrent operations:
- Create separate instances per thread
- Do not share instances across threads
- Each instance maintains its own RNG state
### Usage
```ruby
require 'obfuscator'
require 'obfuscator-rb'
# Basic usage (default :direct mode)
# Text obfuscation (default :direct mode)
obfuscator = Obfuscator::Multilang.new
text = "Hello, Привет! This is a TEST текст."
result = obfuscator.obfuscate(text)
# => Each word is obfuscated using its source alphabet
# => "Idise, Кющэшэ! Izib oq g MUGU дипяд."
# Mixed mode with both alphabets
obfuscator = Obfuscator::Multilang.new(mode: :mixed)
result = obfuscator.obfuscate(text)
# => Words may contain both Latin and Cyrillic characters
# => "Fаyef, Фeфeгю! Muci лi r HЫЛO ицижё."
# With basic naturalization for more natural-looking output
obfuscator = Obfuscator::Multilang.new(mode: :mixed, naturalize: true)
result = obfuscator.obfuscate(text)
# => Output is processed to look more natural
# => "Ohеsion, Wорыой! Наvы мe л ЛУНI yeзing."
# With seed for reproducible results
obfuscator = Obfuscator::Multilang.new(seed: 12345)
result = obfuscator.obfuscate(text)
# => Same input + same seed = same output
# => "Cumic, Фяцёне! Okac ub h POWO щюзёс."
# Number obfuscation
obfuscator = Obfuscator::NumberObfuscator.new
obfuscator.obfuscate(123.45) # => 567.89
obfuscator.obfuscate("1,234.56") # => "5,678.91"
obfuscator.obfuscate("192.168.1.1") # => "234.567.8.9"
# With configuration
obfuscator = Obfuscator::NumberObfuscator.new(
preserve_leading_zeros: false, # Don't keep leading zeros
unsigned: true, # Remove minus signs
seed: 12345 # For reproducible results
)
obfuscator.obfuscate("0042") # => "7391"
obfuscator.obfuscate("-123") # => "456"
# Date obfuscation
obfuscator = Obfuscator::DateObfuscator.new
obfuscator.obfuscate('2024-03-15') # => "2025-08-23"
# Different date formats
eu = Obfuscator::DateObfuscator.new(format: :eu) # European format
eu.obfuscate('15.03.2024') # => "23.08.2025"
us = Obfuscator::DateObfuscator.new(format: :us) # US format
us.obfuscate('03/15/2024') # => "08/23/2025"
rus_short = Obfuscator::DateObfuscator.new(format: :rus_short) # Short format
rus_short.obfuscate('15.03.24') # => "23.08.25"
# With constraints
constrained = Obfuscator::DateObfuscator.new(
format: :iso,
constraints: {
min_year: 2020, # Don't generate dates before 2020
max_year: 2025, # Don't generate dates after 2025
preserve_month: true, # Keep the same month
preserve_weekday: true # Keep the same day of week
}
)
constrained.obfuscate('2024-03-15') # => "2025-03-14" (same month, same weekday)
# With seed for reproducible results
seeded = Obfuscator::DateObfuscator.new(seed: 12345)
seeded.obfuscate('2024-03-15') # => "2025-07-21" (consistent output)
```
### Available Modes
......@@ -176,39 +336,33 @@ result = obfuscator.obfuscate(text)
- `:eng_to_eng` - English to English only
- `:rus_to_rus` - Russian to Russian only
- `:swapped` - English to Russian and vice versa
- `:mixed` - uses both alphabets (just for fun)
### Input Handling
- `:mixed` - uses both alphabets
The obfuscator handles various input types:
### Available Date Presets
- `nil` → returns nil
- Numbers → returns unchanged
- Objects responding to `to_s` → processes normally
- Objects without basic Ruby methods → raises `InputError`
- Invalid encodings → raises `EncodingError`
- `:eu` => `'%d.%m.%Y'` - European format (31.12.2023)
- `:eu_short` => `'%d.%m.%y'` - Short European format (31.12.23)
- `:rus` => `'%d.%m.%Y'` - Russian format (31.12.2023)
- `:rus_short` => `'%d.%m.%y'` - Short Russian format (31.12.23)
- `:iso` => `'%Y-%m-%d'` - ISO format (2023-12-31)
- `:us` => `'%m/%d/%Y'` - US format (12/31/2023)
- `:us_short` => `'%m/%d/%y'` - Short US format (12/31/23)
- `:iso_full` => `'%Y-%m-%dT%H:%M:%S%z'` - Full ISO format (2023-12-31T00:00:00+00:00)
#### Error Types
- `Obfuscator::Error` - Base error class for the gem
- `Obfuscator::InputError` - Raised for invalid input types
- `Obfuscator::EncodingError` - Raised for encoding-related issues
#### Example Usage with Error Handling
### Error Handling
```ruby
begin
obfuscator.obfuscate(text)
rescue Obfuscator::InputError => e
# Handle invalid input types
puts "Invalid input: #{e.message}"
puts "Invalid input type: #{e.message}"
rescue Obfuscator::EncodingError => e
# Handle encoding issues
puts "Encoding error: #{e.message}"
rescue Obfuscator::Error => e
# Handle other obfuscation errors
puts "Obfuscation failed: #{e.message}"
puts "Obfuscation error: #{e.message}"
end
```
......
# Release Checklist
## Initial Setup
### RubyGems Configuration
#### Option 1: Credentials File (Recommended)
1. Create/edit `~/.gem/credentials`:
```yaml
---
:rubygems_api_key: your_api_key_here
```
2. Set proper permissions:
```bash
chmod 600 ~/.gem/credentials
```
#### Option 2: Environment Variable
Set `RUBYGEMS_API_KEY` environment variable:
```bash
export RUBYGEMS_API_KEY=your_api_key_here
```
### First-time Push with MFA
1. Get your MFA code ready
2. Push the gem:
```bash
gem push pkg/obfuscator-rb-X.Y.Z.gem --otp YOUR_MFA_CODE
```
### Common RubyGems CLI commands
```bash
# Build gem with the gemspec file
gem build obfuscator-rb.gemspec # output: obfuscator-rb-X.Y.Z.gem
# Build gem with the Rakefile
rake build # output: pkg/obfuscator-rb-X.Y.Z.gem
# Push a new version of the gem
gem push pkg/obfuscator-rb-X.Y.Z.gem --otp YOUR_MFA_CODE
# Yank a version of the gem
gem yank obfuscator-rb -v X.Y.Z --otp YOUR_MFA_CODE
# List owned gems
gem list -r obfuscator-rb
# Check gem details
gem info obfuscator-rb
# Install a specific version of the gem locally
gem install ./obfuscator-rb-X.Y.Z.gem
```
## Pre-release Checks
### 1. Code Quality
- [ ] Run full test suite:
```bash
bundle exec rake test
```
- [ ] Run Rubocop:
```bash
bundle exec rubocop
```
### 2. Version Update
- [ ] Update version in `lib/obfuscator/version.rb`
- [ ] Update CHANGELOG.md
- Add new version section
- Document all changes under appropriate categories
- Add release date
### 3. Local Build & Test
- [ ] Build gem using Rake (preferred):
```bash
bundle exec rake build
# Gem will be in pkg/obfuscator-rb-X.Y.Z.gem
```
OR build directly:
```bash
gem build obfuscator-rb.gemspec
```
- [ ] Install and test locally:
```bash
gem install pkg/obfuscator-rb-X.Y.Z.gem
# OR if built directly:
# gem install ./obfuscator-rb-X.Y.Z.gem
```
- [ ] Verify in IRB:
```ruby
require 'obfuscator-rb'
# Test basic functionality
```
### 4. Documentation Check
- [ ] Review gemspec
- Version number
- Dependencies
- Metadata
- [ ] Check README is up to date
- [ ] Verify YARD documentation if changed
## Release Process
### 5. Version Control
- [ ] Commit changes:
```bash
git add .
git commit -m "feat: release X.Y.Z"
```
- [ ] Create version tag:
```bash
git tag -a vX.Y.Z -m "Release X.Y.Z"
```
- [ ] Push changes and tags:
```bash
git push origin master
git push origin vX.Y.Z
```
### 6. Publish
- [ ] Push to RubyGems:
```bash
gem push pkg/obfuscator-rb-X.Y.Z.gem --otp YOUR_MFA_CODE
```
- [ ] Verify gem page on RubyGems.org
## Post-release
- [ ] Clean up local gem files:
```bash
rm -rf pkg/
# OR if built directly:
# rm obfuscator-rb-*.gem
```
- [ ] Announce release if significant
- [ ] Update any related documentation or wiki pages
# Git Workflow Guide
## Branch Structure
- `master`: Release branch, contains only tagged releases
- `dev`: Main development branch (local only)
- Feature branches: Created from and merged back to `dev`
## Regular Development Flow
1. Start work from dev:
```bash
git checkout dev
git pull origin master # sync with latest release if needed
git checkout -b feat/my-feature
```
2. Make changes and commit:
```bash
git add .
git commit -m "feat: description"
```
3. Merge back to dev:
```bash
git checkout dev
git merge feat/my-feature
git branch -d feat/my-feature
```
## Release Process
1. Prepare release in dev:
```bash
git checkout dev
# Update version and CHANGELOG
git commit -m "chore: prepare release X.Y.Z"
```
2. Create release:
```bash
git checkout master
git merge --squash dev # squash all dev commits
git commit -m "release: version X.Y.Z"
git tag -a vX.Y.Z -m "version X.Y.Z"
```
3. Push release:
```bash
git push origin master --tags
```
4. Update dev:
```bash
git checkout dev
git merge master # keep dev in sync with latest release
```
\ Нет новой строки в конце файла
......@@ -2,7 +2,7 @@
# frozen_string_literal: true
require 'bundler/setup'
require 'obfuscator'
require 'obfuscator-rb'
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
......
# frozen_string_literal: true
# Obfuscator is a text obfuscation library that preserves text structure while replacing content
# with meaningless but natural-looking words. It supports both English and Russian languages,
# as well as numbers and dates with format preservation.
#
# The gem provides three main obfuscators:
# - {Multilang} for text obfuscation with multiple language support
# - {NumberObfuscator} for number and mixed content obfuscation
# - {DateObfuscator} for date obfuscation with format preservation
#
# THREAD SAFETY:
# Individual obfuscator instances are NOT thread-safe. For concurrent operations:
# - Create separate instances per thread
# - Do not share instances across threads
# - Each instance maintains its own RNG state
#
# @example Basic text obfuscation
# require 'obfuscator-rb'
#
# obfuscator = Obfuscator::Multilang.new
# obfuscator.obfuscate("Hello, World!") # => "Kites, Mefal!"
#
# @example Number obfuscation with format preservation
# num_obf = Obfuscator::NumberObfuscator.new
# num_obf.obfuscate(123.45) # => 567.89
# num_obf.obfuscate("1,234.56") # => "5,678.91"
# num_obf.obfuscate("192.168.1.1") # => "234.567.8.9"
# num_obf.obfuscate("ABC-42XY") # => "DEF-73ZW"
#
# @example Date obfuscation with constraints
# date_obf = Obfuscator::DateObfuscator.new(
# format: :iso,
# constraints: {
# min_year: 2020,
# max_year: 2025,
# preserve_month: true
# }
# )
# date_obf.obfuscate("2023-12-31") # => "2025-12-15"
#
# @example Error handling
# begin
# obfuscator.obfuscate(input)
# rescue Obfuscator::InputError => e
# # Handle invalid input types
# rescue Obfuscator::EncodingError => e
# # Handle encoding issues
# rescue Obfuscator::Error => e
# # Handle other obfuscation errors
# end
#
# Error handling is provided through specific error classes:
# - {Error} Base error class for the gem
# - {InputError} Raised for invalid input types
# - {EncodingError} Raised for encoding-related issues
#
# @see Multilang For text obfuscation functionality
# @see NumberObfuscator For number and mixed content obfuscation
# @see DateObfuscator For date obfuscation functionality
# @see Internal::RNG For random number generation utilities
module Obfuscator
class Error < StandardError; end
class EncodingError < Error; end
class InputError < Error; end
# Base module for the Obfuscator gem.
#
# THREAD SAFETY:
# Individual obfuscator instances are NOT thread-safe. For concurrent operations:
# - Create separate instances per thread
# - Do not share instances across threads
# - Each instance maintains its own RNG state
#
# @example Thread-safe usage
# threads = 4.times.map do
# Thread.new do
# # Create a new instance for each thread
# obfuscator = Obfuscator::NumberObfuscator.new(seed: 12345)
# obfuscator.obfuscate("123.45")
# end
# end
end
require_relative 'obfuscator/version'
require_relative 'obfuscator/constants'
require_relative 'obfuscator/internal/rng'
require_relative 'obfuscator/naturalizer'
require_relative 'obfuscator/multilang'
require_relative 'obfuscator/date_obfuscator'
require_relative 'obfuscator/number_obfuscator'
# Usage example:
if __FILE__ == $PROGRAM_NAME
obfuscator = Obfuscator::Multilang.new(seed: 12_345)
original_text = 'Hello, Мир! This is a TEST текст.'
obfuscated = obfuscator.obfuscate(original_text)
puts "Original: #{original_text}"
puts "Obfuscated: #{obfuscated}" # 'Cumic, Фяц! Piwi ok c UBOH ричуг.'
end
# frozen_string_literal: true
require_relative 'obfuscator/version'
require_relative 'obfuscator/constants'
require_relative 'obfuscator/naturalizer'
require_relative 'obfuscator/multilang'
module Obfuscator
class Error < StandardError; end
class EncodingError < Error; end
class InputError < Error; end
end
# Usage example:
if __FILE__ == $PROGRAM_NAME
obfuscator = Obfuscator::Multilang.new(seed: 12_345)
original_text = 'Hello, Мир! This is a TEST текст.'
obfuscated = obfuscator.obfuscate(original_text)
puts "Original: #{original_text}"
puts "Obfuscated: #{obfuscated}" # 'Cumic, Фяц! Piwi ok c UBOH ричуг.'
end
# frozen_string_literal: true
require 'date'
require_relative 'internal/rng'
module Obfuscator
# Class for obfuscating dates while preserving their format and optionally some properties.
#
# Supports various date formats through presets or custom format strings.
# Can preserve certain date characteristics (month, weekday) and respect year constraints.
# All generated dates are valid - for example, it won't generate February 31st.
#
# @example Basic usage with preset format
# obfuscator = DateObfuscator.new
# obfuscator.obfuscate('2023-12-31') # => "2025-07-15"
#
# @example With custom format string
# obfuscator = DateObfuscator.new(format: '%Y-%m-%d')
# obfuscator.obfuscate('2023-12-31') # => "2025-07-15"
#
# @example With constraints
# obfuscator = DateObfuscator.new(
# constraints: {
# min_year: 2020, # Minimum year to generate
# max_year: 2025, # Maximum year to generate
# preserve_month: true, # Keep the same month
# preserve_weekday: true # Keep the same day of week
# }
# )
#
# @example With seed for reproducible results
# obfuscator = DateObfuscator.new(seed: 12345)
# obfuscator.obfuscate('2023-12-31') # => Same result for same seed
#
# Available preset formats:
# - :eu => '%d.%m.%Y' # 31.12.2023
# - :eu_short => '%d.%m.%y' # 31.12.23
# - :rus => '%d.%m.%Y' # 31.12.2023
# - :rus_short => '%d.%m.%y' # 31.12.23
# - :iso => '%Y-%m-%d' # 2023-12-31
# - :us => '%m/%d/%Y' # 12/31/2023
# - :us_short => '%m/%d/%y' # 12/31/23
# - :iso_full => '%Y-%m-%dT%H:%M:%S%z' # 2023-12-31T00:00:00+00:00
#
# @param format [Symbol, String] Preset format name or custom format string (default: :iso)
# @param seed [Integer, nil] Optional seed for reproducible results
# @param constraints [Hash] Optional constraints for date generation
# @option constraints [Integer] :min_year Minimum year to generate (default: 2000)
# @option constraints [Integer] :max_year Maximum year to generate (default: 2030)
# @option constraints [Boolean] :preserve_month Keep the same month (default: false)
# @option constraints [Boolean] :preserve_weekday Keep the same day of week (default: false)
#
# @raise [Error] If date string is invalid or doesn't match the format
class DateObfuscator
include Internal::RNG
PRESET_FORMATS = {
eu: '%d.%m.%Y', # 31.12.2023
eu_short: '%d.%m.%y', # 31.12.23
rus: '%d.%m.%Y', # 31.12.2023
rus_short: '%d.%m.%y', # 31.12.23
iso: '%Y-%m-%d', # 2023-12-31
us: '%m/%d/%Y', # 12/31/2023
us_short: '%m/%d/%y', # 12/31/23
iso_full: '%Y-%m-%dT%H:%M:%S%z' # 2023-12-31T00:00:00+00:00
}.freeze
def initialize(format: :iso, seed: nil, constraints: {})
@seed = seed # Store the seed
setup_rng(seed)
@format = PRESET_FORMATS[format] || format
@constraints = default_constraints.merge(constraints)
end
def obfuscate(date_string)
return date_string if !date_string.is_a?(Date) && (date_string.nil? || date_string.empty?)
begin
date = date_string.is_a?(Date) ? date_string : ::Date.strptime(date_string, @format)
# Create a unique seed based on both the original seed and input date
if @seed
combined_seed = [@seed, date.to_time.to_i].hash
setup_rng(combined_seed)
end
obfuscated_date = generate_date(date)
obfuscated_date.strftime(@format)
rescue ArgumentError => e
raise Error, "Invalid date or format: #{e.message}"
end
end
private
def default_constraints
{
min_year: 2000,
max_year: 2030,
preserve_month: false,
preserve_weekday: false
}
end
def generate_date(original_date)
year = random_year
month = @constraints[:preserve_month] ? original_date.month : random_month
day = random_day(year, month, original_date)
::Date.new(year, month, day)
end
def random_year
random_range(@constraints[:min_year]..@constraints[:max_year])
end
def random_month
random_range(1..12)
end
def random_day(year, month, original_date)
days_in_month = ::Date.new(year, month, -1).day
if @constraints[:preserve_weekday]
# Find a day that falls on the same weekday
target_weekday = original_date.wday
possible_days = (1..days_in_month).select do |d|
::Date.new(year, month, d).wday == target_weekday
end
random_sample(possible_days)
else
random_range(1..days_in_month)
end
end
end
end
# frozen_string_literal: true
module Obfuscator
module Internal
# Internal module providing Random Number Generation functionality.
# This module is intended for internal use only and shouldn't be used directly by gem users.
#
# Provides consistent random number generation across the gem's classes,
# with optional seed support for reproducible results.
#
# @api private
#
# Usage:
# include Internal::RNG
#
# def initialize(seed = nil)
# setup_rng(seed)
# end
#
# private
#
# def some_method
# random_sample(some_array) # For array sampling
# random_probability # For random float between 0 and 1
# random_range(some_range) # For range sampling
# end
module RNG
private
def setup_rng(seed = nil)
@rng = seed.nil? ? Random.new : Random.new(seed)
end
def random_sample(array)
raise ArgumentError, "Expected Array, got #{array.class}" unless array.is_a?(Array)
raise ArgumentError, 'Array cannot be empty' if array.empty?
array.sample(random: @rng)
end
def random_probability
@rng.rand
end
def random_range(range)
raise ArgumentError, "Expected Range, got #{range.class}" unless range.is_a?(Range)
raise ArgumentError, 'Range cannot be infinite' if range.end.nil?
@rng.rand(range)
end
end
end
end
# frozen_string_literal: true
require_relative 'constants'
require_relative 'internal/rng'
require_relative 'naturalizer'
module Obfuscator
# A class responsible for obfuscating text in Russian and English languages.
#
# This class provides various modes for obfuscating text, including direct,
# swapped, and mixed modes. It also supports naturalization of the obfuscated text.
# This class provides various modes for obfuscating text while preserving the original
# text structure, whitespace, punctuation, and capitalization. The obfuscation can be
# performed in several modes and optionally naturalized to produce more readable output.
#
# Available modes:
# - MODE_DIRECT (default): Preserves language, replacing words with same-language random words
# - MODE_ENG_TO_ENG: Only obfuscates English words, leaves Russian untouched
# - MODE_RUS_TO_RUS: Only obfuscates Russian words, leaves English untouched
# - MODE_SWAPPED: Swaps languages (English→Russian and Russian→English)
# - MODE_MIXED: Generates words containing both English and Russian characters
#
# @example Basic usage
# obfuscator = Multilang.new
# obfuscator.obfuscate("Hello world!") # => "Kites mefal!"
#
# @example Using swapped mode with naturalization
# obfuscator = Multilang.new(mode: :swapped, naturalize: true)
# obfuscator.obfuscate("Hello мир!") # => "Привет world!"
#
# @param mode [Symbol] The obfuscation mode to use (default: MODE_DIRECT)
# @param seed [Integer, nil] Optional seed for reproducible results
# @param naturalize [Boolean] Whether to naturalize the output (default: false)
#
# @raise [InputError] If input doesn't respond to :to_s
# @raise [EncodingError] If input has invalid encoding
# @raise [Error] If obfuscation fails for any other reason
class Multilang
include Constants
include Internal::RNG
MODE_DIRECT = :direct # 1:1 obfuscation, the default
MODE_ENG_TO_ENG = :eng_to_eng # eng/rus → eng/rus untouched
......@@ -19,22 +45,20 @@ module Obfuscator
def initialize(mode: MODE_DIRECT, seed: nil, naturalize: false)
@mode = mode
@seed = seed
@naturalizer = if naturalize
naturalizer_rng = @seed.nil? ? Random.new : Random.new(@seed)
Naturalizer.new(naturalizer_rng)
end
@seed = seed # Store the seed
setup_rng(seed)
@naturalizer = Naturalizer.new(seed) if naturalize
end
def obfuscate(input)
# Reset RNG state before each obfuscation if seed was provided
setup_rng(@seed) if @seed
raise InputError, 'Input must respond to :to_s' unless input.respond_to?(:to_s)
return input if input.nil? || input.is_a?(Numeric)
text = input.to_s
# Reset RNG state for each call if seed was provided, create new if not
@rng = @seed.nil? ? Random.new : Random.new(@seed)
# Ensure UTF-8 encoding
begin
text = text.encode('UTF-8') unless text.encoding == Encoding::UTF_8
......@@ -45,7 +69,6 @@ module Obfuscator
# Split preserving all whitespace and punctuation
begin
tokens = text.split(/(\s+|[[:punct:]])/)
tokens.map do |token|
if token.match?(/\s+|[[:punct:]]/)
token # Preserve whitespace and punctuation
......@@ -96,9 +119,9 @@ module Obfuscator
end
def detect_language(word)
first_char = word.downcase[0]
return :russian if first_char.match?(/[а-яё]/)
return :english if first_char.match?(/[a-z]/)
first_char = word[0]
return :russian if first_char.match?(/[а-яёА-ЯЁ]/)
return :english if first_char.match?(/[a-zA-Z]/)
:unknown
end
......@@ -137,11 +160,11 @@ module Obfuscator
return '' if length.zero?
result = ''
is_vowel = @rng.rand < vowel_start_prob
is_vowel = random_probability < vowel_start_prob
while result.length < length
chars = is_vowel ? vowels : consonants
result += chars.sample(random: @rng)
result += random_sample(chars)
is_vowel = !is_vowel
end
......@@ -151,26 +174,26 @@ module Obfuscator
def generate_mixed_word(length)
return '' if length.zero?
is_vowel = @rng.rand < 0.25
is_vowel = random_probability < 0.25
result = ''
while result.length < length
# 50/50 chance of Russian or English
use_russian = @rng.rand < 0.5
use_russian = random_probability < 0.5
# 25/75 chance of vowel for Russian/English
# is_vowel = @rng.rand < (use_russian ? 0.25 : 0.4)
char = if is_vowel
if use_russian
RUSSIAN_VOWELS.sample(random: @rng)
random_sample(RUSSIAN_VOWELS)
else
ENGLISH_VOWELS.sample(random: @rng)
random_sample(ENGLISH_VOWELS)
end
elsif use_russian
RUSSIAN_CONSONANTS.sample(random: @rng)
random_sample(RUSSIAN_CONSONANTS)
else
ENGLISH_CONSONANTS.sample(random: @rng)
random_sample(ENGLISH_CONSONANTS)
end
result += char
......@@ -185,5 +208,13 @@ module Obfuscator
pattern[i] ? c.upcase : c.downcase
end.join
end
def random_sample(array)
array.sample(random: @rng)
end
def random_probability
@rng.rand
end
end
end
# frozen_string_literal: true
require_relative 'constants'
require_relative 'internal/rng'
module Obfuscator
# A class responsible for naturalizing words, making them more readable and
# natural-looking while preserving their structure.
# A class responsible for naturalizing words by applying linguistic rules to make them
# more readable and natural-looking while preserving their structure.
#
# The naturalizer applies several rules to improve readability:
# 1. No soft/hard signs (ь/ъ) after Latin letters
# 2. No щ after w/th combinations
# 3. No й after consonants
# 4. No triple consonants (inserts appropriate vowel)
# 5. Handles impossible letter combinations
# 6. No double vowels
# 7. Special handling for ё, ю, я after consonants
# 8. Applies appropriate language-specific endings for longer words
#
# @example Basic usage
# naturalizer = Naturalizer.new
# naturalizer.naturalize("Thщит") # => "Thкит"
#
# @example With seed for reproducible results
# naturalizer = Naturalizer.new(12345)
# naturalizer.naturalize("Thщит") # => Same result for same seed
#
# @param seed [Integer, nil] Optional seed for reproducible results
#
# @see Multilang For the main obfuscation class that uses this naturalizer
class Naturalizer
include Constants
include Internal::RNG
def initialize(rng = Random.new)
@rng = rng
def initialize(seed = nil)
@seed = seed # Store the seed
setup_rng(seed)
end
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength,Metrics/PerceivedComplexity
def naturalize(word)
# Reset RNG state before each naturalization if seed was provided
setup_rng(@seed) if @seed
return word unless word.respond_to?(:to_s)
return word if word.length < 2
......@@ -32,16 +60,16 @@ module Obfuscator
# Rule 1: No ь/ъ after Latin letters
soft_hard_signs = %w[ь ъ]
if latin?(char) && soft_hard_signs.include?(next_char)
chars[i + 1] = RUSSIAN_CONSONANTS.reject { |c| soft_hard_signs.include?(c) }.sample(random: @rng)
chars[i + 1] = random_sample(RUSSIAN_CONSONANTS.reject { |c| soft_hard_signs.include?(c) })
end
# Rule 2: No щ after w/th
if (char == 'w' || (i.positive? && chars[i - 1] == 't' && char == 'h')) && next_char == 'щ'
chars[i + 1] = (RUSSIAN_CONSONANTS - ['щ']).sample(random: @rng)
chars[i + 1] = random_sample(RUSSIAN_CONSONANTS - ['щ'])
end
# Rule 3: No й after consonants
chars[i + 1] = (RUSSIAN_CONSONANTS - ['й']).sample(random: @rng) if consonant?(char) && next_char == 'й'
chars[i + 1] = random_sample(RUSSIAN_CONSONANTS - ['й']) if consonant?(char) && next_char == 'й'
# Rule 4: No triple consonants
if i < chars.length - 2 &&
......@@ -49,9 +77,9 @@ module Obfuscator
consonant?(next_char) &&
consonant?(chars[i + 2])
chars[i + 1] = if cyrillic?(next_char)
RUSSIAN_VOWELS.sample(random: @rng)
random_sample(RUSSIAN_VOWELS)
else
ENGLISH_VOWELS.sample(random: @rng)
random_sample(ENGLISH_VOWELS)
end
end
......@@ -59,18 +87,18 @@ module Obfuscator
current_pair = char + next_char
if IMPOSSIBLE_COMBINATIONS.any? { |combo| current_pair.include?(combo) }
chars[i + 1] = if cyrillic?(next_char)
RUSSIAN_CONSONANTS.sample(random: @rng)
random_sample(RUSSIAN_CONSONANTS)
else
ENGLISH_CONSONANTS.sample(random: @rng)
random_sample(ENGLISH_CONSONANTS)
end
end
# Rule 6: No double vowels
if vowel?(char) && vowel?(next_char)
chars[i + 1] = if cyrillic?(next_char)
RUSSIAN_CONSONANTS.sample(random: @rng)
random_sample(RUSSIAN_CONSONANTS)
else
ENGLISH_CONSONANTS.sample(random: @rng)
random_sample(ENGLISH_CONSONANTS)
end
end
......@@ -78,7 +106,7 @@ module Obfuscator
# This rule is a special case of Rule 5
soft_vowels = %w[ё ю я]
if consonant?(char) && soft_vowels.include?(next_char)
chars[i + 1] = (RUSSIAN_VOWELS - soft_vowels).sample(random: @rng)
chars[i + 1] = random_sample(RUSSIAN_VOWELS - soft_vowels)
end
result << char
......@@ -137,14 +165,14 @@ module Obfuscator
return word if word.length < 4
base = word[0...-2]
base + RUSSIAN_ENDINGS.sample(random: @rng)
base + random_sample(RUSSIAN_ENDINGS)
end
def apply_english_ending(word)
return word if word.length < 4
base = word[0...-2]
base + ENGLISH_ENDINGS.sample(random: @rng)
base + random_sample(ENGLISH_ENDINGS)
end
end
end
# frozen_string_literal: true
module Obfuscator
# Class for obfuscating numbers while preserving their format and structure.
# Handles pure numbers (integers, floats), formatted strings, and mixed content
# including multilingual text (Latin and Cyrillic alphabets).
#
# @example Basic usage with numbers
# obfuscator = NumberObfuscator.new
# obfuscator.obfuscate(123.45) # => 567.89
# obfuscator.obfuscate(-42) # => -73
#
# @example With string numbers and leading zeros
# obfuscator.obfuscate("0042") # => "0073"
# obfuscator.obfuscate("00123.40") # => "00567.80"
#
# @example With formatted numbers
# obfuscator.obfuscate("1 234,56") # => "5 678,91"
# obfuscator.obfuscate("1_000_000") # => "5_678_912"
#
# @example With mixed content (Latin and Cyrillic)
# obfuscator.obfuscate("ABC-42XY") # => "DEF-73ZW"
# obfuscator.obfuscate("АБВ-42ЩЮ") # => "ГДЕ-73ЖЗ"
# obfuscator.obfuscate("21.11.234.23") # => "65.87.891.45"
#
# @example With options
# # Don't preserve leading zeros
# obfuscator = NumberObfuscator.new(preserve_leading_zeros: false)
# obfuscator.obfuscate("0042") # => "7391"
#
# # Remove signs from negative numbers
# obfuscator = NumberObfuscator.new(unsigned: true)
# obfuscator.obfuscate("-123") # => "456"
#
# # Use seed for reproducible results
# obfuscator = NumberObfuscator.new(seed: 12345)
# obfuscator.obfuscate("123.45") # => Same result for same seed
#
# Features:
# - Preserves number format (decimal separators, thousand separators)
# - Maintains leading zeros (optional)
# - Handles mixed content with letters and numbers
# - Supports both Latin and Cyrillic alphabets
# - Provides reproducible results with seeds
# - UTF-8 encoding support
#
# @param preserve_leading_zeros [Boolean] Whether to keep leading zeros in string numbers (default: true)
# @param unsigned [Boolean] Whether to remove signs from negative numbers (default: false)
# @param seed [Integer, nil] Optional seed for reproducible results
#
# @raise [Error] If number obfuscation fails
# @raise [InputError] If input is neither Numeric nor String
#
# @note This method is optimized for single-use. For bulk operations,
# consider creating a single instance and reusing it.
#
# @note This class is not thread-safe. For concurrent usage,
# create separate instances per thread.
#
# @note Requires Ruby 3.0+ for pattern matching features
class NumberObfuscator
include Internal::RNG
FormatError = Class.new(Error)
RangeError = Class.new(Error)
# Consider memoizing character sets
UPPERCASE_CYRILLIC = ('А'..'Я').to_a - ['Ё']
LOWERCASE_CYRILLIC = ('а'..'я').to_a - ['ё']
def initialize(preserve_leading_zeros: true, unsigned: false, seed: nil)
@preserve_leading_zeros = preserve_leading_zeros
@unsigned = unsigned
@seed = seed # Store seed for reuse
setup_rng(seed)
end
def obfuscate(input)
return input if input.nil? || (input.is_a?(String) && input.empty?)
# Create a unique seed based on both the original seed and input
if @seed
combined_seed = [@seed, input.to_s].hash
setup_rng(combined_seed)
end
# Handle unsigned option at the entry point
input = if @unsigned && input.is_a?(String)
input.gsub(/^-/, '')
elsif @unsigned && input.is_a?(Numeric)
input.abs
else
input
end
case input
when Numeric
obfuscate_numeric(input)
when String
obfuscate_string(input)
else
raise InputError, "Input must be Numeric or String, got: #{input.class}"
end
rescue InputError
raise
rescue StandardError => e
raise Error, "Number obfuscation error: #{e.message}"
end
private
def preserve_format(original, new_number)
return new_number if original.nil?
return original if original.to_s.match?(/^-?0+\.?0*$/)
original_str = original.to_s
is_negative = original_str.start_with?('-') || new_number.negative?
new_str = new_number.abs.to_s
# Split into parts for IP-like numbers
if original_str.count('.') > 1
parts = original_str.gsub(/^-/, '').split('.')
new_parts = parts.map do |part|
part_len = part.length
new_part = new_number.abs.to_s[-part_len..]
new_part.rjust(part_len, '0')
end
new_str = new_parts.join('.')
else
# Handle decimal places
if original_str.include?('.') || original_str.include?(',')
separator = original_str.include?(',') ? ',' : '.'
decimal_places = original_str.gsub(/^-/, '').split(/[.,]/).last&.length || 0
new_str = format("%.#{decimal_places}f", new_number.abs).tr('.', separator)
end
# Handle leading zeros
if @preserve_leading_zeros
leading_zeros = original_str.gsub(/^-/, '').match(/^0+/)&.[](0)
new_str = "#{leading_zeros}#{new_str}" if leading_zeros
end
# Ensure exact length match
new_str = new_str.rjust(original_str.gsub(/^-/, '').length, '0')
end
# Add sign only if input was negative
is_negative ? "-#{new_str}" : new_str
end
def obfuscate_string(text)
return text if text.empty?
# Split into numbers and separators while preserving positions
parts = text.scan(/(\d+(?:\.\d+)?)|([^\d]+)/)
parts.flatten!
parts.compact!
# Generate base seed for the entire string
base_seed = if @seed
"#{@seed}:#{text}".hash
else
Random.new_seed
end
result = ''
numbers_count = parts.count { |p| p.match?(/\d/) }
number_index = 0
parts.each do |part|
result += if part.match?(/\d/)
position_seed = "#{base_seed}:#{number_index}:#{numbers_count}".hash
local_rng = Random.new(position_seed)
number_result = with_temporary_rng(local_rng) do
original_str = @unsigned ? part.gsub(/^-/, '') : part
original = if original_str.include?('.')
original_str.to_f
else
original_str.to_i
end
new_number = generate_similar_number(original)
preserve_format(original_str, new_number)
end
number_index += 1
number_result
else
part
end
end
result
end
def obfuscate_mixed_string(text)
# Split into tokens preserving all separators and non-numeric parts
# \p{L} matches any kind of letter from any language
# \p{N} matches any kind of numeric character in any script
tokens = text.split(/(\d+(?:\.\d+)?|\s+|[[:punct:]]|[\p{L}]+)/)
tokens.map do |token|
case token
when /^\d+(?:\.\d+)?$/ # Pure number
new_number = generate_similar_number(token.to_f)
preserve_format(token, new_number)
when /\p{L}+/ # Letters (any script)
obfuscate_letters(token)
else # Spaces and punctuation
token
end
end.join
end
def obfuscate_letters(text)
# Ensure UTF-8 encoding
text = text.encode('UTF-8') unless text.encoding == Encoding::UTF_8
text.chars.map do |char|
case char
when /[A-Z]/
(((char.ord - 'A'.ord + random_integer(1, 25)) % 26) + 'A'.ord).chr
when /[a-z]/
(((char.ord - 'a'.ord + random_integer(1, 25)) % 26) + 'a'.ord).chr
when /[А-Я]/
if char == 'Ё'
'Е'
else
random_sample(UPPERCASE_CYRILLIC)
end
when /[а-я]/
if char == 'ё'
'е'
else
random_sample(LOWERCASE_CYRILLIC)
end
else
char
end
end.join.force_encoding('UTF-8')
end
def obfuscate_numeric(number)
return number if number.zero?
case number
when Integer
# For integers, maintain the same number of digits
digits = number.abs.to_s.length
base = 10**(digits - 1)
new_number = ((random_probability * 9) + 1) * base
new_number = new_number.to_i
# Handle sign based on unsigned option
if @unsigned
new_number.abs
else
number.negative? ? -new_number.abs : new_number.abs
end
when Float, BigDecimal
# For floating point, maintain similar magnitude and precision
str_number = number.to_s
decimal_places = str_number.split('.')[1]&.length || 0
new_number = generate_similar_number(number)
# Ensure consistent decimal places
new_number = new_number.round(decimal_places) if decimal_places.positive?
# Handle sign based on unsigned option
if @unsigned
new_number.abs
else
number.negative? ? -new_number.abs : new_number.abs
end
else
raise InputError, "Unsupported numeric type: #{number.class}"
end
end
def generate_similar_number(number)
return 0 if number.zero?
# Work with absolute value
abs_num = number.abs
str_num = abs_num.to_s
is_float = number.is_a?(Float) || str_num.include?('.')
num_digits = str_num.gsub(/[^\d]/, '').length
# Generate base number with correct digits
base = 10**(num_digits - 1)
max = (10**num_digits) - 1
new_number = base + (random_probability * (max - base)).to_i
# Handle float conversion and decimal places
if is_float
decimal_places = str_num.split('.')[1]&.length || 0
new_number = (new_number.to_f / (10**decimal_places)).round(decimal_places)
end
# Handle sign based on unsigned option
if @unsigned
new_number.abs
else
number.negative? ? -new_number.abs : new_number.abs
end
end
def random_integer(min, max)
(random_probability * (max - min + 1)).to_i + min
end
def with_temporary_rng(temp_rng)
original_rng = @rng
@rng = temp_rng
yield
ensure
@rng = original_rng
end
end
end
# frozen_string_literal: true
module Obfuscator
VERSION = '0.1.0'
VERSION = '0.8.1'
end
......@@ -3,20 +3,24 @@
require_relative 'lib/obfuscator/version'
Gem::Specification.new do |spec|
spec.name = 'obfuscator'
spec.name = 'obfuscator-rb'
spec.version = Obfuscator::VERSION
spec.authors = ['Aleksandr Dryzhuk']
spec.email = ['dev@ad-it.pro']
spec.summary = 'Text obfuscator that preserves structure while working with both English and Russian languages'
spec.summary = 'A robust data obfuscator for numbers, dates, and text with format preservation'
spec.description = <<~DESC
A Ruby gem for text obfuscation that preserves text structure while replacing content
with meaningless but natural-looking words. Supports both English and Russian languages,
with various obfuscation modes and optional text naturalization.
A Ruby library for data obfuscation that:
- Preserves original data format and structure as much as possible
- Supports numbers (including IP-like sequences), dates, and text
- Maintains text structure while replacing content with meaningless but natural-looking words in English and Russian
- Maintains data type consistency and decimal precision
- Offers seeded randomization for reproducible results
- Handles various number formats (leading zeros, separators)
- Provides configurable options (unsigned mode, format preservation)
Гем для обфускации текста, сохраняющий его структуру и естественный вид, но заменяющий при этом содержимое
бессмысленными словами. Поддерживает английский и русский языки, различные режимы обфускации и опциональную
натурализацию текста.
Note: Individual obfuscator instances are not thread-safe.
For concurrent operations, create separate instances per thread.
DESC
spec.homepage = 'https://hub.mos.ru/ad/obfuscator'
spec.license = 'MIT'
......@@ -31,7 +35,7 @@ Gem::Specification.new do |spec|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
gemspec = File.basename(__FILE__)
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
ls.readlines("\x0", chomp: true).reject do |f|
ls.each_line("\x0", chomp: true).reject do |f|
(f == gemspec) ||
f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
end
......
# frozen_string_literal: true
require 'test_helper'
require 'date'
require_relative '../lib/obfuscator/date_obfuscator'
module Obfuscator
class DateObfuscatorTest < Minitest::Test
def setup
@date_obf = Obfuscator::DateObfuscator.new
end
def test_handles_nil_input
assert_nil @date_obf.obfuscate(nil)
end
def test_handles_empty_string
assert_empty @date_obf.obfuscate('')
end
def test_raises_on_invalid_date
assert_raises(Obfuscator::Error) do
@date_obf.obfuscate('31.02.2023') # Invalid February date
end
end
def test_raises_on_invalid_format
assert_raises(Obfuscator::Error) do
@date_obf.obfuscate('2023.31.02') # Doesn't match ISO format
end
end
def test_preserves_format
date = '2023-12-31'
result = @date_obf.obfuscate(date)
assert_match(/\d{4}-\d{2}-\d{2}/, result)
end
def test_different_formats
dates = {
eu: '31.12.2023',
eu_short: '31.12.23',
rus: '31.12.2023',
rus_short: '31.12.23',
iso: '2023-12-31'
}
dates.each do |format, date|
obf = Obfuscator::DateObfuscator.new(format: format)
result = obf.obfuscate(date)
refute_equal date, result, "Date should be different for #{format} format"
# Verify format is preserved
case format
when :eu, :rus
assert_match(/\d{2}\.\d{2}\.\d{4}/, result)
when :eu_short, :rus_short
assert_match(/\d{2}\.\d{2}\.\d{2}/, result)
when :iso
assert_match(/\d{4}-\d{2}-\d{2}/, result)
end
end
end
def test_respects_year_constraints
constraints = { min_year: 2020, max_year: 2025 }
date = '2023-06-15'
obf = Obfuscator::DateObfuscator.new(constraints: constraints)
10.times do
result = obf.obfuscate(date)
year = result.split('-').first.to_i
assert_includes 2020..2025, year
end
end
def test_preserves_month_when_configured
date = '2023-06-15'
obf = Obfuscator::DateObfuscator.new(constraints: { preserve_month: true })
result = obf.obfuscate(date)
assert_equal '06', result.split('-')[1]
end
def test_preserves_weekday_when_configured
date = '2023-06-15' # This was a Thursday
obf = Obfuscator::DateObfuscator.new(constraints: { preserve_weekday: true })
result = obf.obfuscate(date)
original_date = Date.strptime(date, '%Y-%m-%d')
result_date = Date.strptime(result, '%Y-%m-%d')
assert_equal original_date.wday, result_date.wday
end
def test_produces_reproducible_output_with_seed
date = '2023-06-15'
obf1 = Obfuscator::DateObfuscator.new(seed: 12_345)
obf2 = Obfuscator::DateObfuscator.new(seed: 12_345)
assert_equal obf1.obfuscate(date), obf2.obfuscate(date)
end
def test_produces_reproducible_output_with_seed_sequentially
date = '2023-06-15'
obfuscator = DateObfuscator.new(seed: 12_345)
result1 = obfuscator.obfuscate(date)
result2 = obfuscator.obfuscate(date)
assert_equal result1, result2,
'Sequential calls with same seed should produce identical results'
end
def test_generates_different_output_without_seed
date = '2023-06-15'
results = Set.new
10.times do
results.add(@date_obf.obfuscate(date))
end
assert_operator results.size, :>, 1, 'Should generate different dates'
end
def test_custom_format
obf = Obfuscator::DateObfuscator.new(format: '%Y/%m/%d')
date = '2023/06/15'
result = obf.obfuscate(date)
assert_match(%r{\d{4}/\d{2}/\d{2}}, result)
refute_equal date, result
end
def test_produces_different_results_for_different_inputs_with_same_seed
obfuscator = DateObfuscator.new(seed: 12_345)
date1 = '2023-01-01'
date2 = '2023-01-02'
result1a = obfuscator.obfuscate(date1)
result1b = obfuscator.obfuscate(date1)
result2 = obfuscator.obfuscate(date2)
assert_equal result1a, result1b,
'Same input with same seed should produce identical results'
refute_equal result1a, result2,
'Different inputs with same seed should produce different results'
end
def test_produces_reproducible_results_across_instances
date = '2023-01-01'
obf1 = DateObfuscator.new(seed: 12_345)
obf2 = DateObfuscator.new(seed: 12_345)
result1 = obf1.obfuscate(date)
result2 = obf2.obfuscate(date)
assert_equal result1, result2,
'Same input and seed should produce identical results across instances'
end
end
end
# frozen_string_literal: true
require 'test_helper'
module Obfuscator
class NaturalizerTest < Minitest::Test
def setup
@naturalizer = Naturalizer.new
end
def test_handles_short_words
assert_equal 'a', @naturalizer.naturalize('a')
assert_equal 'ab', @naturalizer.naturalize('ab')
end
def test_produces_reproducible_output_with_seed
word = 'Thщит'
nat1 = Naturalizer.new(12_345)
nat2 = Naturalizer.new(12_345)
assert_equal nat1.naturalize(word), nat2.naturalize(word)
end
def test_produces_reproducible_output_with_seed_sequentially
word = 'Thщит'
naturalizer = Naturalizer.new(12_345)
result1 = naturalizer.naturalize(word)
result2 = naturalizer.naturalize(word)
assert_equal result1, result2,
'Sequential calls with same seed should produce identical results'
end
def test_handles_soft_hard_signs_after_latin
result = @naturalizer.naturalize('abь')
refute_match(/[a-z][ьъ]/, result.downcase)
end
def test_handles_shcha_after_th
result = @naturalizer.naturalize('thщ')
refute_match(/th[щ]/, result.downcase)
end
def test_handles_short_j_after_consonants
result = @naturalizer.naturalize('tй')
refute_match(/[бвгджзклмнпрстфхцчшщ]й/i, result)
end
def test_handles_triple_consonants
result = @naturalizer.naturalize('стрк')
refute_match(/[бвгджзклмнпрстфхцчшщ]{3}/i, result)
end
def test_handles_double_vowels
result = @naturalizer.naturalize('aа')
refute_match(/[аеёиоуыэюяaeiouy]{2}/i, result)
end
def test_handles_soft_vowels_after_consonants
result = @naturalizer.naturalize('тё')
refute_match(/[бвгджзклмнпрстфхцчшщ][ёюя]/i, result)
end
end
end
# frozen_string_literal: true
require 'test_helper'
require_relative '../lib/obfuscator/number_obfuscator'
module Obfuscator
class NumberObfuscatorTest < Minitest::Test
def setup
@obfuscator = NumberObfuscator.new
end
def test_handles_integers
expected_length = 2 # '42'.length
result = @obfuscator.obfuscate(42)
assert_kind_of Integer, result
assert_equal result.to_s.length, expected_length
end
def test_handles_negative_integers
expected_length = 2 # '42'.length
result = @obfuscator.obfuscate(-42)
assert_predicate result, :negative?
assert_equal result.abs.to_s.length, expected_length
end
def test_handles_floats
input = 123.45
result = @obfuscator.obfuscate(input)
assert_kind_of Float, result
assert_equal input.to_s.split('.')[1].length, result.to_s.split('.')[1].length
end
def test_handles_string_numbers
input = '42'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^\d+$/, result)
end
def test_preserves_leading_zeros_by_default
input = '0042'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^0+\d+$/, result)
end
def test_can_skip_leading_zeros_preservation
obfuscator = NumberObfuscator.new(preserve_leading_zeros: false)
input = '0042'
result = obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^\d{4}$/, result)
end
def test_handles_formatted_numbers
input = '1 234,56'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^\d[\s_]\d{3},\d{2}$/, result)
end
def test_handles_mixed_content
input = 'ABC-42XY'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^[A-Z]+-\d+[A-Z]+$/, result)
end
def test_preserves_decimal_separators
input = '123,45'
result = @obfuscator.obfuscate(input)
assert_match(/^\d+,\d+$/, result)
input = '123.45'
result = @obfuscator.obfuscate(input)
assert_match(/^\d+\.\d+$/, result)
end
def test_handles_multiple_number_groups
input = '21.11.234.23'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^\d+\.\d+(\.\d+)*$/, result)
end
def test_produces_reproducible_output_with_seed
obf1 = NumberObfuscator.new(seed: 12_345)
obf2 = NumberObfuscator.new(seed: 12_345)
input = '123.45'
assert_equal obf1.obfuscate(input), obf2.obfuscate(input)
end
def test_produces_reproducible_output_with_seed_sequentially
obfuscator = NumberObfuscator.new(seed: 12_345)
input = '123.45'
result1 = obfuscator.obfuscate(input)
result2 = obfuscator.obfuscate(input)
assert_equal result1, result2,
'Sequential calls with same seed should produce identical results'
end
def test_handles_unsigned_option
obfuscator = NumberObfuscator.new(unsigned: true)
result = obfuscator.obfuscate('-42')
refute_match(/^-/, result)
end
def test_handles_zero
assert_equal '0', @obfuscator.obfuscate('0')
assert_equal 0, @obfuscator.obfuscate(0)
assert_equal '0.00', @obfuscator.obfuscate('0.00')
end
def test_raises_error_for_invalid_input
assert_raises(InputError) { @obfuscator.obfuscate(:string) }
assert_raises(InputError) { @obfuscator.obfuscate([]) }
end
def test_handles_mixed_content_with_cyrillic_uppercase
input = 'АБВ-42ЩЮ'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^[А-ЯЁ]+-\d+[А-ЯЁ]+$/, result)
end
def test_handles_mixed_content_with_cyrillic_lowercase
input = 'абв-42щю'
result = @obfuscator.obfuscate(input)
assert_equal input.length, result.length
assert_match(/^[а-яё]+-\d+[а-яё]+$/, result)
end
def test_handles_large_numbers
# Use large but not extreme numbers for regular testing
large_positive = 1_000_000_000_000.0 # 1 trillion
large_negative = -1_000_000_000_000.0 # -1 trillion
result1 = @obfuscator.obfuscate(large_positive)
assert_kind_of Float, result1, 'Should handle large positive numbers'
assert_operator result1, :>, 0, 'Should generate positive number for positive input'
assert_operator result1, :<, large_positive * 100, 'Should stay within reasonable magnitude'
result2 = @obfuscator.obfuscate(large_negative)
assert_kind_of Float, result2, 'Should handle large negative numbers'
assert_operator result2, :<, 0, 'Should generate negative number for negative input'
assert_operator result2.abs, :<, large_positive.abs * 100, 'Should stay within reasonable magnitude'
end
def test_handles_extreme_numbers
skip 'Run with STRESS_TEST=1 to test extreme values' unless ENV['STRESS_TEST']
# Use a large but not maximum value
very_large = 1.0e+100 # Much smaller than Float::MAX but still very large
result1 = @obfuscator.obfuscate(very_large)
assert_kind_of Float, result1, 'Should handle very large numbers'
assert_operator result1, :>, 0, 'Should generate positive number for positive input'
assert_operator result1, :<, very_large * 100, 'Should stay within reasonable magnitude'
result2 = @obfuscator.obfuscate(-very_large)
assert_kind_of Float, result2, 'Should handle very large negative numbers'
assert_operator result2, :<, 0, 'Should generate negative number for negative input'
assert_operator result2.abs, :<, very_large * 100, 'Should stay within reasonable magnitude'
end
def test_seeded_obfuscation_differs_for_different_inputs
obfuscator = NumberObfuscator.new(seed: 12_345)
input1 = '10-20-30'
input2 = '20-30-40'
result1a = obfuscator.obfuscate(input1)
result1b = obfuscator.obfuscate(input1)
result2 = obfuscator.obfuscate(input2)
assert_equal result1a, result1b,
'Same input with same seed should produce identical results'
refute_equal result1a, result2,
'Different inputs with same seed should produce different results'
end
end
end
......@@ -171,6 +171,17 @@ class ObfuscatorMultilangTest < Minitest::Test
assert_equal obf1.obfuscate(text), obf2.obfuscate(text)
end
def test_produces_reproducible_output_with_seed_sequentially
text = 'Hello World'
obfuscator = Obfuscator::Multilang.new(seed: 12_345)
result1 = obfuscator.obfuscate(text)
result2 = obfuscator.obfuscate(text)
assert_equal result1, result2,
'Sequential calls with same seed should produce identical results'
end
def test_handles_empty_string
assert_equal '', @obfuscator.obfuscate('')
end
......
# frozen_string_literal: true
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
require 'obfuscator'
require 'obfuscator-rb'
require 'minitest/autorun'
require 'minitest/reporters'
......
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать