feat: complete base64tool
This commit is contained in:
parent
aff62711f1
commit
9205d3ce4d
|
|
@ -0,0 +1,50 @@
|
|||
# =============================================================================
|
||||
# AngelaMos | 2026
|
||||
# publish-base64-tool.yml
|
||||
# =============================================================================
|
||||
|
||||
name: Publish b64tool to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'PROJECTS/beginner/base64-tool/**'
|
||||
- '!PROJECTS/beginner/base64-tool/README.md'
|
||||
- '!PROJECTS/beginner/base64-tool/Justfile'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pypi-publish:
|
||||
name: Upload b64tool to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/b64tool
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build
|
||||
|
||||
- name: Build package
|
||||
working-directory: PROJECTS/beginner/base64-tool
|
||||
run: python -m build
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: PROJECTS/beginner/base64-tool/dist/
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
# =============================================================================
|
||||
# AngelaMos | 2026
|
||||
# dev.compose.yml
|
||||
# =============================================================================
|
||||
|
||||
name: ${APP_NAME:-template}-dev
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
# ©AngelaMos | 2026
|
||||
# .gitignore
|
||||
|
||||
.venv
|
||||
venv/
|
||||
__pycache__
|
||||
*.pyc
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
htmlcov
|
||||
.coverage
|
||||
dist
|
||||
*.egg-info
|
||||
.hypothesis
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# ©AngelaMos | 2026
|
||||
[style]
|
||||
based_on_style = pep8
|
||||
column_limit = 89
|
||||
indent_width = 4
|
||||
continuation_indent_width = 4
|
||||
indent_closing_brackets = false
|
||||
dedent_closing_brackets = true
|
||||
indent_blank_lines = false
|
||||
spaces_before_comment = 2
|
||||
spaces_around_power_operator = false
|
||||
spaces_around_default_or_named_assign = true
|
||||
space_between_ending_comma_and_closing_bracket = false
|
||||
space_inside_brackets = false
|
||||
spaces_around_subscript_colon = true
|
||||
blank_line_before_nested_class_or_def = false
|
||||
blank_line_before_class_docstring = false
|
||||
blank_lines_around_top_level_definition = 2
|
||||
blank_lines_between_top_level_imports_and_variables = 2
|
||||
blank_line_before_module_docstring = false
|
||||
split_before_logical_operator = true
|
||||
split_before_first_argument = true
|
||||
split_before_named_assigns = true
|
||||
split_complex_comprehension = true
|
||||
split_before_expression_after_opening_paren = false
|
||||
split_before_closing_bracket = true
|
||||
split_all_comma_separated_values = true
|
||||
split_all_top_level_comma_separated_values = false
|
||||
coalesce_brackets = false
|
||||
each_dict_entry_on_separate_line = true
|
||||
allow_multiline_lambdas = false
|
||||
allow_multiline_dictionary_keys = false
|
||||
split_penalty_import_names = 0
|
||||
join_multiple_lines = false
|
||||
align_closing_bracket_with_visual_indent = true
|
||||
arithmetic_precedence_indication = false
|
||||
split_penalty_for_added_line_split = 275
|
||||
use_tabs = false
|
||||
split_before_dot = false
|
||||
split_arguments_when_comma_terminated = true
|
||||
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
|
||||
i18n_comment = ['# Translators:', '# i18n:']
|
||||
split_penalty_comprehension = 80
|
||||
split_penalty_after_opening_bracket = 280
|
||||
split_penalty_before_if_expr = 0
|
||||
split_penalty_bitwise_operator = 290
|
||||
split_penalty_logical_operator = 0
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
# ©AngelaMos | 2026
|
||||
# Justfile
|
||||
# =============================================================================
|
||||
|
||||
set dotenv-load
|
||||
set export
|
||||
set shell := ["bash", "-uc"]
|
||||
|
||||
project := file_name(justfile_directory())
|
||||
version := `git describe --tags --always 2>/dev/null || echo "dev"`
|
||||
|
||||
# =============================================================================
|
||||
# Default
|
||||
# =============================================================================
|
||||
|
||||
default:
|
||||
@just --list --unsorted
|
||||
|
||||
# =============================================================================
|
||||
# Run
|
||||
# =============================================================================
|
||||
|
||||
[group('run')]
|
||||
run *ARGS:
|
||||
uv run b64tool {{ARGS}}
|
||||
|
||||
[group('run')]
|
||||
encode DATA *ARGS:
|
||||
uv run b64tool encode "{{DATA}}" {{ARGS}}
|
||||
|
||||
[group('run')]
|
||||
decode DATA *ARGS:
|
||||
uv run b64tool decode "{{DATA}}" {{ARGS}}
|
||||
|
||||
[group('run')]
|
||||
detect DATA:
|
||||
uv run b64tool detect "{{DATA}}"
|
||||
|
||||
[group('run')]
|
||||
peel DATA:
|
||||
uv run b64tool peel "{{DATA}}"
|
||||
|
||||
[group('run')]
|
||||
chain DATA STEPS:
|
||||
uv run b64tool chain "{{DATA}}" --steps {{STEPS}}
|
||||
|
||||
# =============================================================================
|
||||
# Linting and Formatting
|
||||
# =============================================================================
|
||||
|
||||
[group('lint')]
|
||||
ruff *ARGS:
|
||||
uv run ruff check src/ tests/ {{ARGS}}
|
||||
|
||||
[group('lint')]
|
||||
ruff-fix:
|
||||
uv run ruff check src/ tests/ --fix
|
||||
uv run ruff format src/ tests/
|
||||
|
||||
[group('lint')]
|
||||
ruff-format:
|
||||
uv run ruff format src/ tests/
|
||||
|
||||
[group('lint')]
|
||||
lint: ruff
|
||||
|
||||
# =============================================================================
|
||||
# Type Checking
|
||||
# =============================================================================
|
||||
|
||||
[group('types')]
|
||||
mypy *ARGS:
|
||||
uv run mypy src/ {{ARGS}}
|
||||
|
||||
[group('types')]
|
||||
typecheck: mypy
|
||||
|
||||
# =============================================================================
|
||||
# Testing
|
||||
# =============================================================================
|
||||
|
||||
[group('test')]
|
||||
test *ARGS:
|
||||
uv run pytest {{ARGS}}
|
||||
|
||||
[group('test')]
|
||||
test-cov:
|
||||
uv run pytest --cov=base64_tool --cov-report=term-missing --cov-report=html
|
||||
|
||||
# =============================================================================
|
||||
# CI / Quality
|
||||
# =============================================================================
|
||||
|
||||
[group('ci')]
|
||||
ci: lint typecheck test
|
||||
|
||||
[group('ci')]
|
||||
check: ruff mypy
|
||||
|
||||
# =============================================================================
|
||||
# Setup
|
||||
# =============================================================================
|
||||
|
||||
[group('setup')]
|
||||
setup:
|
||||
uv sync --all-extras
|
||||
|
||||
[group('setup')]
|
||||
install:
|
||||
uv sync
|
||||
|
||||
[group('setup')]
|
||||
install-dev:
|
||||
uv sync --all-extras
|
||||
|
||||
# =============================================================================
|
||||
# Utilities
|
||||
# =============================================================================
|
||||
|
||||
[group('util')]
|
||||
info:
|
||||
@echo "Project: {{project}}"
|
||||
@echo "Version: {{version}}"
|
||||
@echo "OS: {{os()}} ({{arch()}})"
|
||||
|
||||
[group('util')]
|
||||
clean:
|
||||
-rm -rf .mypy_cache
|
||||
-rm -rf .pytest_cache
|
||||
-rm -rf .ruff_cache
|
||||
-rm -rf htmlcov
|
||||
-rm -rf .coverage
|
||||
-rm -rf dist
|
||||
-rm -rf *.egg-info
|
||||
@echo "Cache directories cleaned"
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
```ruby
|
||||
██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗ ██████╗ ██╗
|
||||
██╔══██╗██╔════╝ ██║ ██║╚══██╔══╝██╔═══██╗██╔═══██╗██║
|
||||
██████╔╝███████╗ ███████║ ██║ ██║ ██║██║ ██║██║
|
||||
██╔══██╗██╔═══██╗╚════██║ ██║ ██║ ██║██║ ██║██║
|
||||
██████╔╝╚██████╔╝ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗
|
||||
╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||
```
|
||||
|
||||
* **Multi format encoding/decoding CLI with recursive layer detection for security analysis.**
|
||||
|
||||
## Features
|
||||
|
||||
- **Encode/Decode** across Base64, Base64URL, Base32, Hex, and URL encoding formats
|
||||
- **Auto-detect** encoding format with confidence scoring
|
||||
- **Peel** recursively through multi-layered encoding (the kind attackers use to evade WAFs and IDS)
|
||||
- **Chain** multiple encoding steps together for testing obfuscation patterns
|
||||
- **Pipeline-friendly** output for integration into security workflows
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run b64tool encode "Hello World"
|
||||
uv run b64tool decode "SGVsbG8gV29ybGQ="
|
||||
uv run b64tool detect "SGVsbG8gV29ybGQ="
|
||||
uv run b64tool peel "NjQ0ODY1NmM2YzZmMjA1NzZmNzI2YzY0"
|
||||
uv run b64tool chain "secret" --steps base64,hex,url
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Encode
|
||||
|
||||
```bash
|
||||
b64tool encode "Hello World" # Base64 (default)
|
||||
b64tool encode "Hello World" --format hex # Hex
|
||||
b64tool encode "Hello World" --format base32 # Base32
|
||||
b64tool encode "hello world&foo=bar" --format url # URL encoding
|
||||
echo "piped input" | b64tool encode # Stdin
|
||||
b64tool encode --file secret.txt # File input
|
||||
```
|
||||
|
||||
### Decode
|
||||
|
||||
```bash
|
||||
b64tool decode "SGVsbG8gV29ybGQ=" # Base64 (default)
|
||||
b64tool decode "48656c6c6f" --format hex # Hex
|
||||
b64tool decode "JBSWY3DP" --format base32 # Base32
|
||||
```
|
||||
|
||||
### Detect
|
||||
|
||||
Identifies the encoding format with confidence scoring:
|
||||
|
||||
```bash
|
||||
b64tool detect "SGVsbG8gV29ybGQ="
|
||||
```
|
||||
|
||||
### Peel (Recursive Layer Detection)
|
||||
|
||||
The signature feature. Automatically peels back multiple encoding layers, the way real malware and attack payloads obfuscate data:
|
||||
|
||||
```bash
|
||||
b64tool peel "NjQ0ODY1NmM2YzZmMjA1NzZmNzI2YzY0"
|
||||
```
|
||||
|
||||
### Chain
|
||||
|
||||
Build multi-layered encodings for testing WAF rules, IDS signatures, or understanding obfuscation:
|
||||
|
||||
```bash
|
||||
b64tool chain "alert('xss')" --steps base64,hex,url
|
||||
```
|
||||
|
||||
## Security Context
|
||||
|
||||
Encoding layering is a real attack technique:
|
||||
|
||||
- **WAF Bypass**: Double-encoding payloads to slip past web application firewalls (OWASP)
|
||||
- **Malware Obfuscation**: DARKGATE malware uses custom Base64 alphabets with randomized shuffling
|
||||
- **Data Exfiltration**: Base64-encoded data hidden in DNS queries and HTTP headers
|
||||
- **IDS Evasion**: Multi-layer encoding to avoid signature-based detection
|
||||
|
||||
This tool helps security professionals analyze and understand these patterns.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Overview
|
||||
|
||||
## What This Project Does
|
||||
|
||||
b64tool is a multi-format encoding/decoding CLI that handles Base64, Base64URL, Base32, hex, and URL encoding. The standout feature is recursive layer peeling, which automatically detects and decodes multiple stacked encoding layers, the same technique attackers use to obfuscate malware payloads and bypass web application firewalls.
|
||||
|
||||
You give it an encoded blob. It figures out what format it is, decodes it, checks if the result is itself encoded, and keeps going until it reaches the original data. Think of it like an onion. Each layer is a different encoding, and the tool peels them one at a time.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
In real security work, you constantly encounter encoded data. JWT tokens are base64. Certificates are base64. Hex dumps show up in packet captures and malware analysis. URL encoding appears in every web request. And when attackers want to hide payloads, they don't just encode once. They stack encodings: base64 the payload, hex encode that, then URL encode the hex. This tool handles all of that.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
- How Base64, Base32, hex, and URL encoding actually work at the byte level
|
||||
- Why encoding is not encryption (and why confusing them causes real vulnerabilities)
|
||||
- Pattern recognition for identifying encoding formats
|
||||
- How attackers layer encodings for obfuscation (with real examples from DARKGATE malware)
|
||||
- Building a clean, typed Python CLI with typer and rich
|
||||
- Confidence-based detection algorithms
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.14+
|
||||
- `uv` package manager
|
||||
- Basic command line comfort
|
||||
- Understanding of bytes vs strings (helpful, not required)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd PROJECTS/beginner/base64-tool
|
||||
uv sync
|
||||
uv run b64tool --help
|
||||
```
|
||||
|
||||
### Try the basics
|
||||
|
||||
```bash
|
||||
uv run b64tool encode "Hello World"
|
||||
# Output: SGVsbG8gV29ybGQ=
|
||||
|
||||
uv run b64tool decode "SGVsbG8gV29ybGQ="
|
||||
# Output: Hello World
|
||||
|
||||
uv run b64tool detect "SGVsbG8gV29ybGQ="
|
||||
# Shows: base64, 95% confidence
|
||||
```
|
||||
|
||||
### Try multi-layer encoding
|
||||
|
||||
```bash
|
||||
uv run b64tool chain "alert('xss')" --steps base64,hex
|
||||
# Produces a hex-encoded base64 string
|
||||
|
||||
uv run b64tool peel "5957786c636e516f4a33687a63796370"
|
||||
# Peels: hex → base64 → alert('xss')
|
||||
```
|
||||
|
||||
### Pipe support
|
||||
|
||||
```bash
|
||||
echo "SGVsbG8=" | uv run b64tool decode
|
||||
cat encoded_payload.txt | uv run b64tool peel
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
base64-tool/
|
||||
├── src/base64_tool/
|
||||
│ ├── cli.py # Typer commands (encode, decode, detect, peel, chain)
|
||||
│ ├── constants.py # Enums, thresholds, character sets
|
||||
│ ├── encoders.py # Pure encode/decode functions + registry
|
||||
│ ├── detector.py # Format detection with confidence scoring
|
||||
│ ├── peeler.py # Recursive multi-layer decoding
|
||||
│ ├── formatter.py # Rich terminal output
|
||||
│ └── utils.py # Input resolution, text helpers
|
||||
├── tests/ # 78 tests across all modules
|
||||
├── pyproject.toml # Python 3.14, typer, rich
|
||||
└── Justfile # Task runner shortcuts
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [01-CONCEPTS.md](./01-CONCEPTS.md) - How encoding works, real CVEs, encoding vs encryption
|
||||
- [02-ARCHITECTURE.md](./02-ARCHITECTURE.md) - System design, module relationships, data flow
|
||||
- [03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md) - Code walkthrough with file references
|
||||
- [04-CHALLENGES.md](./04-CHALLENGES.md) - Extension ideas from easy to expert
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# Concepts
|
||||
|
||||
## Encoding vs. Encryption
|
||||
|
||||
This is the single most important concept in this project, and confusing them causes real vulnerabilities.
|
||||
|
||||
**Encoding** transforms data into a different representation. Anyone can reverse it. There is no secret key. Base64 encoding is as "secure" as writing something in pig latin.
|
||||
|
||||
**Encryption** transforms data using a secret key. Without the key, you can't get the original back. AES, RSA, ChaCha20. These are encryption.
|
||||
|
||||
CWE-261 (Weak Encoding for Password) exists specifically because developers store passwords with base64 "encryption" instead of actual hashing. In 2018, a D-Link camera (CVE-2017-8417) stored admin credentials in base64 on the device. Anyone with network access could decode them instantly.
|
||||
|
||||
The rule: if you can reverse it without a password or key, it's encoding. Encoding is for compatibility, not security.
|
||||
|
||||
## How Base64 Works
|
||||
|
||||
Base64 converts binary data into 64 printable ASCII characters. The alphabet is `A-Z`, `a-z`, `0-9`, `+`, `/`, with `=` for padding.
|
||||
|
||||
### The Encoding Process
|
||||
|
||||
1. Take the input bytes
|
||||
2. Read them as a stream of bits
|
||||
3. Split into groups of 6 bits (not 8)
|
||||
4. Map each 6-bit value (0-63) to a character in the alphabet
|
||||
|
||||
Why 6 bits? Because 2^6 = 64, which gives exactly 64 possible values per character.
|
||||
|
||||
```
|
||||
Input: "Hi"
|
||||
Bytes: 0x48 0x69
|
||||
Binary: 01001000 01101001
|
||||
|
||||
Split into 6-bit groups:
|
||||
010010 | 000110 | 1001xx
|
||||
|
||||
Pad the last group with zeros:
|
||||
010010 | 000110 | 100100
|
||||
|
||||
Map to alphabet:
|
||||
18 → S 6 → G 36 → k
|
||||
|
||||
Add padding (input was 2 bytes, need 1 pad):
|
||||
Result: "SGk="
|
||||
```
|
||||
|
||||
Three input bytes produce exactly four output characters. When the input isn't divisible by 3, you get `=` padding. One leftover byte gets `==`, two leftover bytes get `=`.
|
||||
|
||||
### Base64URL Variant
|
||||
|
||||
Standard base64 uses `+` and `/` which are special characters in URLs. Base64URL (RFC 4648 Section 5) replaces them:
|
||||
- `+` becomes `-`
|
||||
- `/` becomes `_`
|
||||
|
||||
JWTs use base64url. So do many API tokens. If you see `-` or `_` in what looks like base64, it's probably base64url.
|
||||
|
||||
### Size Overhead
|
||||
|
||||
Base64 converts 3 bytes into 4 characters. That's a 33% size increase. A 1 MB file becomes ~1.33 MB when base64 encoded. This matters for things like embedding images in HTML or sending attachments in email (which is exactly why base64 was invented for MIME).
|
||||
|
||||
## How Base32 Works
|
||||
|
||||
Same idea as base64, but uses only 32 characters: `A-Z` and `2-7`. Groups of 5 bits instead of 6.
|
||||
|
||||
Why would you use base32 over base64? Environments where case sensitivity is a problem. DNS names are case insensitive, so base64 doesn't work there. TOTP tokens (Google Authenticator) use base32 for the shared secret because humans might misread uppercase/lowercase when typing a key.
|
||||
|
||||
Base32 produces longer output than base64 (60% overhead vs 33%). The tradeoff is fewer possible characters means fewer ambiguity issues.
|
||||
|
||||
## Hexadecimal Encoding
|
||||
|
||||
Hex represents each byte as two characters from `0-9` and `a-f`. It's a direct byte-to-text mapping.
|
||||
|
||||
```
|
||||
Input: "Hi"
|
||||
Bytes: 0x48 0x69
|
||||
Hex: 4869
|
||||
```
|
||||
|
||||
100% size overhead (every byte becomes two characters), but it's the most straightforward encoding and universally readable. You see hex in:
|
||||
- MAC addresses: `aa:bb:cc:dd:ee:ff`
|
||||
- Color codes: `#FF5733`
|
||||
- Packet captures and hex dumps
|
||||
- Malware hash signatures: `d41d8cd98f00b204e9800998ecf8427e`
|
||||
- Binary file analysis
|
||||
|
||||
## URL Encoding (Percent Encoding)
|
||||
|
||||
URLs have reserved characters (`?`, `&`, `=`, `/`, `#`, etc.) that have special meaning. URL encoding replaces unsafe characters with `%XX` where `XX` is the hex value.
|
||||
|
||||
```
|
||||
Input: "hello world&key=val"
|
||||
Encoded: "hello%20world%26key%3Dval"
|
||||
```
|
||||
|
||||
Space is `%20` in standard URL encoding but `+` in form encoding (`application/x-www-form-urlencoded`). The distinction matters for web security testing.
|
||||
|
||||
## RFC 4648
|
||||
|
||||
RFC 4648 is the definitive standard for Base16, Base32, and Base64 encoding. Key points:
|
||||
|
||||
- Defines the exact alphabets and padding rules
|
||||
- Specifies when decoders MUST reject vs MAY accept malformed input
|
||||
- Base64 padding is `=`, and it MUST appear only at the end
|
||||
- Decoders that accept non-alphabet characters create security issues (padding oracle attacks become possible)
|
||||
|
||||
Python's `base64` module follows RFC 4648. The `validate=True` parameter in `b64decode` enforces strict compliance, rejecting any non-alphabet characters. This project uses strict decoding.
|
||||
|
||||
## Encoding in Attacks
|
||||
|
||||
### Double Encoding (WAF Bypass)
|
||||
|
||||
Web Application Firewalls (WAFs) check request parameters for malicious content. If you URL-encode a payload, the WAF decodes it once and checks. But what if you double-encode?
|
||||
|
||||
```
|
||||
Payload: <script>alert(1)</script>
|
||||
Single: %3Cscript%3Ealert(1)%3C/script%3E ← WAF catches this
|
||||
Double: %253Cscript%253Ealert(1)%253C/script%253E ← WAF decodes once,
|
||||
sees %3C (looks harmless), passes it through
|
||||
```
|
||||
|
||||
The web server decodes again, and the browser gets the original XSS payload. This is OWASP's A05:2025 Injection category. Double encoding still works against poorly configured WAFs in 2026.
|
||||
|
||||
### Multi-Layer Obfuscation (Malware)
|
||||
|
||||
The DARKGATE malware (actively sold on dark web forums) encodes its configuration and keylogger output using a custom, non-standard base64 alphabet. Older versions used a hardcoded custom alphabet. Version 5.2.3+ randomizes the alphabet based on a hardware ID seed.
|
||||
|
||||
Researchers at Kroll found a weakness: the hardware ID is a 32-byte ASCII MD5 hash, and DARKGATE sums these bytes as a seed. The sum has limited entropy, making brute-force recovery of the custom alphabet feasible.
|
||||
|
||||
This is why understanding encoding matters for security: real malware uses real encoding tricks, and analysts need to decode them.
|
||||
|
||||
### Data Exfiltration
|
||||
|
||||
Attackers exfiltrate data by encoding it and embedding it in seemingly normal traffic:
|
||||
- Base64 data in DNS TXT record queries
|
||||
- Hex-encoded payloads in HTTP headers
|
||||
- URL-encoded data in query parameters that look like analytics tracking
|
||||
|
||||
Tools like this one help defenders spot and decode these patterns.
|
||||
|
||||
## Encoding Detection
|
||||
|
||||
How do you figure out what encoding something is? Pattern matching and heuristics.
|
||||
|
||||
**Base64 signals:**
|
||||
- Characters from `A-Za-z0-9+/=`
|
||||
- Length divisible by 4
|
||||
- Ends with 0, 1, or 2 `=` characters
|
||||
- Mixed case (uppercase AND lowercase letters)
|
||||
|
||||
**Base32 signals:**
|
||||
- All uppercase `A-Z` and digits `2-7`
|
||||
- Length divisible by 8
|
||||
- Padding with 0, 1, 3, 4, or 6 `=` characters
|
||||
|
||||
**Hex signals:**
|
||||
- Only `0-9` and `a-f` (or `A-F`)
|
||||
- Even length
|
||||
- Consistent casing (all lower or all upper)
|
||||
- Sometimes has separators (`:`, `-`, spaces)
|
||||
|
||||
**URL encoding signals:**
|
||||
- Contains `%XX` sequences where XX is valid hex
|
||||
|
||||
The tricky part: some strings match multiple formats. The hex string `CAFE` is also valid base32 (if padded to 8 chars) and base64 (if length works out). Good detection uses confidence scoring, not binary yes/no. This project assigns each format a confidence from 0.0 to 1.0 and ranks them.
|
||||
|
||||
## Testing Your Understanding
|
||||
|
||||
1. Why does base64 output always have a length divisible by 4?
|
||||
2. A developer stores API keys as base64 in a config file and calls it "encrypted configuration." What's wrong?
|
||||
3. You see the string `JBSWY3DPEBLW64TMMQ======`. What encoding is this and how can you tell?
|
||||
4. An IDS alert shows `%253Cscript%253E` in a URL parameter. What happened?
|
||||
5. Why would malware use a non-standard base64 alphabet instead of the RFC 4648 one?
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
# Architecture
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Every module in this project has one job. `encoders.py` transforms data. `detector.py` scores formats. `peeler.py` orchestrates recursive decoding. `formatter.py` renders output. `cli.py` wires user input to functions. No module reaches into another module's concern.
|
||||
|
||||
This isn't over-engineering for a small project. It's how you keep a small project from becoming an unmanageable one. When you need to add a new encoding format, you touch `encoders.py` and `detector.py`. That's it. The CLI, formatter, and peeler don't change.
|
||||
|
||||
## Module Dependency Graph
|
||||
|
||||
```
|
||||
cli.py
|
||||
├── constants.py (EncodingFormat, ExitCode, PEEL_MAX_DEPTH)
|
||||
├── encoders.py (encode, decode, encode_url, decode_url)
|
||||
├── detector.py (detect_encoding)
|
||||
├── peeler.py (peel)
|
||||
├── formatter.py (print_encoded, print_decoded, print_detection, print_peel_result, print_chain_result)
|
||||
└── utils.py (resolve_input_bytes, resolve_input_text)
|
||||
|
||||
peeler.py
|
||||
├── constants.py (CONFIDENCE_THRESHOLD, PEEL_MAX_DEPTH, EncodingFormat)
|
||||
├── detector.py (detect_best)
|
||||
└── utils.py (safe_bytes_preview, truncate)
|
||||
|
||||
detector.py
|
||||
├── constants.py (charsets, thresholds, EncodingFormat)
|
||||
├── encoders.py (try_decode)
|
||||
└── utils.py (is_printable_text)
|
||||
|
||||
formatter.py
|
||||
├── constants.py (EncodingFormat, PREVIEW_LENGTH)
|
||||
├── detector.py (DetectionResult — type only)
|
||||
├── peeler.py (PeelResult — type only)
|
||||
└── utils.py (safe_bytes_preview)
|
||||
|
||||
encoders.py
|
||||
└── constants.py (EncodingFormat)
|
||||
|
||||
utils.py
|
||||
└── (no internal deps)
|
||||
|
||||
constants.py
|
||||
└── (no internal deps)
|
||||
```
|
||||
|
||||
The dependency arrows always point downward. `constants.py` and `utils.py` sit at the bottom with zero internal dependencies. `cli.py` sits at the top, importing from everything. Nothing in the middle reaches upward. This is a directed acyclic graph (DAG), and if you ever create a circular import, Python will tell you immediately.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Encode Command
|
||||
|
||||
```
|
||||
User Input (str or file or stdin)
|
||||
│
|
||||
▼
|
||||
resolve_input_bytes() ← utils.py:12
|
||||
│ Converts any input source to raw bytes
|
||||
▼
|
||||
encode(raw, fmt) ← encoders.py:88
|
||||
│ Dispatches via ENCODER_REGISTRY to format-specific function
|
||||
▼
|
||||
encode_base64(data) (or other) ← encoders.py:22
|
||||
│ Returns encoded string
|
||||
▼
|
||||
print_encoded(result, fmt) ← formatter.py:31
|
||||
│ Rich panel if terminal, raw stdout if piped
|
||||
▼
|
||||
Output
|
||||
```
|
||||
|
||||
### Decode Command
|
||||
|
||||
```
|
||||
User Input (str or file or stdin)
|
||||
│
|
||||
▼
|
||||
resolve_input_text() ← utils.py:29
|
||||
│ Converts any input source to stripped text
|
||||
▼
|
||||
decode(text, fmt) ← encoders.py:93
|
||||
│ Dispatches via ENCODER_REGISTRY to format-specific function
|
||||
▼
|
||||
decode_base64(data) (or other) ← encoders.py:26
|
||||
│ Returns decoded bytes
|
||||
▼
|
||||
print_decoded(result) ← formatter.py:44
|
||||
│ Safe preview (UTF-8 if possible, hex fallback)
|
||||
▼
|
||||
Output
|
||||
```
|
||||
|
||||
### Detect Command
|
||||
|
||||
```
|
||||
User Input (str)
|
||||
│
|
||||
▼
|
||||
detect_encoding(text) ← detector.py:206
|
||||
│
|
||||
├──► _score_base64(text) ← detector.py:31
|
||||
├──► _score_base64url(text)← detector.py:70
|
||||
├──► _score_base32(text) ← detector.py:97
|
||||
├──► _score_hex(text) ← detector.py:126
|
||||
└──► _score_url(text) ← detector.py:174
|
||||
│
|
||||
│ Each scorer returns 0.0–1.0
|
||||
│ Results filtered by CONFIDENCE_THRESHOLD (0.6)
|
||||
│ Sorted by confidence descending
|
||||
▼
|
||||
print_detection(results) ← formatter.py:58
|
||||
│ Rich table: format, confidence %, decoded preview
|
||||
▼
|
||||
Output
|
||||
```
|
||||
|
||||
### Peel Command (the star feature)
|
||||
|
||||
```
|
||||
User Input (str)
|
||||
│
|
||||
▼
|
||||
peel(text, max_depth=20) ← peeler.py:33
|
||||
│
|
||||
├──► LOOP (up to max_depth iterations):
|
||||
│ │
|
||||
│ ├── detect_best(current_text) ← detector.py:226
|
||||
│ │ Returns highest-confidence detection
|
||||
│ │
|
||||
│ ├── Break if: no detection, below threshold, decode fails
|
||||
│ │
|
||||
│ ├── Record PeelLayer (depth, format, confidence, previews)
|
||||
│ │
|
||||
│ └── decoded_bytes → current_text for next iteration
|
||||
│ (break if bytes aren't valid UTF-8)
|
||||
│
|
||||
▼
|
||||
PeelResult(layers, final_output, success)
|
||||
│
|
||||
▼
|
||||
print_peel_result(result) ← formatter.py:94
|
||||
│ Layer-by-layer display + final output panel
|
||||
▼
|
||||
Output
|
||||
```
|
||||
|
||||
### Chain Command
|
||||
|
||||
```
|
||||
User Input (str) + --steps "base64,hex,url"
|
||||
│
|
||||
▼
|
||||
resolve_input_bytes() ← utils.py:12
|
||||
│
|
||||
▼
|
||||
_parse_chain_steps("base64,hex,url") ← cli.py:264
|
||||
│ Validates each format name against EncodingFormat enum
|
||||
│ Returns [BASE64, HEX, URL]
|
||||
▼
|
||||
LOOP over formats:
|
||||
│
|
||||
├── encode(current_bytes, fmt) ← encoders.py:88
|
||||
├── Record (fmt, encoded_string)
|
||||
└── encoded_string → bytes for next iteration
|
||||
│
|
||||
▼
|
||||
print_chain_result(steps, final) ← formatter.py:130
|
||||
│ Step-by-step display + final panel
|
||||
▼
|
||||
Output
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Registry Pattern (encoders.py:73–85)
|
||||
|
||||
Instead of a chain of `if fmt == "base64": ... elif fmt == "base64url": ...`, every encoder and decoder pair is registered in a dictionary:
|
||||
|
||||
```
|
||||
ENCODER_REGISTRY: dict[EncodingFormat, tuple[EncoderFn, DecoderFn]]
|
||||
```
|
||||
|
||||
Adding a new format means adding one entry to the registry and writing the two functions. The dispatch functions `encode()` and `decode()` never change. This is the open-closed principle: open for extension, closed for modification.
|
||||
|
||||
### Frozen Dataclasses (detector.py:24, peeler.py:17, peeler.py:26)
|
||||
|
||||
All result types use `@dataclass(frozen=True, slots=True)`. Frozen means the fields can't be mutated after creation. Slots means no `__dict__` per instance, which uses less memory and is slightly faster. For data that flows through a pipeline and should never be changed, frozen dataclasses are the right tool.
|
||||
|
||||
### Pipeline-Friendly Output (formatter.py:22–28)
|
||||
|
||||
The tool detects whether stdout is a terminal or a pipe. When piped (`echo "data" | b64tool decode | other_tool`), it writes raw text to stdout with no Rich formatting. When interactive, it shows panels, tables, and colors. This happens via `is_piped()` checking `sys.stdout.isatty()`.
|
||||
|
||||
Rich output goes to stderr (`Console(stderr=True)` at `formatter.py:19`), so diagnostic messages never contaminate piped data. This is a standard Unix convention that many CLI tools get wrong.
|
||||
|
||||
### Scorer Architecture (detector.py:195–203)
|
||||
|
||||
Detection uses the same registry pattern as encoding. Each format has a scorer function with the signature `Callable[[str], float]`. The `_SCORERS` dictionary maps `EncodingFormat` to its scorer. This means adding detection for a new format requires writing one scorer function and adding one dict entry.
|
||||
|
||||
Every scorer follows the same structure:
|
||||
1. Quick rejection (charset check, length check)
|
||||
2. Accumulate a confidence score based on structural signals
|
||||
3. Attempt actual decoding
|
||||
4. Bonus if decoded output is printable text
|
||||
5. Return clamped to [0.0, 1.0]
|
||||
|
||||
### Type Aliases with PEP 695 (encoders.py:18–19)
|
||||
|
||||
```
|
||||
type EncoderFn = Callable[[bytes], str]
|
||||
type DecoderFn = Callable[[str], bytes]
|
||||
```
|
||||
|
||||
Python 3.12+ `type` statements (PEP 695) replace `TypeAlias` from `typing`. They're lazily evaluated and more readable. These aliases document the contract: encoders take bytes and return strings, decoders take strings and return bytes.
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
Errors are handled at two levels:
|
||||
|
||||
**Module level**: Functions like `try_decode()` (`encoders.py:98`) catch encoding-specific exceptions and return `None`. The detector and peeler use this to gracefully handle decode failures without crashing.
|
||||
|
||||
**CLI level**: Each command (`cli.py`) wraps its body in a try/except. `typer.BadParameter` is re-raised (Typer formats these nicely). All other exceptions get a `[red]Error:[/red]` message and exit code 1. This prevents stack traces from leaking to end users.
|
||||
|
||||
The intermediate modules (detector, peeler) never catch exceptions themselves. They call `try_decode()` and check for `None`. This keeps error handling at the boundaries, not scattered through business logic.
|
||||
|
||||
## Why Not a Class?
|
||||
|
||||
None of the core modules use classes for behavior (only for data: `EncodingFormat`, `DetectionResult`, `PeelLayer`, `PeelResult`). The encoder functions are pure functions. The scorers are pure functions. The peeler is a function. There's no shared mutable state to encapsulate, so there's no reason for a class.
|
||||
|
||||
An `Encoder` class with `encode()` and `decode()` methods would add indirection without adding value. The registry dict achieves the same polymorphism with less ceremony. This is idiomatic Python: use classes for data, functions for behavior, unless you have state to manage.
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
# Implementation
|
||||
|
||||
## constants.py — The Foundation
|
||||
|
||||
Everything that other modules depend on lives here. No business logic, just definitions.
|
||||
|
||||
### EncodingFormat Enum (line 10)
|
||||
|
||||
```python
|
||||
class EncodingFormat(StrEnum):
|
||||
BASE64 = "base64"
|
||||
BASE64URL = "base64url"
|
||||
BASE32 = "base32"
|
||||
HEX = "hex"
|
||||
URL = "url"
|
||||
```
|
||||
|
||||
`StrEnum` (Python 3.11+) means each member is also a string. You can compare `EncodingFormat.BASE64 == "base64"` and it's `True`. Typer uses this directly for the `--format` option: it auto-generates the choices from the enum values. No separate validation code needed.
|
||||
|
||||
### Character Sets (lines 34–46)
|
||||
|
||||
```python
|
||||
BASE64_CHARSET: Final[frozenset[str]] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
|
||||
```
|
||||
|
||||
Each encoding's valid character set is a `frozenset` for O(1) membership testing. The detector checks `all(c in BASE64_CHARSET for c in stripped)`. With a frozenset, each character lookup is a hash table check instead of a linear scan. For short strings the difference is negligible, but it's the right data structure regardless.
|
||||
|
||||
`Final` from `typing` tells mypy these are constants that should never be reassigned.
|
||||
|
||||
### Thresholds (lines 24–33)
|
||||
|
||||
`PEEL_MAX_DEPTH = 20` caps recursion. In practice, even heavily obfuscated malware payloads rarely exceed 5–6 layers, but the cap prevents pathological inputs from running forever.
|
||||
|
||||
`CONFIDENCE_THRESHOLD = 0.6` is the minimum score for a detection to be considered valid. This value was tuned to avoid false positives while catching legitimate encodings. Too low and random strings trigger false detections. Too high and short or ambiguous encodings get missed.
|
||||
|
||||
`MIN_INPUT_LENGTH = 4` rejects trivially short inputs from detection. A 3-character string could match almost any format by coincidence.
|
||||
|
||||
## encoders.py — Pure Transformations
|
||||
|
||||
### Encode/Decode Functions (lines 22–70)
|
||||
|
||||
Each format has a dedicated encode and decode function. They're small, focused, and independently testable.
|
||||
|
||||
Notable implementation details:
|
||||
|
||||
**decode_base64 (line 26)**: Uses `validate=True` in `b64.b64decode()`. Without this flag, Python's base64 decoder silently ignores non-alphabet characters. With it, any invalid character raises `binascii.Error`. This is RFC 4648 strict compliance. The `01-CONCEPTS.md` doc explains why lax decoders create security issues (padding oracle attacks).
|
||||
|
||||
**decode_base64 and decode_base64url (lines 27, 36)**: Both call `"".join(data.split())` before decoding. This strips all whitespace (newlines, spaces, tabs). Base64 data often comes wrapped at 76 characters (MIME standard), and the decoder needs to handle that.
|
||||
|
||||
**decode_base32 (line 45)**: Calls `.upper()` after stripping whitespace. Base32's alphabet is uppercase only, but users might paste lowercase. Case-normalizing before decoding is more forgiving without being unsafe.
|
||||
|
||||
**decode_hex (lines 53–57)**: Strips common hex separators (space, colon, dash, dot). This means `AA:BB:CC`, `AA BB CC`, `AA-BB-CC`, and `AABBCC` all decode correctly. Real hex data comes in many formats: MAC addresses use colons, hex dumps use spaces, some tools use dashes.
|
||||
|
||||
**encode_url / decode_url (lines 60–70)**: Support a `form` keyword-only argument. Standard URL encoding uses `%20` for spaces. Form encoding (`application/x-www-form-urlencoded`) uses `+`. The distinction matters for web security testing, so both are supported. The `*` in `def encode_url(data: bytes, *, form: bool = False)` forces `form` to be keyword-only, preventing accidental positional usage.
|
||||
|
||||
### The Registry (lines 73–85)
|
||||
|
||||
```python
|
||||
ENCODER_REGISTRY: dict[EncodingFormat, tuple[EncoderFn, DecoderFn]] = {
|
||||
EncodingFormat.BASE64: (encode_base64, decode_base64),
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Functions are first-class objects in Python. This dict maps each format to its (encoder, decoder) pair. Dispatch is a dictionary lookup:
|
||||
|
||||
```python
|
||||
def encode(data: bytes, fmt: EncodingFormat) -> str:
|
||||
encoder_fn, _ = ENCODER_REGISTRY[fmt]
|
||||
return encoder_fn(data)
|
||||
```
|
||||
|
||||
The URL entry uses lambdas (`lambda data: encode_url(data)`) because the URL functions have an extra `form` parameter that doesn't match the `EncoderFn` / `DecoderFn` type signatures. The lambdas create wrapper functions with the default `form=False`.
|
||||
|
||||
### try_decode (lines 98–107)
|
||||
|
||||
```python
|
||||
def try_decode(data: str, fmt: EncodingFormat) -> bytes | None:
|
||||
try:
|
||||
return decode(data, fmt)
|
||||
except (ValueError, binascii.Error, UnicodeDecodeError, UnicodeEncodeError):
|
||||
return None
|
||||
```
|
||||
|
||||
The detector and peeler need to attempt decoding without crashing if the input is invalid. `try_decode` wraps `decode` and converts exceptions to `None`. The exception tuple covers all possible decode failures across all formats. This is the "errors as values" pattern, common in functional programming.
|
||||
|
||||
## detector.py — Confidence Scoring
|
||||
|
||||
### DetectionResult (line 24)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DetectionResult:
|
||||
format: EncodingFormat
|
||||
confidence: float
|
||||
decoded: bytes | None
|
||||
```
|
||||
|
||||
Every detection result carries its confidence as a float from 0.0 to 1.0. This is fundamentally different from a binary "is it base64? yes/no" check. A string like `CAFE` could be hex or base64. Confidence scoring lets the system say "it's 80% likely hex, 50% likely base64" and rank accordingly.
|
||||
|
||||
### Scorer Walkthrough: _score_base64 (lines 31–67)
|
||||
|
||||
This is the most nuanced scorer because base64's character set overlaps with other formats.
|
||||
|
||||
**Phase 1 — Quick rejection (lines 32–38)**:
|
||||
- Strip whitespace
|
||||
- Reject if shorter than 4 characters
|
||||
- Reject if any character isn't in `BASE64_CHARSET`
|
||||
- Reject if length isn't divisible by 4
|
||||
|
||||
These checks are cheap. If any fails, the function returns 0.0 immediately without attempting a decode.
|
||||
|
||||
**Phase 2 — Structural scoring (lines 40–58)**:
|
||||
- Start at 0.4 (baseline for matching charset + length)
|
||||
- +0.1 for valid padding (0, 1, or 2 `=` characters)
|
||||
- +0.1 if `+` or `/` present (these distinguish base64 from hex/base32)
|
||||
- +0.1 for mixed case (uppercase AND lowercase letters)
|
||||
- -0.2 penalty if no uppercase and no special characters (`+`, `/`, `=`)
|
||||
- +0.05 for length >= 8
|
||||
|
||||
That -0.2 penalty is critical. A hex string like `6132666338` contains only lowercase letters and digits. Without the penalty, it scores 0.4 + 0.1 (padding) + 0.05 (length) + 0.15 (decode) + 0.15 (printable) = 0.85 as base64. With the penalty: 0.65. Meanwhile the hex scorer gives it 0.80. Hex wins.
|
||||
|
||||
**Phase 3 — Decode validation (lines 60–66)**:
|
||||
- Attempt actual base64 decode with `try_decode`
|
||||
- If decode fails, return 0.0 (invalid base64 structure)
|
||||
- +0.15 for successful decode
|
||||
- +0.15 if decoded output is printable text
|
||||
|
||||
### The Hex vs Base64 Disambiguation Problem
|
||||
|
||||
This was the hardest detection challenge. Consider the hex string `6332566a636d563049484268655778765957513d` (which is hex-encoded `c2VjcmV0IHBheWxvYWQ=`, which is base64-encoded `secret payload`).
|
||||
|
||||
Every character in that hex string (`0-9`, `a-f`) is also a valid base64 character. The string's length happens to be divisible by 4. Without careful scoring, both formats get similar confidence.
|
||||
|
||||
The solution uses two signals:
|
||||
|
||||
1. **Mixed-case check in base64 scorer (lines 50–55)**: Hex strings are typically all lowercase or all uppercase. Base64 almost always has mixed case. If there's no uppercase and no special chars, base64 gets a -0.2 penalty.
|
||||
|
||||
2. **Consistent-case bonus in hex scorer (lines 154–156)**: If all alphabetic characters are the same case, hex gets a +0.1 bonus.
|
||||
|
||||
These two adjustments create reliable separation. The hex string scores ~0.80 for hex and ~0.50 for base64.
|
||||
|
||||
### _score_url (lines 174–192)
|
||||
|
||||
The URL scorer works differently from the others. Instead of checking a character set, it uses a regex to find `%XX` patterns:
|
||||
|
||||
```python
|
||||
_URL_PATTERN = re.compile(r"%[0-9a-fA-F]{2}")
|
||||
```
|
||||
|
||||
The confidence scales with the ratio of encoded characters to total length. A string that's 50% percent-encoded sequences gets a higher score than one with a single `%20`. This handles the spectrum from "mostly normal text with one encoded space" to "completely percent-encoded payload."
|
||||
|
||||
### detect_encoding (lines 206–223)
|
||||
|
||||
The orchestrator. Runs every scorer, filters by threshold, sorts by confidence descending. The caller gets a ranked list of all possible formats above 0.6 confidence.
|
||||
|
||||
`detect_best` (lines 226–228) is a convenience that returns just the top result.
|
||||
|
||||
## peeler.py — Recursive Layer Stripping
|
||||
|
||||
### The Core Loop (lines 33–73)
|
||||
|
||||
The peel function is iterative, not recursive. Each iteration:
|
||||
|
||||
1. Calls `detect_best(current_text)` to find the most likely format
|
||||
2. Checks three break conditions: no detection, below threshold, decode returned None
|
||||
3. Records a `PeelLayer` with the current depth, format, confidence, and previews
|
||||
4. Decodes and tries to interpret the result as UTF-8
|
||||
|
||||
The UTF-8 check on line 65 is the natural stop condition. When you hit the original data (which might be binary), it won't be valid UTF-8, and the loop breaks. This also prevents the peeler from trying to "detect" encodings in binary garbage.
|
||||
|
||||
### Why Iterative Instead of Recursive
|
||||
|
||||
The function uses a `for` loop with `max_depth` instead of calling itself. In Python, recursive calls have overhead (new stack frames) and risk `RecursionError` for deep chains. An iterative approach with explicit loop state is cleaner and has a hard cap via `range(max_depth)`.
|
||||
|
||||
### PeelResult Design (line 26)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeelResult:
|
||||
layers: tuple[PeelLayer, ...]
|
||||
final_output: bytes
|
||||
success: bool
|
||||
```
|
||||
|
||||
`layers` is a tuple, not a list. Since the result is frozen, the layers shouldn't be mutable either. `final_output` is `bytes` rather than `str` because the final decoded content might be binary data. `success` is `True` if at least one layer was peeled, `False` if the input didn't look encoded at all.
|
||||
|
||||
## formatter.py — Terminal Output
|
||||
|
||||
### stderr for Rich, stdout for Data (line 19)
|
||||
|
||||
```python
|
||||
console = Console(stderr=True)
|
||||
```
|
||||
|
||||
All Rich output (panels, tables, color) goes to stderr. When the user pipes output (`b64tool decode "..." | other_tool`), only raw data hits stdout. The `is_piped()` check on line 22 controls this: piped mode writes raw strings, terminal mode writes Rich panels.
|
||||
|
||||
### Confidence Color Coding (lines 164–169)
|
||||
|
||||
```python
|
||||
def _confidence_color(confidence: float) -> str:
|
||||
if confidence >= 0.9:
|
||||
return "green"
|
||||
if confidence >= 0.7:
|
||||
return "yellow"
|
||||
return "red"
|
||||
```
|
||||
|
||||
Visual feedback in the peel output. Green means the tool is very confident about the detected format. Yellow means it's likely correct but there's some ambiguity. Red means the detection is marginal (just above the 0.6 threshold).
|
||||
|
||||
## cli.py — Wiring It Together
|
||||
|
||||
### Typer Application (lines 39–45)
|
||||
|
||||
```python
|
||||
app = typer.Typer(
|
||||
name="b64tool",
|
||||
help="Multi-format encoding/decoding CLI with recursive layer detection",
|
||||
no_args_is_help=True,
|
||||
pretty_exceptions_show_locals=False,
|
||||
)
|
||||
```
|
||||
|
||||
`no_args_is_help=True` shows the help message when the user runs `b64tool` with no subcommand. `pretty_exceptions_show_locals=False` prevents Typer from dumping local variables on crash, which could expose sensitive data being encoded/decoded.
|
||||
|
||||
### Annotated Type Hints (throughout)
|
||||
|
||||
Every CLI argument and option uses `Annotated[type, typer.Option(...)]`. This is the modern Typer pattern (replacing the older `typer.Option(default, ...)` style). The type annotation and CLI metadata stay together, and mypy validates the types correctly.
|
||||
|
||||
### Chain Step Parsing (lines 264–281)
|
||||
|
||||
```python
|
||||
def _parse_chain_steps(raw: str) -> list[EncodingFormat]:
|
||||
```
|
||||
|
||||
Parses `"base64,hex,url"` into `[EncodingFormat.BASE64, EncodingFormat.HEX, EncodingFormat.URL]`. Each step is stripped, lowercased, and validated against the enum. Invalid format names produce a clear error listing all valid options. This is a private function (prefixed with `_`) because it's implementation detail of the chain command.
|
||||
|
||||
## utils.py — Input Resolution
|
||||
|
||||
### Three Input Sources (lines 12–43)
|
||||
|
||||
Both `resolve_input_bytes` and `resolve_input_text` follow the same priority:
|
||||
|
||||
1. **File** (`--file` flag): Read from disk
|
||||
2. **Argument**: Direct string on the command line
|
||||
3. **Stdin**: Piped input (only if stdin is not a TTY)
|
||||
4. If none: raise `typer.BadParameter`
|
||||
|
||||
This means these all work:
|
||||
```bash
|
||||
b64tool encode "Hello"
|
||||
b64tool encode --file data.txt
|
||||
echo "Hello" | b64tool encode
|
||||
```
|
||||
|
||||
The distinction between bytes and text matters. Encoding operates on bytes (you can encode a binary file). Decoding operates on text (the encoded string is always ASCII-safe). This is why there are two separate functions rather than one generic one.
|
||||
|
||||
### is_printable_text (lines 60–68)
|
||||
|
||||
Used by the detector to boost confidence when decoded output looks like readable text. The threshold (default 0.8) means at least 80% of characters must be printable or common whitespace (`\n`, `\r`, `\t`). This prevents binary data that happens to decode without errors from getting a "printable text" bonus.
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
# Challenges
|
||||
|
||||
## Easy
|
||||
|
||||
### 1. Add ROT13 Encoding
|
||||
|
||||
ROT13 shifts each letter by 13 positions. It's a Caesar cipher with a fixed key that's its own inverse (encoding and decoding are the same operation).
|
||||
|
||||
**What to do:**
|
||||
- Add `ROT13 = "rot13"` to `EncodingFormat` in `constants.py`
|
||||
- Write `encode_rot13(data: bytes) -> str` and `decode_rot13(data: str) -> bytes` in `encoders.py`
|
||||
- Add the entry to `ENCODER_REGISTRY`
|
||||
- Write a scorer `_score_rot13` in `detector.py` (hint: try decoding and check if the result has more recognizable English words than the input)
|
||||
- Add tests
|
||||
|
||||
**Why this matters:** ROT13 is trivial to implement but the detection scorer is tricky. How do you distinguish ROT13 text from plain ASCII text? Both use the same character set. You'll need a language-level heuristic (letter frequency analysis, common word matching).
|
||||
|
||||
### 2. Add JSON Output Mode
|
||||
|
||||
Currently output is either Rich panels or raw text. Add a `--json` flag that outputs structured JSON.
|
||||
|
||||
**What to do:**
|
||||
- Add a `--json` / `-j` flag to the `detect` and `peel` commands in `cli.py`
|
||||
- When active, serialize `DetectionResult` and `PeelResult` to JSON and write to stdout
|
||||
- Skip Rich formatting entirely in JSON mode
|
||||
|
||||
**Example output:**
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"format": "base64",
|
||||
"confidence": 0.95,
|
||||
"decoded_preview": "Hello World"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:** Machine-readable output is essential for integrating CLI tools into automated pipelines. Security automation workflows (SOAR, SIEM correlation) consume JSON, not colored terminal text.
|
||||
|
||||
### 3. Add `--output` File Flag
|
||||
|
||||
Add a `-o` / `--output` flag to write results directly to a file instead of stdout.
|
||||
|
||||
**What to do:**
|
||||
- Add the option to `encode`, `decode`, and `peel` commands
|
||||
- For encode/decode, write the raw result to the file
|
||||
- For peel, write the final output
|
||||
- Print a confirmation message to stderr
|
||||
|
||||
**Why this matters:** When decoding binary payloads (malware samples, images), you need the exact bytes written to disk, not a terminal preview.
|
||||
|
||||
---
|
||||
|
||||
## Medium
|
||||
|
||||
### 4. Add ASCII85 (Base85) Encoding
|
||||
|
||||
ASCII85 encodes 4 bytes into 5 characters using 85 printable ASCII characters. It's used in PDF files and Git binary patches.
|
||||
|
||||
**What to do:**
|
||||
- Add `ASCII85 = "ascii85"` to `EncodingFormat`
|
||||
- Use `base64.a85encode()` and `base64.a85decode()` from Python's standard library
|
||||
- Write a scorer for detection (ASCII85 data has a distinctive character distribution, often includes characters like `!`, `@`, `#` that don't appear in other encodings)
|
||||
- Handle the `<~...~>` wrapper that Adobe's variant uses
|
||||
|
||||
**Why this matters:** You'll encounter ASCII85 in PDF malware analysis. Malicious PDFs embed executable content using ASCII85 streams.
|
||||
|
||||
### 5. Decode-Chain Command (Reverse of Chain)
|
||||
|
||||
The `chain` command encodes through multiple layers. Build a `decode-chain` command that takes a chain specification and decodes in reverse order.
|
||||
|
||||
**What to do:**
|
||||
- Add a `decode-chain` command to `cli.py`
|
||||
- Accept `--steps` like the chain command
|
||||
- Apply decoders in reverse order: `--steps base64,hex` decodes hex first, then base64
|
||||
- Show each intermediate step
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
b64tool decode-chain "4147567362473873" --steps base64,hex
|
||||
# Step 1: hex decode → "AGVsbG8s"
|
||||
# Step 2: base64 decode → "Hello,"
|
||||
```
|
||||
|
||||
**Why this matters:** When you know the encoding order from analysis (or from the `peel` output), you can verify by explicitly specifying the decode chain. This is useful for validating peel results.
|
||||
|
||||
### 6. Hex Dump Display
|
||||
|
||||
Add a `hexdump` command that shows decoded binary data in traditional hex dump format (offset, hex bytes, ASCII representation).
|
||||
|
||||
**What to do:**
|
||||
- Add a `hexdump` command that takes encoded input and a format flag
|
||||
- Decode the input, then display in hex dump format:
|
||||
```
|
||||
00000000 48 65 6c 6c 6f 20 57 6f 72 6c 64 21 0a 00 ff fe |Hello World!....|
|
||||
```
|
||||
- 16 bytes per line, offset on the left, ASCII on the right (non-printable as `.`)
|
||||
|
||||
**Why this matters:** Hex dump is the standard view for binary analysis. Malware analysts live in hex dump views. Combining decode + hexdump in one tool saves piping through `xxd` or `hexdump`.
|
||||
|
||||
---
|
||||
|
||||
## Hard
|
||||
|
||||
### 7. Custom Base64 Alphabet Support
|
||||
|
||||
The DARKGATE malware uses custom base64 alphabets to evade detection tools that only understand RFC 4648. Build support for encoding/decoding with arbitrary alphabets.
|
||||
|
||||
**What to do:**
|
||||
- Add a `--alphabet` option to encode and decode commands
|
||||
- Accept a 64-character string (or 65 with padding character)
|
||||
- Implement custom alphabet encode/decode without relying on Python's `base64` module (it doesn't support custom alphabets directly)
|
||||
- The encoding logic: take 3 bytes, split into four 6-bit values, map each to the custom alphabet
|
||||
- Add a `--alphabet-file` option for reading the alphabet from a file
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
b64tool encode "secret" --format base64 --alphabet "ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvutsrqponmlkjihgfedcba9876543210+/"
|
||||
```
|
||||
|
||||
**Why this matters:** Real malware uses this. Building it teaches you the actual encoding math (bit manipulation, not just calling library functions). Threat intelligence teams need tools that handle non-standard alphabets.
|
||||
|
||||
### 8. Encoding Frequency Analysis
|
||||
|
||||
Add an `analyze` command that takes a file of encoded samples and reports statistics.
|
||||
|
||||
**What to do:**
|
||||
- Read a file where each line is an encoded sample
|
||||
- Run detection on every line
|
||||
- Report: format distribution, average confidence per format, detection failures
|
||||
- Flag samples that match multiple formats (ambiguous detections)
|
||||
- Output as a summary table and optionally as JSON
|
||||
|
||||
**Why this matters:** SOC analysts often receive bulk indicators (IOCs) and need to quickly categorize them. Is this list of strings mostly base64 tokens? Hex hashes? Mixed? Bulk analysis answers that.
|
||||
|
||||
### 9. Streaming Mode for Large Files
|
||||
|
||||
Currently the tool reads entire files into memory. Add streaming support for large file processing.
|
||||
|
||||
**What to do:**
|
||||
- For base64 encoding: read 3 bytes at a time (produces 4 output chars), or buffer in multiples of 3
|
||||
- For base64 decoding: read 4 characters at a time
|
||||
- For hex: read 1 byte / 2 characters at a time
|
||||
- Implement with generators to maintain constant memory usage
|
||||
- Add a `--stream` flag or auto-detect based on file size
|
||||
|
||||
**Why this matters:** Memory-mapping a 2 GB file to base64 encode it will crash most systems. Streaming processes any file size with constant memory. This is how production tools like `base64` (coreutils) work.
|
||||
|
||||
---
|
||||
|
||||
## Expert
|
||||
|
||||
### 10. Binary Format Detection in Decoded Output
|
||||
|
||||
After decoding, automatically detect what the decoded binary data is (file type magic bytes).
|
||||
|
||||
**What to do:**
|
||||
- After decode or peel, check the first few bytes against known magic numbers:
|
||||
- `\x89PNG\r\n\x1a\n` → PNG image
|
||||
- `PK\x03\x04` → ZIP/DOCX/JAR archive
|
||||
- `\x7fELF` → ELF executable (Linux binary)
|
||||
- `MZ` → PE executable (Windows binary)
|
||||
- `%PDF` → PDF document
|
||||
- `GIF87a` / `GIF89a` → GIF image
|
||||
- Display the detected file type alongside the decoded output
|
||||
- Optionally save to a file with the correct extension
|
||||
|
||||
**Why this matters:** Malware payloads are often base64-encoded executables. Knowing that a decoded blob is a PE executable versus a PNG image changes your entire analysis approach. The DARKGATE loader encodes its second-stage payload in custom base64 — the decoded output is a DLL.
|
||||
|
||||
### 11. Entropy Analysis
|
||||
|
||||
Add an `entropy` command that calculates Shannon entropy of data before and after decoding.
|
||||
|
||||
**What to do:**
|
||||
- Calculate Shannon entropy: `H = -Σ p(x) log2(p(x))` for each byte value
|
||||
- Report entropy on a 0–8 scale (8 bits per byte = maximum entropy)
|
||||
- Compare entropy before and after decoding
|
||||
- Flag high-entropy decoded output (likely encrypted or compressed, not just encoded)
|
||||
|
||||
**Interpretation guide:**
|
||||
- 0–1: highly structured/repetitive (like all zeros)
|
||||
- 3–5: English text, source code
|
||||
- 6–7: compressed data, some binary formats
|
||||
- 7.5–8: encrypted data, random bytes
|
||||
|
||||
**Why this matters:** If you peel 3 encoding layers and the output has entropy 7.9, it's almost certainly encrypted — further encoding analysis won't help. If entropy is 4.5, it's probably readable text. Entropy is the first triage check in malware analysis.
|
||||
|
||||
### 12. Integrate with CyberChef Recipes
|
||||
|
||||
CyberChef (GCHQ's open-source data analysis tool) uses "recipes" — JSON descriptions of operation chains. Build import/export compatibility.
|
||||
|
||||
**What to do:**
|
||||
- Parse CyberChef recipe JSON format
|
||||
- Map CyberChef operations to b64tool formats (e.g., "To Base64" → base64 encode)
|
||||
- Execute a recipe as a chain command
|
||||
- Export peel results as a CyberChef recipe (reverse operations)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
b64tool recipe --import recipe.json "input data"
|
||||
b64tool peel "encoded_data" --export-recipe result.json
|
||||
```
|
||||
|
||||
**Why this matters:** CyberChef is ubiquitous in CTF competitions and security operations centers. Being able to exchange recipes between tools demonstrates real interoperability. It also means your tool can leverage the thousands of existing CyberChef recipes shared by the community.
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
# ©AngelaMos | 2026
|
||||
# pyproject.toml
|
||||
|
||||
[project]
|
||||
name = "b64tool"
|
||||
version = "0.1.0"
|
||||
description = "Multi-format encoding/decoding CLI with recursive layer detection for security analysis."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "CarterPerez-dev", email = "support@certgames.com" }]
|
||||
keywords = [
|
||||
"base64",
|
||||
"encoding",
|
||||
"decoding",
|
||||
"security",
|
||||
"cli",
|
||||
"cybersecurity",
|
||||
"forensics",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Information Technology",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Security",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"typer>=0.21.1,<0.22.0",
|
||||
"rich>=14.3.2,<15.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=9.0.2,<10.0.0",
|
||||
"pytest-cov>=7.0.0,<8.0.0",
|
||||
"hypothesis>=6.130.0,<7.0.0",
|
||||
"mypy>=1.19.1,<2.0.0",
|
||||
"ruff>=0.15.0,<0.16.0",
|
||||
"pylint>=4.0.4,<5.0.0",
|
||||
"pre-commit>=4.5.1,<5.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main"
|
||||
Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/base64-tool"
|
||||
Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues"
|
||||
|
||||
[project.scripts]
|
||||
b64tool = "base64_tool.cli:app"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/base64_tool"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
line-length = 89
|
||||
src = ["src"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E",
|
||||
"W",
|
||||
"F",
|
||||
"B",
|
||||
"C4",
|
||||
"UP",
|
||||
"ARG",
|
||||
"SIM",
|
||||
"PTH",
|
||||
"RUF",
|
||||
"S",
|
||||
"N",
|
||||
]
|
||||
ignore = [
|
||||
"E501",
|
||||
"B008",
|
||||
"S101",
|
||||
"S104",
|
||||
"S105",
|
||||
"ARG001",
|
||||
"N999",
|
||||
"N818",
|
||||
"RUF005",
|
||||
]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
[tool.pylint.main]
|
||||
py-version = "3.14"
|
||||
jobs = 4
|
||||
persistent = true
|
||||
suggestion-mode = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra -q"
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
|
||||
██████╗ ██████╗ ██╗ ██╗████████╗ ██████╗ ██████╗ ██╗
|
||||
██╔══██╗██╔════╝ ██║ ██║╚══██╔══╝██╔═══██╗██╔═══██╗██║
|
||||
██████╔╝███████╗ ███████║ ██║ ██║ ██║██║ ██║██║
|
||||
██╔══██╗██╔═══██╗╚════██║ ██║ ██║ ██║██║ ██║██║
|
||||
██████╔╝╚██████╔╝ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗
|
||||
╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
|
||||
Multi-format encoder/decoder with recursive layer peeling
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "CarterPerez-dev"
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
cli.py
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
|
||||
from base64_tool import __version__
|
||||
from base64_tool.constants import (
|
||||
EncodingFormat,
|
||||
ExitCode,
|
||||
PEEL_MAX_DEPTH,
|
||||
)
|
||||
from base64_tool.detector import detect_encoding, score_all_formats
|
||||
from base64_tool.encoders import (
|
||||
decode,
|
||||
encode,
|
||||
encode_url,
|
||||
decode_url,
|
||||
)
|
||||
from base64_tool.formatter import (
|
||||
print_chain_result,
|
||||
print_decoded,
|
||||
print_detection,
|
||||
print_encoded,
|
||||
print_peel_result,
|
||||
)
|
||||
from base64_tool.peeler import peel
|
||||
from base64_tool.utils import (
|
||||
resolve_input_bytes,
|
||||
resolve_input_text,
|
||||
)
|
||||
|
||||
|
||||
app = typer.Typer(
|
||||
name="b64tool",
|
||||
help=("Multi-format encoding/decoding CLI "
|
||||
"with recursive layer detection"),
|
||||
no_args_is_help=True,
|
||||
pretty_exceptions_show_locals=False,
|
||||
)
|
||||
|
||||
_console = Console(stderr=True)
|
||||
|
||||
|
||||
def _version_callback(value: bool) -> None:
|
||||
if value:
|
||||
_console.print(f"b64tool v{__version__}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
version: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--version",
|
||||
"-v",
|
||||
help="Show version and exit.",
|
||||
callback=_version_callback,
|
||||
is_eager=True,
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@app.command(name="encode")
|
||||
def encode_cmd(
|
||||
data: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Data to encode."),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
EncodingFormat,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help="Target encoding format.",
|
||||
),
|
||||
] = EncodingFormat.BASE64,
|
||||
file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--file",
|
||||
"-i",
|
||||
help="Read input from file.",
|
||||
),
|
||||
] = None,
|
||||
form: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--form",
|
||||
help="Use form-encoding for URL (space becomes +).",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
try:
|
||||
raw = resolve_input_bytes(data, file)
|
||||
if fmt == EncodingFormat.URL and form:
|
||||
result = encode_url(raw, form=True)
|
||||
else:
|
||||
result = encode(raw, fmt)
|
||||
print_encoded(result, fmt)
|
||||
except typer.BadParameter:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(code=ExitCode.ERROR) from None
|
||||
|
||||
|
||||
@app.command(name="decode")
|
||||
def decode_cmd(
|
||||
data: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Data to decode."),
|
||||
] = None,
|
||||
fmt: Annotated[
|
||||
EncodingFormat,
|
||||
typer.Option(
|
||||
"--format",
|
||||
"-f",
|
||||
help="Source encoding format.",
|
||||
),
|
||||
] = EncodingFormat.BASE64,
|
||||
file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--file",
|
||||
"-i",
|
||||
help="Read input from file.",
|
||||
),
|
||||
] = None,
|
||||
form: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--form",
|
||||
help="Use form-decoding for URL (+ becomes space).",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
try:
|
||||
text = resolve_input_text(data, file)
|
||||
if fmt == EncodingFormat.URL and form:
|
||||
result = decode_url(text, form=True)
|
||||
else:
|
||||
result = decode(text, fmt)
|
||||
print_decoded(result)
|
||||
except typer.BadParameter:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(code=ExitCode.ERROR) from None
|
||||
|
||||
|
||||
@app.command(name="detect")
|
||||
def detect_cmd(
|
||||
data: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Data to analyze."),
|
||||
] = None,
|
||||
file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--file",
|
||||
"-i",
|
||||
help="Read input from file.",
|
||||
),
|
||||
] = None,
|
||||
verbose: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--verbose",
|
||||
"-V",
|
||||
help="Show per-format score breakdown.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
try:
|
||||
text = resolve_input_text(data, file)
|
||||
results = detect_encoding(text)
|
||||
scores = score_all_formats(text) if verbose else None
|
||||
print_detection(results, verbose_scores=scores)
|
||||
except typer.BadParameter:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(code=ExitCode.ERROR) from None
|
||||
|
||||
|
||||
@app.command(name="peel")
|
||||
def peel_cmd(
|
||||
data: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Data to recursively decode."),
|
||||
] = None,
|
||||
file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--file",
|
||||
"-i",
|
||||
help="Read input from file.",
|
||||
),
|
||||
] = None,
|
||||
max_depth: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--max-depth",
|
||||
"-d",
|
||||
help="Maximum decoding layers.",
|
||||
),
|
||||
] = PEEL_MAX_DEPTH,
|
||||
verbose: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--verbose",
|
||||
"-V",
|
||||
help="Show per-format score breakdown at each layer.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
try:
|
||||
text = resolve_input_text(data, file)
|
||||
result = peel(text, max_depth=max_depth, verbose=verbose)
|
||||
print_peel_result(result, verbose=verbose)
|
||||
except typer.BadParameter:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(code=ExitCode.ERROR) from None
|
||||
|
||||
|
||||
@app.command(name="chain")
|
||||
def chain_cmd(
|
||||
data: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Data to encode through chain."),
|
||||
] = None,
|
||||
steps: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--steps",
|
||||
"-s",
|
||||
help=("Comma-separated encoding formats "
|
||||
"(e.g. base64,hex,url)."),
|
||||
),
|
||||
] = "base64",
|
||||
file: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
"--file",
|
||||
"-i",
|
||||
help="Read input from file.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
try:
|
||||
raw = resolve_input_bytes(data, file)
|
||||
formats = _parse_chain_steps(steps)
|
||||
intermediates: list[tuple[EncodingFormat, str]] = []
|
||||
current = raw
|
||||
|
||||
for step_fmt in formats:
|
||||
encoded = encode(current, step_fmt)
|
||||
intermediates.append((step_fmt, encoded))
|
||||
current = encoded.encode("utf-8")
|
||||
|
||||
final = intermediates[-1][1] if intermediates else ""
|
||||
print_chain_result(intermediates, final)
|
||||
except typer.BadParameter:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_console.print(f"[red]Error:[/red] {exc}")
|
||||
raise typer.Exit(code=ExitCode.ERROR) from None
|
||||
|
||||
|
||||
def _parse_chain_steps(raw: str) -> list[EncodingFormat]:
|
||||
formats: list[EncodingFormat] = []
|
||||
valid_names = ", ".join(f.value for f in EncodingFormat)
|
||||
|
||||
for step in raw.split(","):
|
||||
cleaned = step.strip().lower()
|
||||
try:
|
||||
formats.append(EncodingFormat(cleaned))
|
||||
except ValueError:
|
||||
raise typer.BadParameter(
|
||||
f"Unknown format '{cleaned}'. "
|
||||
f"Valid formats: {valid_names}"
|
||||
) from None
|
||||
|
||||
if not formats:
|
||||
raise typer.BadParameter("At least one step is required.")
|
||||
|
||||
return formats
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
constants.py
|
||||
"""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class EncodingFormat(StrEnum):
|
||||
BASE64 = "base64"
|
||||
BASE64URL = "base64url"
|
||||
BASE32 = "base32"
|
||||
HEX = "hex"
|
||||
URL = "url"
|
||||
|
||||
|
||||
class ExitCode:
|
||||
SUCCESS: Final[int] = 0
|
||||
ERROR: Final[int] = 1
|
||||
INVALID_INPUT: Final[int] = 2
|
||||
|
||||
|
||||
PEEL_MAX_DEPTH: Final[int] = 20
|
||||
|
||||
MIN_INPUT_LENGTH: Final[int] = 4
|
||||
|
||||
PREVIEW_LENGTH: Final[int] = 72
|
||||
|
||||
CONFIDENCE_THRESHOLD: Final[float] = 0.6
|
||||
|
||||
PRINTABLE_RATIO_THRESHOLD: Final[float] = 0.8
|
||||
|
||||
|
||||
class ScoreWeight:
|
||||
DECODE_SUCCESS: Final[float] = 0.15
|
||||
PRINTABLE_RESULT: Final[float] = 0.15
|
||||
LONGER_INPUT: Final[float] = 0.05
|
||||
|
||||
B64_BASE: Final[float] = 0.4
|
||||
B64_VALID_PADDING: Final[float] = 0.1
|
||||
B64_SPECIAL_CHARS: Final[float] = 0.1
|
||||
B64_MIXED_CASE: Final[float] = 0.1
|
||||
B64_NO_SIGNAL_PENALTY: Final[float] = 0.2
|
||||
|
||||
B64URL_BASE: Final[float] = 0.3
|
||||
B64URL_SAFE_CHARS: Final[float] = 0.25
|
||||
|
||||
B32_BASE: Final[float] = 0.35
|
||||
B32_VALID_PADDING: Final[float] = 0.1
|
||||
B32_UPPERCASE: Final[float] = 0.1
|
||||
|
||||
HEX_BASE: Final[float] = 0.3
|
||||
HEX_SEPARATOR_PRESENT: Final[float] = 0.2
|
||||
HEX_ALPHA_CHARS: Final[float] = 0.1
|
||||
HEX_NO_ALPHA_PENALTY: Final[float] = 0.15
|
||||
HEX_CONSISTENT_CASE: Final[float] = 0.1
|
||||
HEX_DECODE_SUCCESS: Final[float] = 0.1
|
||||
|
||||
URL_BASE: Final[float] = 0.3
|
||||
URL_RATIO_MULTIPLIER: Final[float] = 0.4
|
||||
URL_RATIO_CAP: Final[float] = 0.35
|
||||
URL_DECODE_CHANGED: Final[float] = 0.15
|
||||
|
||||
BASE64_CHARSET: Final[
|
||||
frozenset[str]
|
||||
] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=")
|
||||
|
||||
BASE64URL_CHARSET: Final[
|
||||
frozenset[str]
|
||||
] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_=")
|
||||
|
||||
BASE32_CHARSET: Final[frozenset[str]] = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=")
|
||||
|
||||
HEX_CHARSET: Final[frozenset[str]] = frozenset("0123456789abcdefABCDEF")
|
||||
|
||||
HEX_SEPARATORS: Final[frozenset[str]] = frozenset(" :.-")
|
||||
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
detector.py
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from base64_tool.constants import (
|
||||
BASE32_CHARSET,
|
||||
BASE64_CHARSET,
|
||||
BASE64URL_CHARSET,
|
||||
CONFIDENCE_THRESHOLD,
|
||||
EncodingFormat,
|
||||
HEX_CHARSET,
|
||||
HEX_SEPARATORS,
|
||||
MIN_INPUT_LENGTH,
|
||||
ScoreWeight as W,
|
||||
)
|
||||
from base64_tool.encoders import try_decode
|
||||
from base64_tool.utils import is_printable_text
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DetectionResult:
|
||||
format: EncodingFormat
|
||||
confidence: float
|
||||
decoded: bytes | None
|
||||
|
||||
|
||||
def _score_base64(data: str) -> float:
|
||||
stripped = "".join(data.split())
|
||||
if len(stripped) < MIN_INPUT_LENGTH:
|
||||
return 0.0
|
||||
if not all(c in BASE64_CHARSET for c in stripped):
|
||||
return 0.0
|
||||
if len(stripped) % 4 != 0:
|
||||
return 0.0
|
||||
|
||||
score = W.B64_BASE
|
||||
content = stripped.rstrip("=")
|
||||
|
||||
padding = len(stripped) - len(content)
|
||||
if padding <= 2:
|
||||
score += W.B64_VALID_PADDING
|
||||
|
||||
if any(c in stripped for c in "+/"):
|
||||
score += W.B64_SPECIAL_CHARS
|
||||
|
||||
has_upper = any(c.isupper() for c in content)
|
||||
has_lower = any(c.islower() for c in content)
|
||||
if has_upper and has_lower:
|
||||
score += W.B64_MIXED_CASE
|
||||
elif not has_upper and not any(c in stripped for c in "+/="):
|
||||
score -= W.B64_NO_SIGNAL_PENALTY
|
||||
|
||||
if len(stripped) >= 8:
|
||||
score += W.LONGER_INPUT
|
||||
|
||||
decoded = try_decode(stripped, EncodingFormat.BASE64)
|
||||
if decoded is None:
|
||||
return 0.0
|
||||
score += W.DECODE_SUCCESS
|
||||
if is_printable_text(decoded):
|
||||
score += W.PRINTABLE_RESULT
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
def _score_base64url(data: str) -> float:
|
||||
stripped = "".join(data.split())
|
||||
if len(stripped) < MIN_INPUT_LENGTH:
|
||||
return 0.0
|
||||
if not all(c in BASE64URL_CHARSET for c in stripped):
|
||||
return 0.0
|
||||
|
||||
score = W.B64URL_BASE
|
||||
|
||||
has_url_chars = any(c in stripped for c in "-_")
|
||||
has_std_chars = any(c in stripped for c in "+/")
|
||||
|
||||
if has_url_chars and not has_std_chars:
|
||||
score += W.B64URL_SAFE_CHARS
|
||||
elif not has_url_chars:
|
||||
return 0.0
|
||||
|
||||
decoded = try_decode(stripped, EncodingFormat.BASE64URL)
|
||||
if decoded is None:
|
||||
return 0.0
|
||||
score += W.DECODE_SUCCESS
|
||||
if is_printable_text(decoded):
|
||||
score += W.PRINTABLE_RESULT
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
def _score_base32(data: str) -> float:
|
||||
stripped = "".join(data.split()).upper()
|
||||
if len(stripped) < MIN_INPUT_LENGTH:
|
||||
return 0.0
|
||||
if not all(c in BASE32_CHARSET for c in stripped):
|
||||
return 0.0
|
||||
if len(stripped) % 8 != 0:
|
||||
return 0.0
|
||||
|
||||
score = W.B32_BASE
|
||||
|
||||
valid_pad_counts = frozenset({0, 1, 3, 4, 6})
|
||||
padding = len(stripped) - len(stripped.rstrip("="))
|
||||
if padding in valid_pad_counts:
|
||||
score += W.B32_VALID_PADDING
|
||||
|
||||
if data == data.upper():
|
||||
score += W.B32_UPPERCASE
|
||||
|
||||
decoded = try_decode(stripped, EncodingFormat.BASE32)
|
||||
if decoded is None:
|
||||
return 0.0
|
||||
score += W.DECODE_SUCCESS
|
||||
if is_printable_text(decoded):
|
||||
score += W.PRINTABLE_RESULT
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
def _score_hex(data: str) -> float:
|
||||
stripped = data.strip()
|
||||
if len(stripped) < MIN_INPUT_LENGTH:
|
||||
return 0.0
|
||||
|
||||
hex_only = stripped
|
||||
for sep in HEX_SEPARATORS:
|
||||
hex_only = hex_only.replace(sep, "")
|
||||
|
||||
if not hex_only:
|
||||
return 0.0
|
||||
if not all(c in HEX_CHARSET for c in hex_only):
|
||||
return 0.0
|
||||
if len(hex_only) % 2 != 0:
|
||||
return 0.0
|
||||
|
||||
score = W.HEX_BASE
|
||||
|
||||
has_separators = any(sep in stripped for sep in HEX_SEPARATORS)
|
||||
if has_separators:
|
||||
score += W.HEX_SEPARATOR_PRESENT
|
||||
|
||||
has_alpha = any(c in "abcdefABCDEF" for c in hex_only)
|
||||
if has_alpha:
|
||||
score += W.HEX_ALPHA_CHARS
|
||||
else:
|
||||
score -= W.HEX_NO_ALPHA_PENALTY
|
||||
|
||||
is_consistent_case = (
|
||||
hex_only == hex_only.lower() or hex_only == hex_only.upper()
|
||||
)
|
||||
if is_consistent_case:
|
||||
score += W.HEX_CONSISTENT_CASE
|
||||
|
||||
if len(hex_only) >= 8:
|
||||
score += W.LONGER_INPUT
|
||||
|
||||
decoded = try_decode(stripped, EncodingFormat.HEX)
|
||||
if decoded is None:
|
||||
return 0.0
|
||||
score += W.HEX_DECODE_SUCCESS
|
||||
if is_printable_text(decoded):
|
||||
score += W.PRINTABLE_RESULT
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
_URL_PATTERN = re.compile(r"%[0-9a-fA-F]{2}")
|
||||
|
||||
|
||||
def _score_url(data: str) -> float:
|
||||
if len(data) < MIN_INPUT_LENGTH:
|
||||
return 0.0
|
||||
|
||||
matches = _URL_PATTERN.findall(data)
|
||||
if not matches:
|
||||
return 0.0
|
||||
|
||||
encoded_char_count = len(matches) * 3
|
||||
ratio = encoded_char_count / len(data)
|
||||
score = W.URL_BASE + min(ratio * W.URL_RATIO_MULTIPLIER, W.URL_RATIO_CAP)
|
||||
|
||||
decoded = try_decode(data, EncodingFormat.URL)
|
||||
if decoded is not None:
|
||||
decoded_text = decoded.decode("utf-8", errors="replace")
|
||||
if decoded_text != data:
|
||||
score += W.URL_DECODE_CHANGED
|
||||
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
_SCORERS: dict[EncodingFormat, Callable[[str], float]] = {
|
||||
EncodingFormat.BASE64: _score_base64,
|
||||
EncodingFormat.BASE64URL: _score_base64url,
|
||||
EncodingFormat.BASE32: _score_base32,
|
||||
EncodingFormat.HEX: _score_hex,
|
||||
EncodingFormat.URL: _score_url,
|
||||
}
|
||||
|
||||
|
||||
def score_all_formats(data: str) -> dict[EncodingFormat, float]:
|
||||
return {fmt: scorer(data) for fmt, scorer in _SCORERS.items()}
|
||||
|
||||
|
||||
def detect_encoding(data: str) -> list[DetectionResult]:
|
||||
results: list[DetectionResult] = []
|
||||
|
||||
for fmt, confidence in score_all_formats(data).items():
|
||||
if confidence >= CONFIDENCE_THRESHOLD:
|
||||
decoded = try_decode(data, fmt)
|
||||
results.append(
|
||||
DetectionResult(
|
||||
format=fmt,
|
||||
confidence=round(confidence, 2),
|
||||
decoded=decoded,
|
||||
)
|
||||
)
|
||||
|
||||
results.sort(key=lambda r: r.confidence, reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
def detect_best(data: str) -> DetectionResult | None:
|
||||
results = detect_encoding(data)
|
||||
return results[0] if results else None
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
encoders.py
|
||||
"""
|
||||
|
||||
import base64 as b64
|
||||
import binascii
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import (
|
||||
quote,
|
||||
quote_plus,
|
||||
unquote,
|
||||
unquote_plus,
|
||||
)
|
||||
from base64_tool.constants import EncodingFormat
|
||||
|
||||
|
||||
type EncoderFn = Callable[[bytes], str]
|
||||
type DecoderFn = Callable[[str], bytes]
|
||||
|
||||
|
||||
def encode_base64(data: bytes) -> str:
|
||||
return b64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def decode_base64(data: str) -> bytes:
|
||||
cleaned = "".join(data.split())
|
||||
return b64.b64decode(cleaned, validate=True)
|
||||
|
||||
|
||||
def encode_base64url(data: bytes) -> str:
|
||||
return b64.urlsafe_b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def decode_base64url(data: str) -> bytes:
|
||||
cleaned = "".join(data.split())
|
||||
return b64.urlsafe_b64decode(cleaned)
|
||||
|
||||
|
||||
def encode_base32(data: bytes) -> str:
|
||||
return b64.b32encode(data).decode("ascii")
|
||||
|
||||
|
||||
def decode_base32(data: str) -> bytes:
|
||||
cleaned = "".join(data.split()).upper()
|
||||
return b64.b32decode(cleaned)
|
||||
|
||||
|
||||
def encode_hex(data: bytes) -> str:
|
||||
return data.hex()
|
||||
|
||||
|
||||
def decode_hex(data: str) -> bytes:
|
||||
cleaned = data.strip()
|
||||
for sep in (" ", ":", "-", "."):
|
||||
cleaned = cleaned.replace(sep, "")
|
||||
return bytes.fromhex(cleaned)
|
||||
|
||||
|
||||
def encode_url(data: bytes, *, form: bool = False) -> str:
|
||||
text = data.decode("utf-8")
|
||||
if form:
|
||||
return quote_plus(text)
|
||||
return quote(text, safe="")
|
||||
|
||||
|
||||
def decode_url(data: str, *, form: bool = False) -> bytes:
|
||||
if form:
|
||||
return unquote_plus(data).encode("utf-8")
|
||||
return unquote(data).encode("utf-8")
|
||||
|
||||
|
||||
ENCODER_REGISTRY: dict[
|
||||
EncodingFormat,
|
||||
tuple[EncoderFn, DecoderFn],
|
||||
] = {
|
||||
EncodingFormat.BASE64: (encode_base64, decode_base64),
|
||||
EncodingFormat.BASE64URL: (encode_base64url, decode_base64url),
|
||||
EncodingFormat.BASE32: (encode_base32, decode_base32),
|
||||
EncodingFormat.HEX: (encode_hex, decode_hex),
|
||||
EncodingFormat.URL: (
|
||||
lambda data: encode_url(data),
|
||||
lambda data: decode_url(data),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def encode(data: bytes, fmt: EncodingFormat) -> str:
|
||||
encoder_fn, _ = ENCODER_REGISTRY[fmt]
|
||||
return encoder_fn(data)
|
||||
|
||||
|
||||
def decode(data: str, fmt: EncodingFormat) -> bytes:
|
||||
_, decoder_fn = ENCODER_REGISTRY[fmt]
|
||||
return decoder_fn(data)
|
||||
|
||||
|
||||
def try_decode(data: str, fmt: EncodingFormat) -> bytes | None:
|
||||
try:
|
||||
return decode(data, fmt)
|
||||
except (
|
||||
ValueError,
|
||||
binascii.Error,
|
||||
UnicodeDecodeError,
|
||||
UnicodeEncodeError,
|
||||
):
|
||||
return None
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
formatter.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from base64_tool.constants import (
|
||||
CONFIDENCE_THRESHOLD,
|
||||
EncodingFormat,
|
||||
PREVIEW_LENGTH,
|
||||
)
|
||||
from base64_tool.detector import DetectionResult
|
||||
from base64_tool.peeler import PeelResult
|
||||
from base64_tool.utils import safe_bytes_preview
|
||||
|
||||
|
||||
console = Console(stderr=True)
|
||||
|
||||
|
||||
def is_piped() -> bool:
|
||||
return not sys.stdout.isatty()
|
||||
|
||||
|
||||
def write_raw(text: str) -> None:
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def print_encoded(result: str, fmt: EncodingFormat) -> None:
|
||||
if is_piped():
|
||||
write_raw(result)
|
||||
return
|
||||
panel = Panel(
|
||||
Text(result, style="green"),
|
||||
title=f"[bold cyan]{fmt.value}[/bold cyan] encoded",
|
||||
border_style="cyan",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
def print_decoded(result: bytes) -> None:
|
||||
preview = safe_bytes_preview(result, length=4096)
|
||||
if is_piped():
|
||||
write_raw(preview)
|
||||
return
|
||||
panel = Panel(
|
||||
Text(preview, style="green"),
|
||||
title="[bold cyan]Decoded[/bold cyan]",
|
||||
border_style="cyan",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
def print_score_breakdown(
|
||||
scores: dict[EncodingFormat, float],
|
||||
) -> None:
|
||||
table = Table(
|
||||
title="Score Breakdown",
|
||||
show_header=True,
|
||||
header_style="bold magenta",
|
||||
)
|
||||
table.add_column("Format", style="cyan", min_width=10)
|
||||
table.add_column(
|
||||
"Score",
|
||||
justify="right",
|
||||
min_width=8,
|
||||
)
|
||||
table.add_column("Status", min_width=10)
|
||||
|
||||
sorted_scores = sorted(
|
||||
scores.items(), key=lambda x: x[1], reverse=True,
|
||||
)
|
||||
for fmt, score in sorted_scores:
|
||||
color = _confidence_color(score)
|
||||
if score >= CONFIDENCE_THRESHOLD:
|
||||
status = "[green]detected[/green]"
|
||||
elif score > 0:
|
||||
status = "[yellow]below threshold[/yellow]"
|
||||
else:
|
||||
status = "[dim]no match[/dim]"
|
||||
table.add_row(
|
||||
fmt.value,
|
||||
f"[{color}]{score:.0%}[/{color}]",
|
||||
status,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_detection(
|
||||
results: list[DetectionResult],
|
||||
*,
|
||||
verbose_scores: dict[EncodingFormat, float] | None = None,
|
||||
) -> None:
|
||||
if verbose_scores is not None:
|
||||
print_score_breakdown(verbose_scores)
|
||||
console.print()
|
||||
|
||||
if not results:
|
||||
console.print("[yellow]No encoding format detected.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(
|
||||
title="Detection Results",
|
||||
show_header=True,
|
||||
header_style="bold magenta",
|
||||
)
|
||||
table.add_column("Format", style="cyan", min_width=10)
|
||||
table.add_column(
|
||||
"Confidence",
|
||||
justify="right",
|
||||
style="green",
|
||||
min_width=12,
|
||||
)
|
||||
table.add_column("Decoded Preview", style="dim")
|
||||
|
||||
for result in results:
|
||||
confidence_str = f"{result.confidence:.0%}"
|
||||
preview = ""
|
||||
if result.decoded is not None:
|
||||
preview = safe_bytes_preview(
|
||||
result.decoded,
|
||||
PREVIEW_LENGTH,
|
||||
)
|
||||
table.add_row(
|
||||
result.format.value,
|
||||
confidence_str,
|
||||
preview,
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_peel_result(
|
||||
result: PeelResult,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
if not result.success:
|
||||
console.print("[yellow]No encoding layers detected.[/yellow]")
|
||||
return
|
||||
|
||||
layer_count = len(result.layers)
|
||||
suffix = "s" if layer_count > 1 else ""
|
||||
console.print()
|
||||
console.print(
|
||||
f"[bold cyan]Peeled {layer_count} encoding "
|
||||
f"layer{suffix}[/bold cyan]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
for layer in result.layers:
|
||||
color = _confidence_color(layer.confidence)
|
||||
console.print(
|
||||
f" [bold]Layer {layer.depth}[/bold] "
|
||||
f"[cyan]{layer.format.value}[/cyan] "
|
||||
f"[{color}]{layer.confidence:.0%}[/{color}]"
|
||||
)
|
||||
console.print(f" [dim]{layer.decoded_preview}[/dim]")
|
||||
|
||||
if verbose and layer.all_scores:
|
||||
console.print()
|
||||
print_score_breakdown(dict(layer.all_scores))
|
||||
console.print()
|
||||
|
||||
console.print()
|
||||
|
||||
preview = safe_bytes_preview(result.final_output, length=4096)
|
||||
panel = Panel(
|
||||
Text(preview, style="bold green"),
|
||||
title="[bold]Final Output[/bold]",
|
||||
border_style="green",
|
||||
subtitle=(f"[dim]{layer_count} layer{suffix} peeled[/dim]"),
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
def print_chain_result(
|
||||
steps: list[tuple[EncodingFormat, str]],
|
||||
final: str,
|
||||
) -> None:
|
||||
if is_piped():
|
||||
write_raw(final)
|
||||
return
|
||||
|
||||
console.print()
|
||||
console.print("[bold cyan]Encoding Chain[/bold cyan]")
|
||||
console.print()
|
||||
|
||||
for i, (fmt, intermediate) in enumerate(steps):
|
||||
marker = "start" if i == 0 else "step"
|
||||
arrow = f" [{marker}] " if i == 0 else " -> "
|
||||
truncated = intermediate[:PREVIEW_LENGTH]
|
||||
ellipsis = "..." if len(intermediate) > PREVIEW_LENGTH else ""
|
||||
console.print(
|
||||
f"{arrow}[cyan]{fmt.value}[/cyan] "
|
||||
f"[dim]{truncated}{ellipsis}[/dim]"
|
||||
)
|
||||
|
||||
console.print()
|
||||
panel = Panel(
|
||||
Text(final, style="green"),
|
||||
title="[bold]Chain Result[/bold]",
|
||||
border_style="cyan",
|
||||
subtitle=f"[dim]{len(steps)} steps[/dim]",
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
|
||||
def _confidence_color(confidence: float) -> str:
|
||||
if confidence >= 0.9:
|
||||
return "green"
|
||||
if confidence >= 0.7:
|
||||
return "yellow"
|
||||
return "red"
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
peeler.py
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from base64_tool.constants import (
|
||||
CONFIDENCE_THRESHOLD,
|
||||
PEEL_MAX_DEPTH,
|
||||
EncodingFormat,
|
||||
)
|
||||
from base64_tool.detector import detect_best, score_all_formats
|
||||
from base64_tool.utils import safe_bytes_preview, truncate
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeelLayer:
|
||||
depth: int
|
||||
format: EncodingFormat
|
||||
confidence: float
|
||||
encoded_preview: str
|
||||
decoded_preview: str
|
||||
all_scores: tuple[tuple[EncodingFormat, float], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeelResult:
|
||||
layers: tuple[PeelLayer, ...]
|
||||
final_output: bytes
|
||||
success: bool
|
||||
|
||||
|
||||
def peel(
|
||||
data: str,
|
||||
*,
|
||||
max_depth: int = PEEL_MAX_DEPTH,
|
||||
threshold: float = CONFIDENCE_THRESHOLD,
|
||||
verbose: bool = False,
|
||||
) -> PeelResult:
|
||||
layers: list[PeelLayer] = []
|
||||
current_text = data
|
||||
current_bytes = data.encode("utf-8")
|
||||
|
||||
for depth in range(max_depth):
|
||||
detection = detect_best(current_text)
|
||||
|
||||
if detection is None:
|
||||
break
|
||||
if detection.confidence < threshold:
|
||||
break
|
||||
if detection.decoded is None:
|
||||
break
|
||||
|
||||
scores = (
|
||||
tuple(score_all_formats(current_text).items())
|
||||
if verbose
|
||||
else ()
|
||||
)
|
||||
|
||||
decoded_bytes = detection.decoded
|
||||
layer = PeelLayer(
|
||||
depth=depth + 1,
|
||||
format=detection.format,
|
||||
confidence=detection.confidence,
|
||||
encoded_preview=truncate(current_text),
|
||||
decoded_preview=safe_bytes_preview(decoded_bytes),
|
||||
all_scores=scores,
|
||||
)
|
||||
layers.append(layer)
|
||||
current_bytes = decoded_bytes
|
||||
|
||||
try:
|
||||
current_text = decoded_bytes.decode("utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
break
|
||||
|
||||
return PeelResult(
|
||||
layers=tuple(layers),
|
||||
final_output=current_bytes,
|
||||
success=len(layers) > 0,
|
||||
)
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
utils.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
|
||||
def resolve_input_bytes(
|
||||
data: str | None,
|
||||
file: Path | None,
|
||||
) -> bytes:
|
||||
if file is not None:
|
||||
if not file.exists():
|
||||
raise typer.BadParameter(f"File not found: {file}")
|
||||
return file.read_bytes()
|
||||
if data is not None:
|
||||
return data.encode("utf-8")
|
||||
if not sys.stdin.isatty():
|
||||
return sys.stdin.buffer.read()
|
||||
raise typer.BadParameter(
|
||||
"No input provided. Pass an argument, use --file, or pipe stdin."
|
||||
)
|
||||
|
||||
|
||||
def resolve_input_text(
|
||||
data: str | None,
|
||||
file: Path | None,
|
||||
) -> str:
|
||||
if file is not None:
|
||||
if not file.exists():
|
||||
raise typer.BadParameter(f"File not found: {file}")
|
||||
return file.read_text("utf-8").strip()
|
||||
if data is not None:
|
||||
return data.strip()
|
||||
if not sys.stdin.isatty():
|
||||
return sys.stdin.read().strip()
|
||||
raise typer.BadParameter(
|
||||
"No input provided. Pass an argument, use --file, or pipe stdin."
|
||||
)
|
||||
|
||||
|
||||
def truncate(text: str, length: int = 72) -> str:
|
||||
if len(text) <= length:
|
||||
return text
|
||||
return text[: length] + "..."
|
||||
|
||||
|
||||
def safe_bytes_preview(data: bytes, length: int = 72) -> str:
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
return truncate(text, length)
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return truncate(data.hex(), length)
|
||||
|
||||
|
||||
def is_printable_text(data: bytes, threshold: float = 0.8) -> bool:
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if not text:
|
||||
return False
|
||||
printable_count = sum(1 for c in text if c.isprintable() or c in "\n\r\t")
|
||||
return (printable_count / len(text)) >= threshold
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
__init__.py
|
||||
"""
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_cli.py
|
||||
"""
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from base64_tool.cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestEncodeCommand:
|
||||
def test_encode_base64(self) -> None:
|
||||
result = runner.invoke(app, ["encode", "Hello World"])
|
||||
assert result.exit_code == 0
|
||||
assert "SGVsbG8gV29ybGQ=" in result.output
|
||||
|
||||
def test_encode_hex(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["encode",
|
||||
"Hello",
|
||||
"--format",
|
||||
"hex"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "48656c6c6f" in result.output
|
||||
|
||||
def test_encode_base32(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["encode",
|
||||
"Hello",
|
||||
"--format",
|
||||
"base32"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "JBSWY3DP" in result.output
|
||||
|
||||
def test_encode_url(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["encode",
|
||||
"hello world&test",
|
||||
"--format",
|
||||
"url"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "%20" in result.output or "hello" in result.output
|
||||
|
||||
def test_encode_empty_string(self) -> None:
|
||||
result = runner.invoke(app, ["encode", ""])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestDecodeCommand:
|
||||
def test_decode_base64(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["decode",
|
||||
"SGVsbG8gV29ybGQ="],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Hello World" in result.output
|
||||
|
||||
def test_decode_hex(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["decode",
|
||||
"48656c6c6f",
|
||||
"--format",
|
||||
"hex"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Hello" in result.output
|
||||
|
||||
def test_decode_invalid_base64(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["decode",
|
||||
"!!!invalid!!!"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestDetectCommand:
|
||||
def test_detect_base64(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["detect",
|
||||
"SGVsbG8gV29ybGQ="],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "base64" in result.output.lower()
|
||||
|
||||
def test_detect_hex(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["detect",
|
||||
"48656c6c6f20576f726c64"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "hex" in result.output.lower()
|
||||
|
||||
def test_detect_no_match(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["detect",
|
||||
"just plain text"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "no encoding" in result.output.lower()
|
||||
|
||||
|
||||
class TestPeelCommand:
|
||||
def test_peel_single_layer(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["peel",
|
||||
"SGVsbG8gV29ybGQ="],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "layer" in result.output.lower()
|
||||
|
||||
def test_peel_no_encoding(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["peel",
|
||||
"hello world"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestChainCommand:
|
||||
def test_chain_single_step(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chain",
|
||||
"Hello",
|
||||
"--steps",
|
||||
"base64"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "SGVsbG8=" in result.output
|
||||
|
||||
def test_chain_multiple_steps(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chain",
|
||||
"Hi",
|
||||
"--steps",
|
||||
"base64,hex"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_chain_invalid_format(self) -> None:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chain",
|
||||
"test",
|
||||
"--steps",
|
||||
"fake"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestVersionFlag:
|
||||
def test_version_output(self) -> None:
|
||||
result = runner.invoke(app, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "b64tool" in result.output
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_detector.py
|
||||
"""
|
||||
|
||||
from base64_tool.constants import EncodingFormat
|
||||
from base64_tool.detector import detect_best, detect_encoding
|
||||
|
||||
|
||||
class TestDetectBase64:
|
||||
def test_standard_base64(self) -> None:
|
||||
result = detect_best("SGVsbG8gV29ybGQ=")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE64
|
||||
assert result.confidence >= 0.7
|
||||
|
||||
def test_base64_no_padding(self) -> None:
|
||||
result = detect_best("SGVsbG8gV29ybGQh")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE64
|
||||
|
||||
def test_base64_with_plus_slash(self) -> None:
|
||||
result = detect_best("dGVzdC9wYXRoK3F1ZXJ5")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE64
|
||||
|
||||
|
||||
class TestDetectBase64Url:
|
||||
def test_url_safe_chars(self) -> None:
|
||||
result = detect_best("dGVzdC1kYXRhX3ZhbHVl")
|
||||
assert result is not None
|
||||
assert result.format in (
|
||||
EncodingFormat.BASE64URL,
|
||||
EncodingFormat.BASE64,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectBase32:
|
||||
def test_standard_base32(self) -> None:
|
||||
result = detect_best("JBSWY3DPEBLW64TMMQ======")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE32
|
||||
assert result.confidence >= 0.6
|
||||
|
||||
def test_base32_uppercase(self) -> None:
|
||||
result = detect_best("JBSWY3DP")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.BASE32
|
||||
|
||||
|
||||
class TestDetectHex:
|
||||
def test_hex_with_letters(self) -> None:
|
||||
result = detect_best("48656c6c6f20576f726c64")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.HEX
|
||||
assert result.confidence >= 0.6
|
||||
|
||||
def test_hex_with_colons(self) -> None:
|
||||
result = detect_best("48:65:6c:6c:6f:20:57:6f:72:6c:64")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.HEX
|
||||
|
||||
def test_pure_digits_not_detected(self) -> None:
|
||||
result = detect_best("1234567890")
|
||||
if result is not None and result.format == EncodingFormat.HEX:
|
||||
assert result.confidence < 0.7
|
||||
|
||||
|
||||
class TestDetectUrl:
|
||||
def test_url_encoded(self) -> None:
|
||||
result = detect_best("hello%20world%21%40%23")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.URL
|
||||
|
||||
def test_heavily_encoded(self) -> None:
|
||||
result = detect_best("%48%65%6C%6C%6F%20%57%6F%72%6C%64")
|
||||
assert result is not None
|
||||
assert result.format == EncodingFormat.URL
|
||||
assert result.confidence >= 0.6
|
||||
|
||||
|
||||
class TestDetectMultiple:
|
||||
def test_returns_sorted_by_confidence(self) -> None:
|
||||
results = detect_encoding("SGVsbG8gV29ybGQ=")
|
||||
if len(results) > 1:
|
||||
confidences = [r.confidence for r in results]
|
||||
assert confidences == sorted(confidences, reverse = True)
|
||||
|
||||
def test_no_match_returns_empty(self) -> None:
|
||||
results = detect_encoding("hello world")
|
||||
assert results == []
|
||||
|
||||
def test_short_string_returns_empty(self) -> None:
|
||||
results = detect_encoding("ab")
|
||||
assert results == []
|
||||
|
||||
|
||||
class TestDetectBest:
|
||||
def test_returns_highest_confidence(self) -> None:
|
||||
result = detect_best("SGVsbG8gV29ybGQ=")
|
||||
assert result is not None
|
||||
all_results = detect_encoding("SGVsbG8gV29ybGQ=")
|
||||
if all_results:
|
||||
assert result.confidence == all_results[0].confidence
|
||||
|
||||
def test_no_match_returns_none(self) -> None:
|
||||
assert detect_best("not encoded at all!") is None
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_encoders.py
|
||||
"""
|
||||
|
||||
import binascii
|
||||
|
||||
import pytest
|
||||
|
||||
from base64_tool.constants import EncodingFormat
|
||||
from base64_tool.encoders import (
|
||||
decode,
|
||||
decode_base32,
|
||||
decode_base64,
|
||||
decode_base64url,
|
||||
decode_hex,
|
||||
decode_url,
|
||||
encode,
|
||||
encode_base32,
|
||||
encode_base64,
|
||||
encode_base64url,
|
||||
encode_hex,
|
||||
encode_url,
|
||||
try_decode,
|
||||
)
|
||||
|
||||
|
||||
class TestBase64:
|
||||
def test_encode_simple_text(self) -> None:
|
||||
assert encode_base64(b"Hello World") == "SGVsbG8gV29ybGQ="
|
||||
|
||||
def test_decode_simple_text(self) -> None:
|
||||
assert decode_base64("SGVsbG8gV29ybGQ=") == b"Hello World"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = b"The quick brown fox jumps over the lazy dog"
|
||||
assert decode_base64(encode_base64(original)) == original
|
||||
|
||||
def test_encode_empty(self) -> None:
|
||||
assert encode_base64(b"") == ""
|
||||
|
||||
def test_decode_empty(self) -> None:
|
||||
assert decode_base64("") == b""
|
||||
|
||||
def test_encode_binary_data(self) -> None:
|
||||
data = bytes(range(256))
|
||||
assert decode_base64(encode_base64(data)) == data
|
||||
|
||||
def test_decode_with_whitespace(self) -> None:
|
||||
encoded = "SGVs\nbG8g\nV29y\nbGQ="
|
||||
assert decode_base64(encoded) == b"Hello World"
|
||||
|
||||
def test_decode_invalid_raises(self) -> None:
|
||||
with pytest.raises((ValueError, binascii.Error)):
|
||||
decode_base64("!!!invalid!!!")
|
||||
|
||||
def test_encode_unicode(self) -> None:
|
||||
data = "Hello 世界".encode()
|
||||
decoded = decode_base64(encode_base64(data))
|
||||
assert decoded == data
|
||||
|
||||
|
||||
class TestBase64Url:
|
||||
def test_encode_with_url_chars(self) -> None:
|
||||
data = b"\xfb\xff\xfe"
|
||||
encoded = encode_base64url(data)
|
||||
assert "+" not in encoded
|
||||
assert "/" not in encoded
|
||||
|
||||
def test_decode_url_safe(self) -> None:
|
||||
result = decode_base64url(encode_base64url(b"test/path+query"))
|
||||
assert result == b"test/path+query"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = b"https://example.com?token=abc+def/ghi"
|
||||
assert decode_base64url(encode_base64url(original)) == original
|
||||
|
||||
|
||||
class TestBase32:
|
||||
def test_encode_simple(self) -> None:
|
||||
assert encode_base32(b"Hello") == "JBSWY3DP"
|
||||
|
||||
def test_decode_simple(self) -> None:
|
||||
assert decode_base32("JBSWY3DP") == b"Hello"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = b"Base32 encoding test"
|
||||
assert decode_base32(encode_base32(original)) == original
|
||||
|
||||
def test_decode_lowercase_accepted(self) -> None:
|
||||
assert decode_base32("jbswy3dp") == b"Hello"
|
||||
|
||||
def test_decode_with_padding(self) -> None:
|
||||
assert decode_base32("JBSWY3DPEBLW64TMMQ======") == b"Hello World"
|
||||
|
||||
|
||||
class TestHex:
|
||||
def test_encode_simple(self) -> None:
|
||||
assert encode_hex(b"\xca\xfe") == "cafe"
|
||||
|
||||
def test_decode_simple(self) -> None:
|
||||
assert decode_hex("cafe") == b"\xca\xfe"
|
||||
|
||||
def test_decode_with_colons(self) -> None:
|
||||
assert decode_hex("ca:fe:ba:be") == b"\xca\xfe\xba\xbe"
|
||||
|
||||
def test_decode_with_spaces(self) -> None:
|
||||
assert decode_hex("ca fe ba be") == b"\xca\xfe\xba\xbe"
|
||||
|
||||
def test_decode_with_dashes(self) -> None:
|
||||
assert decode_hex("ca-fe-ba-be") == b"\xca\xfe\xba\xbe"
|
||||
|
||||
def test_decode_uppercase(self) -> None:
|
||||
assert decode_hex("CAFE") == b"\xca\xfe"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = b"Hello World"
|
||||
assert decode_hex(encode_hex(original)) == original
|
||||
|
||||
|
||||
class TestUrl:
|
||||
def test_encode_special_chars(self) -> None:
|
||||
result = encode_url(b"hello world&foo=bar")
|
||||
assert " " not in result
|
||||
assert "&" not in result
|
||||
|
||||
def test_decode_percent_encoded(self) -> None:
|
||||
assert decode_url("hello%20world") == b"hello world"
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = b"path/to/file?key=value&other=test"
|
||||
assert decode_url(encode_url(original)) == original
|
||||
|
||||
def test_form_encode_space_as_plus(self) -> None:
|
||||
result = encode_url(b"hello world", form = True)
|
||||
assert "+" in result
|
||||
assert "%20" not in result
|
||||
|
||||
def test_form_decode_plus_as_space(self) -> None:
|
||||
assert decode_url("hello+world", form = True) == b"hello world"
|
||||
|
||||
|
||||
class TestRegistryDispatch:
|
||||
@pytest.mark.parametrize("fmt", list(EncodingFormat))
|
||||
def test_encode_decode_roundtrip(
|
||||
self,
|
||||
fmt: EncodingFormat,
|
||||
) -> None:
|
||||
original = b"roundtrip test data"
|
||||
encoded = encode(original, fmt)
|
||||
decoded = decode(encoded, fmt)
|
||||
assert decoded == original
|
||||
|
||||
def test_try_decode_valid(self) -> None:
|
||||
result = try_decode("SGVsbG8=", EncodingFormat.BASE64)
|
||||
assert result == b"Hello"
|
||||
|
||||
def test_try_decode_invalid_returns_none(self) -> None:
|
||||
result = try_decode("!!!bad!!!", EncodingFormat.BASE64)
|
||||
assert result is None
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_peeler.py
|
||||
"""
|
||||
|
||||
from base64_tool.constants import EncodingFormat
|
||||
from base64_tool.encoders import encode
|
||||
from base64_tool.peeler import peel
|
||||
|
||||
|
||||
class TestSingleLayer:
|
||||
def test_peel_base64(self) -> None:
|
||||
encoded = encode(b"Hello World", EncodingFormat.BASE64)
|
||||
result = peel(encoded)
|
||||
assert result.success is True
|
||||
assert len(result.layers) == 1
|
||||
assert result.layers[0].format == EncodingFormat.BASE64
|
||||
assert result.final_output == b"Hello World"
|
||||
|
||||
def test_peel_hex(self) -> None:
|
||||
encoded = encode(b"Hello World", EncodingFormat.HEX)
|
||||
result = peel(encoded)
|
||||
assert result.success is True
|
||||
assert len(result.layers) >= 1
|
||||
assert result.final_output == b"Hello World"
|
||||
|
||||
def test_peel_base32(self) -> None:
|
||||
encoded = encode(b"Hello World", EncodingFormat.BASE32)
|
||||
result = peel(encoded)
|
||||
assert result.success is True
|
||||
assert len(result.layers) >= 1
|
||||
|
||||
|
||||
class TestMultiLayer:
|
||||
def test_base64_then_hex(self) -> None:
|
||||
step1 = encode(b"secret payload", EncodingFormat.BASE64)
|
||||
step2 = encode(step1.encode("utf-8"), EncodingFormat.HEX)
|
||||
result = peel(step2)
|
||||
assert result.success is True
|
||||
assert len(result.layers) >= 2
|
||||
assert b"secret payload" in result.final_output
|
||||
|
||||
def test_base64_double_encoded(self) -> None:
|
||||
step1 = encode(b"double layer", EncodingFormat.BASE64)
|
||||
step2 = encode(step1.encode("utf-8"), EncodingFormat.BASE64)
|
||||
result = peel(step2)
|
||||
assert result.success is True
|
||||
assert len(result.layers) >= 2
|
||||
|
||||
|
||||
class TestPeelEdgeCases:
|
||||
def test_plaintext_no_layers(self) -> None:
|
||||
result = peel("just plain text")
|
||||
assert result.success is False
|
||||
assert len(result.layers) == 0
|
||||
|
||||
def test_max_depth_respected(self) -> None:
|
||||
encoded = encode(b"data", EncodingFormat.BASE64)
|
||||
result = peel(encoded, max_depth = 0)
|
||||
assert len(result.layers) == 0
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
result = peel("")
|
||||
assert result.success is False
|
||||
|
||||
def test_layer_metadata_populated(self) -> None:
|
||||
encoded = encode(b"test data", EncodingFormat.BASE64)
|
||||
result = peel(encoded)
|
||||
if result.success and result.layers:
|
||||
layer = result.layers[0]
|
||||
assert layer.depth == 1
|
||||
assert layer.confidence > 0
|
||||
assert len(layer.encoded_preview) > 0
|
||||
assert len(layer.decoded_preview) > 0
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
"""
|
||||
©AngelaMos | 2026
|
||||
test_properties.py
|
||||
"""
|
||||
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
from base64_tool.constants import EncodingFormat
|
||||
from base64_tool.encoders import (
|
||||
decode,
|
||||
decode_base32,
|
||||
decode_base64,
|
||||
decode_base64url,
|
||||
decode_hex,
|
||||
decode_url,
|
||||
encode,
|
||||
encode_base32,
|
||||
encode_base64,
|
||||
encode_base64url,
|
||||
encode_hex,
|
||||
encode_url,
|
||||
)
|
||||
|
||||
|
||||
class TestBase64Properties:
|
||||
@given(st.binary())
|
||||
def test_roundtrip(self, data: bytes) -> None:
|
||||
assert decode_base64(encode_base64(data)) == data
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_encoded_is_ascii(self, data: bytes) -> None:
|
||||
encoded = encode_base64(data)
|
||||
encoded.encode("ascii")
|
||||
|
||||
@given(st.binary())
|
||||
def test_encoded_length_is_multiple_of_4(self, data: bytes) -> None:
|
||||
encoded = encode_base64(data)
|
||||
if encoded:
|
||||
assert len(encoded) % 4 == 0
|
||||
|
||||
|
||||
class TestBase64UrlProperties:
|
||||
@given(st.binary())
|
||||
def test_roundtrip(self, data: bytes) -> None:
|
||||
assert decode_base64url(encode_base64url(data)) == data
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_no_standard_base64_chars(self, data: bytes) -> None:
|
||||
encoded = encode_base64url(data)
|
||||
assert "+" not in encoded
|
||||
assert "/" not in encoded
|
||||
|
||||
|
||||
class TestBase32Properties:
|
||||
@given(st.binary())
|
||||
def test_roundtrip(self, data: bytes) -> None:
|
||||
assert decode_base32(encode_base32(data)) == data
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_encoded_is_uppercase(self, data: bytes) -> None:
|
||||
encoded = encode_base32(data)
|
||||
assert encoded == encoded.upper()
|
||||
|
||||
@given(st.binary())
|
||||
def test_encoded_length_is_multiple_of_8(self, data: bytes) -> None:
|
||||
encoded = encode_base32(data)
|
||||
if encoded:
|
||||
assert len(encoded) % 8 == 0
|
||||
|
||||
|
||||
class TestHexProperties:
|
||||
@given(st.binary())
|
||||
def test_roundtrip(self, data: bytes) -> None:
|
||||
assert decode_hex(encode_hex(data)) == data
|
||||
|
||||
@given(st.binary(min_size=1))
|
||||
def test_encoded_length_is_double(self, data: bytes) -> None:
|
||||
assert len(encode_hex(data)) == len(data) * 2
|
||||
|
||||
@given(st.binary())
|
||||
def test_encoded_is_hex_chars_only(self, data: bytes) -> None:
|
||||
encoded = encode_hex(data)
|
||||
assert all(c in "0123456789abcdef" for c in encoded)
|
||||
|
||||
|
||||
class TestUrlProperties:
|
||||
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
||||
@settings(max_examples=200)
|
||||
def test_roundtrip(self, text: str) -> None:
|
||||
data = text.encode("utf-8")
|
||||
assert decode_url(encode_url(data)) == data
|
||||
|
||||
@given(st.text(alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "S", "Z"))))
|
||||
@settings(max_examples=200)
|
||||
def test_form_roundtrip(self, text: str) -> None:
|
||||
data = text.encode("utf-8")
|
||||
assert decode_url(encode_url(data, form=True), form=True) == data
|
||||
|
||||
|
||||
class TestCrossFormatProperties:
|
||||
@given(st.binary(min_size=1, max_size=256))
|
||||
def test_all_formats_roundtrip(self, data: bytes) -> None:
|
||||
for fmt in (
|
||||
EncodingFormat.BASE64,
|
||||
EncodingFormat.BASE64URL,
|
||||
EncodingFormat.BASE32,
|
||||
EncodingFormat.HEX,
|
||||
):
|
||||
assert decode(encode(data, fmt), fmt) == data
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "astroid"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "b64tool"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "hypothesis" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pylint" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.130.0,<7.0.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1,<2.0.0" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1,<5.0.0" },
|
||||
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4,<5.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2,<10.0.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0,<8.0.0" },
|
||||
{ name = "rich", specifier = ">=14.3.2,<15.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0,<0.16.0" },
|
||||
{ name = "typer", specifier = ">=0.21.1,<0.22.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.20.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hypothesis"
|
||||
version = "6.151.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/d7/c40dcd401cc360d8d084e584ffb7ab17255fde22e2b9cf2b53bf25aed629/hypothesis-6.151.5.tar.gz", hash = "sha256:ae3a0622f9693e6b19c697777c2c266c02801f9769ab7c2c37b7ec83d4743783", size = 475923, upload-time = "2026-02-03T19:33:55.845Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d9/53a8b53e75279a953fae608bd01025d9afcf393406c0da1dda1b7f5693c5/hypothesis-6.151.5-py3-none-any.whl", hash = "sha256:c0e15c91fa0e67bc0295551ef5041bebad42753b7977a610cd7a6ec1ad04ef13", size = 543338, upload-time = "2026-02-03T19:33:54.583Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.7.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylint"
|
||||
version = "4.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "astroid" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "dill" },
|
||||
{ name = "isort" },
|
||||
{ name = "mccabe" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sortedcontainers"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.36.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
|
||||
]
|
||||
|
|
@ -1 +1 @@
|
|||
Subproject commit 0eec274fba59ddd373080ae961dd4497eaf2e454
|
||||
Subproject commit ecbb534e85e8e381e6e89aece4b786db8f7ad172
|
||||
Loading…
Reference in New Issue