Files
Brother-x86 07300d9405 hop
2026-04-18 21:51:40 +02:00

367 lines
12 KiB
Python

import os
import re
import subprocess
import typer
from pathlib import Path
from typing import Optional
from rich.console import Console
from rich.table import Table
from rich import print as rprint
app = typer.Typer(
name="obfstr-patch",
help="🔒 Obfusque automatiquement les strings dans les fichiers Rust avec obfstr!",
pretty_exceptions_show_locals=False,
context_settings={"help_option_names": ["-h", "--help"]},
)
console = Console()
SKIP_PATTERNS = [
r'obfstr!',
r'encrypt_string!',
r'include_bytes!',
r'\bpub\b',
r'\bfn\b',
r'\bextern\b',
r'^\s*use\s',
r'^\s*#',
r'^\s*//',
r'^\s*\*',
r'#\[',
r'\bmod\b',
r'impl\b',
r'feature\s*=', # strings dans les attributs cfg/feature flags
]
STRING_REGEX = re.compile(r'"((?:[^"\\]|\\.)*)"')
OBFSTR_IMPORT = 'use obfstr::obfstr;\n'
FORMAT_MACROS_RE = re.compile(
r'\b(format|write|writeln|print|println|eprint|eprintln|panic|debug|trace|info|warn|error)\s*!'
)
def should_skip_line(line: str) -> bool:
return any(re.search(pattern, line) for pattern in SKIP_PATTERNS)
def split_args(s: str) -> list:
args = []
depth = 0
current = ''
for char in s:
if char in '([{':
depth += 1
current += char
elif char in ')]}':
depth -= 1
current += char
elif char == ',' and depth == 0:
args.append(current)
current = ''
else:
current += char
if current.strip():
args.append(current)
return args
def transform_format_line(line: str) -> str:
macro_match = FORMAT_MACROS_RE.search(line)
if not macro_match:
return line
after_name = line[macro_match.end():]
paren_match = re.match(r'\s*\(', after_name)
if not paren_match:
return line
macro_content_start = macro_match.end() + paren_match.end()
after_paren = line[macro_content_start:]
writer_prefix = ''
if macro_match.group(1) in ('write', 'writeln'):
writer_match = re.match(r'([^,]+,\s*)', after_paren)
if writer_match:
writer_prefix = writer_match.group(1)
after_paren = after_paren[writer_match.end():]
str_match = re.match(r'"((?:[^"\\]|\\.)*)"', after_paren)
if not str_match:
return line
fmt_content = str_match.group(1)
after_str = after_paren[str_match.end():]
parts = re.split(r'(\{[^{}]*\})', fmt_content)
static_parts = parts[0::2]
specifiers = parts[1::2]
if not any(p for p in static_parts):
return line
new_fmt_parts = []
for i, static in enumerate(static_parts):
if static:
new_fmt_parts.append('{}')
if i < len(specifiers):
new_fmt_parts.append(specifiers[i])
new_fmt = ''.join(new_fmt_parts)
original_args = []
suffix = ''
rest_stripped = after_str.lstrip()
if rest_stripped.startswith(','):
comma_pos = after_str.index(',')
rest = after_str[comma_pos + 1:].lstrip()
inner_depth = 0
close_idx = len(rest)
for idx, ch in enumerate(rest):
if ch in '([{':
inner_depth += 1
elif ch in ')]}':
if inner_depth == 0:
close_idx = idx
break
inner_depth -= 1
inner = rest[:close_idx]
suffix = rest[close_idx:]
original_args = [a for a in split_args(inner) if a.strip()]
else:
suffix = after_str.lstrip()
all_args = []
orig_idx = 0
for i, static in enumerate(static_parts):
if static:
all_args.append(f'obfstr!("{static}")')
if i < len(specifiers):
if orig_idx < len(original_args):
all_args.append(original_args[orig_idx].strip())
orig_idx += 1
all_args.extend(a.strip() for a in original_args[orig_idx:])
prefix = line[:macro_content_start]
args_joined = ', '.join(all_args)
return f'{prefix}{writer_prefix}"{new_fmt}", {args_joined}{suffix}'
def process_line(line: str) -> str:
if should_skip_line(line):
return line
if FORMAT_MACROS_RE.search(line):
return transform_format_line(line)
def replace_string(match):
content = match.group(1)
if content.strip() == '':
return match.group(0)
if '{' in content:
return match.group(0)
# Ignorer les patterns de match : "..." =>
after = line[match.end():].lstrip()
if after.startswith('=>'):
return match.group(0)
# Cote droit d'un match arm : => "..." → obfstr + to_string
before = line[:match.start()]
if '=>' in before:
return f'obfstr!("{content}").to_string()'
return f'obfstr!("{content}")'
return STRING_REGEX.sub(replace_string, line)
def has_obfstr_import(lines: list) -> bool:
return any('use obfstr::obfstr' in line for line in lines)
def insert_import(lines: list) -> list:
last_use_end = -1
brace_depth = 0
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
brace_depth += line.count('{') - line.count('}')
# On ne considère que les `use` au niveau racine (brace_depth == 0)
if brace_depth == 0 and re.match(r'^use\s', stripped):
if stripped.endswith(';'):
last_use_end = i
else:
# use multi-lignes avec accolades : on suit jusqu'à la fermeture
depth = stripped.count('{') - stripped.count('}')
j = i
while depth > 0 and j < len(lines) - 1:
j += 1
depth += lines[j].count('{') - lines[j].count('}')
last_use_end = j
i = j
i += 1
if last_use_end >= 0:
lines.insert(last_use_end + 1, OBFSTR_IMPORT)
else:
# Sauter les //! et #! du début de fichier (crate-level doc comments)
insert_at = 0
for i, line in enumerate(lines):
stripped = line.strip()
if line.startswith('#!') or stripped.startswith('//!'):
insert_at = i + 1
elif stripped == '' and insert_at > 0:
insert_at = i + 1 # inclure les lignes vides après le bloc //!
else:
break
lines.insert(insert_at, OBFSTR_IMPORT)
return lines
def process_file(filepath: str, dry_run: bool = False) -> dict:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
new_lines = []
changed = False
strings_replaced = 0
diffs = []
for i, line in enumerate(lines):
new_line = process_line(line)
if new_line != line:
changed = True
strings_replaced += 1
diffs.append((i + 1, line.rstrip(), new_line.rstrip()))
new_lines.append(new_line)
import_added = False
if changed and not has_obfstr_import(new_lines):
new_lines = insert_import(new_lines)
import_added = True
if changed and not dry_run:
with open(filepath, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
return {
"changed": changed,
"import_added": import_added,
"strings_replaced": strings_replaced,
"diffs": diffs,
}
@app.command()
def patch(
path: Path = typer.Argument(..., help="Dossier ou fichier .rs a traiter"),
dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Simule sans modifier les fichiers"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Affiche aussi les fichiers skippes"),
diff: bool = typer.Option(False, "--diff", "-d", help="Affiche les lignes modifiees avant/apres"),
exclude: Optional[str] = typer.Option(None, "--exclude", "-e", help="Dossier ou pattern a exclure (ex: 'examples' ou 'tests|examples')"),
revert: bool = typer.Option(False, "--revert", "-r", help="Annule toutes les modifications non commitees via git restore"),
):
"""
Patch tous les fichiers .rs pour obfusquer les strings avec obfstr!
"""
if not path.exists():
rprint(f"[red]Path introuvable: {path}[/red]")
raise typer.Exit(1)
project_root = str(path) if path.is_dir() else str(path.parent)
if revert:
console.rule("[bold red]Revert git...[/bold red]")
git_root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, cwd=project_root
).stdout.strip()
result = subprocess.run(["git", "restore", str(path)], cwd=git_root)
if result.returncode != 0:
rprint("[yellow]git restore a echoue, essai avec git checkout...[/yellow]")
subprocess.run(["git", "checkout", "--", str(path)], cwd=git_root)
else:
rprint("[green]Modifications annulees avec succes.[/green]")
raise typer.Exit(0)
if path.is_file() and path.suffix == '.rs':
rs_files = [str(path)]
else:
rs_files = [
os.path.join(dirpath, filename)
for dirpath, _, filenames in os.walk(path)
for filename in filenames
if filename.endswith('.rs')
]
if exclude:
rs_files = [f for f in rs_files if not re.search(exclude, f)]
if not rs_files:
rprint("[yellow]Aucun fichier .rs trouve.[/yellow]")
raise typer.Exit(0)
console.rule(f"[bold blue]obfstr-patch {'(DRY RUN) ' if dry_run else ''}-- {len(rs_files)} fichiers[/bold blue]")
stats = {"patched": 0, "skipped": 0, "imports": 0, "strings": 0}
all_results = {}
table = Table(show_header=True, header_style="bold cyan")
table.add_column("Fichier", style="dim", max_width=60)
table.add_column("Status", justify="center")
table.add_column("Strings", justify="right")
table.add_column("Import", justify="center")
for filepath in rs_files:
result = process_file(filepath, dry_run=dry_run)
all_results[filepath] = result
short_path = os.path.relpath(filepath, str(path))
if result["changed"]:
stats["patched"] += 1
stats["strings"] += result["strings_replaced"]
if result["import_added"]:
stats["imports"] += 1
table.add_row(
short_path,
"[green]PATCHED[/green]" if not dry_run else "[yellow]DRY RUN[/yellow]",
str(result["strings_replaced"]),
"[green]✓[/green]" if result["import_added"] else "[dim]-[/dim]",
)
else:
stats["skipped"] += 1
if verbose:
table.add_row(short_path, "[dim]SKIP[/dim]", "0", "[dim]-[/dim]")
console.print(table)
if diff:
console.rule("[bold magenta]Diff des modifications[/bold magenta]")
for filepath, result in all_results.items():
if result["diffs"]:
short_path = os.path.relpath(filepath, str(path))
console.rule(f"[magenta]{short_path}[/magenta]")
for lineno, before, after in result["diffs"]:
rprint(f" [dim]L{lineno}[/dim]")
rprint(f" [red]- {before}[/red]")
rprint(f" [green]+ {after}[/green]")
console.rule("[bold green]Resume[/bold green]")
rprint(f" [green]Patches :[/green] {stats['patched']}")
rprint(f" [dim]Skippes :[/dim] {stats['skipped']}")
rprint(f" [cyan]Imports :[/cyan] {stats['imports']}")
rprint(f" [cyan]Strings :[/cyan] {stats['strings']}")
if dry_run:
rprint("\n [yellow]Mode DRY RUN -- aucun fichier modifie[/yellow]")
if __name__ == '__main__':
app()