Cybersecurity-Projects/PROJECTS/beginner/base64-tool/src/base64_tool/cli.py

321 lines
8.2 KiB
Python

"""
©AngelaMos | 2026
cli.py
Typer CLI application with five encoding subcommands
Defines the b64tool Typer app and its five commands: encode, decode,
detect, peel, and chain. Each command resolves its input from a
positional argument, --file, or stdin, delegates to the appropriate
logic module, then passes results to formatter.py for display. The
chain command applies a comma-separated sequence of formats in order,
passing each encoded output as the next step's input.
Key exports:
app - The Typer application instance registered as the CLI entry point
Connects to:
__init__.py - imports __version__
constants.py - imports EncodingFormat, ExitCode, PEEL_MAX_DEPTH
encoders.py - imports encode, decode, encode_url, decode_url
detector.py - imports detect_encoding, score_all_formats
peeler.py - imports peel
formatter.py - imports all print_* functions
utils.py - imports resolve_input_bytes, resolve_input_text
test_cli.py - exercises all commands via Typer's CliRunner
"""
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