Merge bfloat16-senbei workspace restructure, bump to 1.1.0

Adopts the fork's workspace split (senbei-cli / senbei-crypto / senbei-io /
senbei-metadata / senbei-pe), its structured error taxonomy, entry-transform
and layout validation, PE32 dd8 key-formula selection with a skip floor, the
CRT entry-stub dd8 oracle, and the extensionless-file scan skip.

Kept from senbei on top of the restructure:
- ManagedExe detection/routing and the CLR (COR20 + BSJB) metadata restore
  in the EXE pipeline.
- The RET+int3 padding fingerprint as the primary dd8 padding signal, ahead
  of the mutated-position 0xCC fallback.
- docs/, .github/, samples/, tests/ (moved to senbei-cli/tests), and the
  web/ wasm frontend (rewired to the split crates), all of which the fork
  had dropped.
- The fork's README compatibility matrix is not taken: it names real games,
  which the public-repo hygiene rules forbid.
- The wasm32 localtime fallback in logfile and unpack_bytes_force_exe (the
  web app's trap-recovery entry point), both lost in the restructure.

Golden corpus: 35/35 byte-identical. clippy -D warnings clean; wasm32 check
clean for the full workspace.
This commit is contained in:
2026-08-30 22:31:21 +08:00
49 changed files with 5259 additions and 3856 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "senbei-cli"
version.workspace = true
edition.workspace = true
description = "Command-line entry point for Senbei"
license.workspace = true
keywords = ["unpacker", "reverse-engineering", "pe", "security-research"]
categories = ["command-line-utilities"]
[[bin]]
name = "senbei"
path = "src/main.rs"
[dependencies]
senbei-io.workspace = true
[dev-dependencies]
senbei-io.workspace = true
senbei-metadata.workspace = true
tempfile.workspace = true
+112
View File
@@ -0,0 +1,112 @@
use senbei_io::{job, pause, scan};
use std::path::Path;
fn main() -> std::process::ExitCode {
let mut args = std::env::args().skip(1);
let mut path: Option<String> = None;
let mut out: Option<String> = None;
let mut quiet: u8 = 0;
let mut no_pause = false;
let mut no_log = false;
let mut verbose = false;
let mut scan_all = false;
while let Some(a) = args.next() {
match a.as_str() {
"-h" | "--help" => {
print_help();
return std::process::ExitCode::SUCCESS;
}
"-V" | "--version" => {
println!("Senbei {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
}
"-q" | "--quiet" => quiet = quiet.saturating_add(1),
"-v" | "--verbose" => verbose = true,
"--no-pause" => no_pause = true,
"--no-log" => no_log = true,
"--scan-all" => scan_all = true,
"--out" => match args.next() {
Some(v) if !v.starts_with('-') => out = Some(v),
_ => {
eprintln!("error: --out requires a directory argument");
return std::process::ExitCode::from(2);
}
},
other if other.starts_with('-') => {
eprintln!("error: unknown option '{other}'");
print_help();
return std::process::ExitCode::from(2);
}
other => {
if let Some(prev) = &path {
eprintln!("error: multiple input paths given ('{prev}' and '{other}')");
return std::process::ExitCode::from(2);
}
path = Some(other.to_string());
}
}
}
let code = match path {
None => {
print_help();
2
}
Some(p) => {
if quiet < 2 {
println!("Senbei {}", env!("CARGO_PKG_VERSION"));
}
let p = Path::new(&p);
let out_path = out.as_deref().map(Path::new);
let result = if p.is_dir() {
job::run_folder_opts(
p,
out_path,
quiet,
verbose,
no_log,
scan_all || scan::scan_all_env(),
)
} else {
job::run_file_v(p, out_path, quiet, verbose, no_log)
};
match result {
Ok(summary) => {
if quiet < 2 {
println!(
"{} unpacked · {} skipped · {} errors · {} suspect · {} metadata",
summary.unpacked,
summary.skipped,
summary.errors,
summary.suspect,
summary.metadata
);
println!("done in {} ms", summary.duration_ms);
}
if summary.errors > 0 { 1 } else { 0 }
}
Err(error) => {
if quiet < 2 {
eprintln!("error: {error:#}");
}
1
}
}
}
};
pause::maybe_pause(no_pause);
std::process::ExitCode::from(code as u8)
}
fn print_help() {
println!(
"senbei <file|folder> [--out DIR] [-v|--verbose] [-q|--quiet]... [--scan-all] [--no-log] [--no-pause] [-V|--version] [-h|--help]"
);
println!(
" --scan-all probe every file in a folder, including ones the scan\n\
\x20 pre-filter skips (under 4128 bytes, extensionless,\n\
\x20 or a bulk-asset extension). Much slower on large trees."
);
}
+11
View File
@@ -0,0 +1,11 @@
//! Shared test fixtures.
#![allow(dead_code)]
use std::path::PathBuf;
/// Path to the workspace-root `samples/` — the user-managed corpus dropped in
/// by hand. Git-ignored except its README; tests here run against whatever is
/// present. `CARGO_MANIFEST_DIR` is `senbei-cli/`, so go one level up.
pub fn samples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../samples")
}
+31
View File
@@ -0,0 +1,31 @@
use senbei_io::job::{default_out_root_for_file, out_name};
use std::path::Path;
#[test]
fn out_name_inserts_unpack_before_last_dot() {
assert_eq!(out_name(Path::new("foo.exe")), Path::new("foo.unpack.exe"));
assert_eq!(
out_name(Path::new("a/b/bar.dll")),
Path::new("a/b/bar.unpack.dll")
);
assert_eq!(out_name(Path::new("x.y.dll")), Path::new("x.y.unpack.dll"));
}
#[test]
fn out_name_no_dot_appends_unpack() {
assert_eq!(out_name(Path::new("nodot")), Path::new("nodot.unpack"));
}
#[test]
fn default_out_root_for_file_is_parent_unpack() {
assert_eq!(
default_out_root_for_file(Path::new("a/b/foo.exe")),
Path::new("a/b/unpack")
);
}
#[test]
fn default_out_root_for_file_cwd_when_no_parent() {
let p = default_out_root_for_file(Path::new("foo.exe"));
assert_eq!(p, Path::new(".").join("unpack"));
}
+47
View File
@@ -0,0 +1,47 @@
use senbei_io::logfile::{Log, local_stamp_compact, local_stamp_display};
#[test]
fn local_stamp_compact_matches_shape() {
let s = local_stamp_compact();
// YYYYMMDD-HHMMSS → 15 chars, digit groups around dash
assert_eq!(s.len(), 15, "got {s}");
assert_eq!(&s[8..9], "-");
assert!(s.as_bytes().iter().enumerate().all(|(i, b)| {
if i == 8 {
*b == b'-'
} else {
b.is_ascii_digit()
}
}));
}
#[test]
fn local_stamp_display_matches_shape() {
let s = local_stamp_display();
// YYYY-MM-DD HH:MM:SS → 19 chars
assert_eq!(s.len(), 19, "got {s}");
assert_eq!(&s[4..5], "-");
assert_eq!(&s[7..8], "-");
assert_eq!(&s[10..11], " ");
assert_eq!(&s[13..14], ":");
assert_eq!(&s[16..17], ":");
}
#[test]
fn log_writes_timestamped_file_in_target_dir() {
let td = tempfile::tempdir().unwrap();
let log = Log::create(td.path()).unwrap();
log.step("hello");
let path = log.path().to_path_buf();
drop(log);
assert!(path.starts_with(td.path()));
let name = path.file_name().unwrap().to_string_lossy();
assert!(
name.starts_with("senbei-") && name.ends_with(".log"),
"unexpected log name: {name}"
);
// senbei-YYYYMMDD-HHMMSS.log
let core = name.trim_start_matches("senbei-").trim_end_matches(".log");
assert_eq!(core.len(), 15, "stamp in name: {name}");
assert!(std::fs::read_to_string(&path).unwrap().contains("hello"));
}
+80
View File
@@ -0,0 +1,80 @@
use senbei_io::job;
use std::path::Path;
fn list_logs(dir: &Path) -> Vec<std::path::PathBuf> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("senbei-") && n.ends_with(".log"))
.unwrap_or(false)
})
.collect()
}
#[test]
fn run_file_no_log_creates_no_logfile() {
let td = tempfile::tempdir().unwrap();
let input = td.path().join("not_crackproof.bin");
std::fs::write(&input, b"not a pe").unwrap();
let out = td.path().join("out");
let s = job::run_file_v(&input, Some(&out), 2, false, true).unwrap();
assert_eq!(s.errors, 1);
// With no_log, no senbei-*.log under out (even if the dir was created).
assert!(list_logs(&out).is_empty());
}
#[test]
fn run_file_writes_log_under_out_with_header_footer() {
let td = tempfile::tempdir().unwrap();
let input = td.path().join("not_crackproof.bin");
std::fs::write(&input, b"not a pe").unwrap();
let out = td.path().join("out");
let s = job::run_file_v(&input, Some(&out), 2, false, false).unwrap();
assert_eq!(s.errors, 1);
let logs = list_logs(&out);
assert_eq!(logs.len(), 1, "expected one log under out, got {logs:?}");
let text = std::fs::read_to_string(&logs[0]).unwrap();
assert!(text.contains("Senbei "), "header version: {text}");
assert!(text.contains("started "), "{text}");
assert!(text.contains("input "), "{text}");
assert!(text.contains("out "), "{text}");
assert!(text.contains("ERR "), "{text}");
assert!(text.contains("done in "), "{text}");
assert!(text.contains("summary:"), "{text}");
}
#[test]
fn run_file_default_out_root_is_parent_unpack() {
let td = tempfile::tempdir().unwrap();
let input = td.path().join("not_crackproof.bin");
std::fs::write(&input, b"not a pe").unwrap();
let _ = job::run_file_v(&input, None, 2, false, false).unwrap();
let unpack = td.path().join("unpack");
assert!(unpack.is_dir());
assert_eq!(list_logs(&unpack).len(), 1);
// log must NOT be next to input's parent root without unpack
assert!(list_logs(td.path()).is_empty());
}
#[test]
fn run_folder_log_lives_under_out_not_root() {
let td = tempfile::tempdir().unwrap();
// empty tree: 0 candidates still creates log under unpack
let s = job::run_folder_v(td.path(), None, 2, false, false).unwrap();
assert_eq!(s.unpacked, 0);
let unpack = td.path().join("unpack");
assert!(unpack.is_dir());
assert_eq!(list_logs(&unpack).len(), 1);
assert!(
list_logs(td.path()).is_empty(),
"log must not sit on input root"
);
let text = std::fs::read_to_string(&list_logs(&unpack)[0]).unwrap();
assert!(text.contains("done in "));
assert!(text.contains("summary:"));
}
+215
View File
@@ -0,0 +1,215 @@
//! Corpus test over the user-managed `senbei/samples` folder.
//!
//! Drop real Crackproof `*.exe` / `*.dll` inputs in there (and/or il2cpp
//! `*.dat` metadata blobs), optionally alongside a byte-exact golden named
//! `<base>.golden.<ext>`. Each input is processed and classified:
//!
//! - golden present, bytes identical -> pass (silent)
//! - golden present, bytes differ -> FAIL (the test fails)
//! - no golden -> WARNING (printed; needs a manual check)
//!
//! Inputs go through [`senbei_io::job::unpack_bytes`], the same routing the CLI
//! uses, **not** `unpack_auto` directly. That matters: `unpack_auto` alone
//! cannot reach the external-companion layout, whose stub is meaningless
//! without its `<name>._` payload — a corpus wired to `unpack_auto` silently
//! covers none of the splice / export-overlay / TLS-restore code, nor the
//! marker-less "new layout" those builds use. A `<input>._` sibling in the
//! samples folder is picked up automatically, exactly as it is on disk.
//!
//! An input whose bytes carry the il2cpp metadata magic is routed through
//! [`senbei_metadata::deobfuscate`] instead, giving the method-token remap
//! real-world coverage (its unit tests only build synthetic layouts).
//!
//! The folder is git-ignored (see `senbei/samples/README.md`), so the set of
//! samples is whatever happens to be on the machine. An empty/absent folder is
//! a no-op pass.
mod common;
use common::samples_dir;
use std::path::Path;
/// An input is a `.exe`/`.dll`/`.dat` whose name doesn't carry the `.golden.`
/// marker — those are goldens, not inputs. External companions (`<name>._`)
/// have extension `_` and are therefore never inputs in their own right; they
/// are consumed by their base module.
fn is_input(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
let ext = ext.to_ascii_lowercase();
if ext != "exe" && ext != "dll" && ext != "dat" {
return false;
}
// Reject goldens like `foo.golden.exe`.
!path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_ascii_lowercase().contains(".golden."))
.unwrap_or(false)
}
/// Golden path for an input: `<base>.golden.<ext>` next to it.
fn golden_for(input: &Path) -> std::path::PathBuf {
let ext = input.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or("");
input.with_file_name(format!("{stem}.golden.{ext}"))
}
/// External-companion path for an input: `<full file name>._` next to it,
/// matching what the CLI looks for on disk.
fn companion_for(input: &Path) -> Option<std::path::PathBuf> {
let name = input.file_name()?;
let mut n = name.to_os_string();
n.push("._");
let p = input.with_file_name(n);
p.is_file().then_some(p)
}
#[test]
fn samples_unpack_against_goldens() {
let dir = samples_dir();
// An absent/empty corpus fails only when explicitly required — a green
// run that unpacked nothing hides every unpack regression, but on public
// CI there is no corpus at all (binaries are never committed), so the
// gate is opt-in via SENBEI_REQUIRE_SAMPLES rather than implied by CI.
// Locally the corpus is the user-managed samples/ folder (see
// samples/README.md).
let require = std::env::var_os("SENBEI_REQUIRE_SAMPLES").is_some();
if !dir.is_dir() {
assert!(
!require,
"samples: {} does not exist — corpus required (CI)",
dir.display()
);
eprintln!("samples: {} does not exist, nothing to test", dir.display());
return;
}
let mut inputs: Vec<_> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_file() && is_input(p))
.collect();
inputs.sort();
if inputs.is_empty() {
assert!(
!require,
"samples: no .exe/.dll inputs in {} — corpus required (CI)",
dir.display()
);
eprintln!("samples: no .exe/.dll inputs in {}", dir.display());
return;
}
let mut passed = 0usize;
let mut warnings: Vec<String> = Vec::new();
let mut failures: Vec<String> = Vec::new();
for input in &inputs {
let name = input.file_name().unwrap().to_string_lossy().to_string();
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => {
failures.push(format!("{name}: read error: {e}"));
continue;
}
};
let got = if senbei_metadata::is_metadata(&bytes) {
// il2cpp metadata: method-token de-obfuscation, no PE pipeline and
// no integrity check (the output is not a PE image).
match senbei_metadata::deobfuscate(&bytes) {
Ok((out, _report)) => out,
Err(e) => {
failures.push(format!("{name}: de-obfuscation failed: {e}"));
continue;
}
}
} else {
// Splice in the external companion when one sits next to the input,
// then run the CLI's routing (which also overlays the stub's export
// table and TLS directory for spliced inputs).
let companion = match companion_for(input) {
Some(p) => match std::fs::read(&p) {
Ok(b) => Some(b),
Err(e) => {
failures.push(format!("{name}: companion read error: {e}"));
continue;
}
},
None => None,
};
let image = match senbei_io::job::unpack_bytes(&bytes, companion.as_deref()) {
Ok(img) => img,
Err(e) => {
failures.push(format!("{name}: unpack failed: {e:?}"));
continue;
}
};
// The static integrity check is a second, golden-independent gate:
// it catches an output that is structurally plausible but would
// crash at runtime (0xC0000005) even when a stale golden still
// byte-matches. (Goldens are byte comparisons only — "matches
// golden" ≠ runs.)
if !image.integrity.ok() {
failures.push(format!(
"{name}: integrity check failed: {}",
image.integrity.issues.join("; ")
));
continue;
}
image.bytes
};
let golden = golden_for(input);
if !golden.exists() {
warnings.push(format!(
"{name}: unpacked OK ({} bytes) but no golden ({}) — MANUAL CHECK",
got.len(),
golden.file_name().unwrap().to_string_lossy()
));
continue;
}
let want = match std::fs::read(&golden) {
Ok(b) => b,
Err(e) => {
failures.push(format!("{name}: golden read error: {e}"));
continue;
}
};
if got.len() != want.len() {
failures.push(format!(
"{name}: length differs: got {} want {}",
got.len(),
want.len()
));
continue;
}
if let Some((i, (a, b))) = got.iter().zip(&want).enumerate().find(|(_, (a, b))| a != b) {
failures.push(format!(
"{name}: first diff at 0x{i:X}: got {a:02X} want {b:02X}"
));
continue;
}
passed += 1;
}
eprintln!(
"samples: {} input(s) — {} pass, {} warning(s), {} failure(s)",
inputs.len(),
passed,
warnings.len(),
failures.len()
);
for w in &warnings {
eprintln!(" WARN {w}");
}
for f in &failures {
eprintln!(" FAIL {f}");
}
assert!(failures.is_empty(), "{} sample(s) failed", failures.len());
}