use clap::{Parser, Subcommand};
use std::io::Write;
use std::path::PathBuf;
mod amlogic;
mod apk;
mod archive;
mod audit;
mod avb;
mod bootimg;
mod bsdiff;
mod content;
mod detect;
mod doctor;
mod dt;
mod engine;
mod erofsfs;
mod ext4fs;
mod extract;
mod hashtree;
mod info;
mod libbrotli;
mod lp;
mod manifest;
mod mcp;
mod otameta;
mod ozip;
mod pac;
mod payload;
mod ramdisk;
mod report;
mod sdat;
mod skill;
mod sparse;
mod term;
#[cfg(test)]
mod testutil;
mod transfer_list;
mod tree;
mod treeout;
#[derive(Parser)]
#[command(version, about = "Extract and audit Android OTA/ROM images")]
struct Cli {
#[arg(long, global = true)]
no_color: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Extract {
input: PathBuf,
#[arg(short, long, default_value = "out")]
output: PathBuf,
#[arg(long)]
force: bool,
#[arg(long, value_delimiter = ',')]
only: Vec<String>,
#[arg(long)]
list: bool,
#[arg(long)]
files: bool,
#[arg(long, value_name = "DIR")]
base: Option<PathBuf>,
#[arg(long)]
allow_partial: bool,
#[arg(long)]
strict: bool,
#[arg(short = 'q', long)]
quiet: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
no_color: bool,
},
Info {
input: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
details: bool,
},
Amlogic {
input: PathBuf,
},
Dt {
input: PathBuf,
#[arg(long)]
json: bool,
},
Partitions {
input: PathBuf,
#[arg(long, default_value = "4096")]
sector_size: u64,
#[arg(long)]
json: bool,
},
Unpack {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
force: bool,
},
Ls {
image: PathBuf,
#[arg(default_value = "/")]
path: String,
#[arg(long)]
json: bool,
},
Cat { image: PathBuf, path: String },
Audit {
#[arg(required = true)]
images: Vec<PathBuf>,
#[arg(long)]
json: bool,
},
Vbmeta {
image: PathBuf,
#[arg(long)]
images: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(short, long)]
key: Option<PathBuf>,
},
Ramdisk {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
list: bool,
},
Files {
image: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Unsparse {
#[arg(required = true)]
inputs: Vec<PathBuf>,
#[arg(short, long)]
output: PathBuf,
#[arg(long)]
force: bool,
},
Identify {
#[arg(required = true)]
paths: Vec<PathBuf>,
#[arg(long)]
json: bool,
},
Report {
input: PathBuf,
#[arg(long)]
json: bool,
},
HashTree {
input: PathBuf,
#[arg(long, value_name = "OUT")]
output: Option<PathBuf>,
},
Doctor {
#[command(subcommand)]
action: DoctorAction,
},
Mcp {
#[arg(long)]
verbose: bool,
},
}
#[derive(Subcommand)]
enum DoctorAction {
Scan {
input: PathBuf,
#[arg(long)]
json: bool,
},
Install {
#[arg(long)]
agent: Option<String>,
#[arg(long)]
print_only: bool,
},
}
fn doctor_scan(input: &std::path::Path, json: bool) -> anyhow::Result<()> {
let findings = doctor::scan(input)?;
let text = if json {
let rows: Vec<serde_json::Value> = findings
.iter()
.map(|f| {
serde_json::json!({
"id": f.id,
"category": f.category,
"severity": f.severity,
"subject": f.subject,
"message": f.message,
"remedy": f.remedy,
})
})
.collect();
serde_json::to_string_pretty(&rows)?
} else {
render_findings(&findings)
};
print_out(&text)?;
if findings.iter().any(|f| f.severity == "error") {
anyhow::bail!("one or more findings have severity error");
}
Ok(())
}
fn doctor_install(agent: Option<String>, print_only: bool) -> anyhow::Result<()> {
if print_only {
return print_out(&skill::content());
}
match agent {
None => {
for a in skill::Agent::all() {
println!("{}: {}", a.name(), a.skill_dir().display());
}
println!("\npass --agent <name> to write, or --print to see the skill");
Ok(())
}
Some(name) => {
let all = skill::Agent::all();
let name = if name == "claude" {
"claude-code".into()
} else {
name
};
let Some(a) = all.iter().find(|a| a.name() == name) else {
anyhow::bail!(
"unknown agent {name:?}; expected one of {}",
skill::Agent::all()
.iter()
.map(|a| a.name())
.collect::<Vec<_>>()
.join(", ")
);
};
let dest = skill::install_in(*a, &a.skill_dir())?;
let mut out = format!("wrote {}", dest.display());
if let Some(extra) = skill::install_project(*a, &std::env::current_dir()?)? {
out.push_str(&format!("\nwrote {}", extra.display()));
}
print_out(&out)
}
}
}
fn hash_tree_command(
input: &std::path::Path,
output: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let data = std::fs::read(input)?;
let vbmeta = avb::read_input(input, None)?;
let Some(desc) = vbmeta.descriptors.iter().find_map(|d| match d {
avb::Descriptor::Hashtree(h) => Some(h),
_ => None,
}) else {
anyhow::bail!(
"{} has no hash tree descriptor to rebuild from",
input.display()
);
};
let end = (desc.image_size as usize).min(data.len());
let tree = hashtree::hash_tree(&data[..end], desc)?;
match output {
Some(p) => {
std::fs::write(p, &tree)?;
print_out(&format!(
"wrote {} bytes of hash tree to {}",
tree.len(),
p.display()
))
}
None => print_out(&format!(
"hash tree recomputed and verified against the descriptor root ({} bytes)",
tree.len()
)),
}
}
fn identify(paths: &[PathBuf], json: bool) -> anyhow::Result<()> {
let mut failed = 0;
let mut rows = Vec::new();
for path in paths {
match detect::identify_path(path) {
Ok(i) => rows.push(serde_json::json!({
"path": path.display().to_string(),
"id": i.id,
"description": i.description,
})),
Err(e) => {
failed += 1;
rows.push(serde_json::json!({
"path": path.display().to_string(),
"error": format!("{e:#}"),
}));
}
}
}
let text = if json {
serde_json::to_string_pretty(&rows)?
} else {
rows.iter()
.map(|r| match r["error"].as_str() {
Some(e) => format!("{}: error: {e}", r["path"].as_str().unwrap_or("")),
None => format!(
"{}: {}",
r["path"].as_str().unwrap_or(""),
r["description"].as_str().unwrap_or("")
),
})
.collect::<Vec<_>>()
.join("\n")
};
print_out(&text)?;
if failed > 0 {
anyhow::bail!("{failed} of {} paths could not be read", paths.len());
}
Ok(())
}
fn ramdisk_command(
input: &std::path::Path,
output: Option<&std::path::Path>,
json: bool,
list: bool,
) -> anyhow::Result<()> {
let found = ramdisk::read_input(input, output)?;
let mut failed = 0;
let text = if json {
let rows: Vec<_> = found
.iter()
.map(|f| match &f.report {
Ok(r) => serde_json::json!({"ramdisk": f.name, "result": r.to_json()}),
Err(e) => {
failed += 1;
serde_json::json!({"ramdisk": f.name, "error": format!("{e:#}")})
}
})
.collect();
serde_json::to_string_pretty(&rows)?
} else {
found
.iter()
.map(|f| match &f.report {
Ok(r) => format!("{}:\n{}", f.name, r.to_text(list)),
Err(e) => {
failed += 1;
format!("{}: error: {e:#}", f.name)
}
})
.collect::<Vec<_>>()
.join("\n\n")
};
print_out(&text)?;
anyhow::ensure!(
failed == 0,
"{failed} of {} ramdisks could not be read",
found.len()
);
Ok(())
}
fn render_findings(findings: &[doctor::Finding]) -> String {
let mut lines = Vec::new();
for f in findings {
lines.push(format!(
"{} {} {}: {}: {}",
f.severity, f.category, f.id, f.subject, f.message
));
if let Some(r) = &f.remedy {
lines.push(format!(" â”” {}", r));
}
}
lines.join(
"
",
)
}
fn print_out(text: &str) -> anyhow::Result<()> {
match writeln!(std::io::stdout(), "{text}") {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), other => Ok(other?),
}
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let no_color = cli.no_color;
let result = match cli.command {
Command::HashTree { input, output } => hash_tree_command(&input, output.as_deref()),
Command::Extract {
input,
output,
force,
only,
list,
files,
allow_partial,
base,
strict,
quiet,
verbose,
no_color,
} => {
let opts = extract::ExtractOptions {
base,
force,
only: (!only.is_empty()).then_some(only),
list,
files,
allow_partial,
strict,
quiet,
verbose,
no_color,
};
extract::run(&input, &output, &opts)
}
Command::Info {
input,
json,
details,
} => info_command(&input, json, details),
Command::Unpack {
input,
output,
json,
force,
} => {
let image = bootimg::read(&input)?;
if let Some(dir) = &output {
bootimg::write_sections(&input, &image, dir, force)?;
}
let text = if json {
serde_json::to_string_pretty(&bootimg::to_json(&image))?
} else {
bootimg::to_text(&image)
};
print_out(&text)
}
Command::Ramdisk {
input,
output,
json,
list,
} => ramdisk_command(&input, output.as_deref(), json, list),
Command::Ls { image, path, json } => ls_command(&image, &path, json),
Command::Cat { image, path } => cat_command(&image, &path),
Command::Audit { images, json } => audit_command(&images, json, no_color),
Command::Vbmeta {
image,
images,
json,
key,
} => vbmeta_command(&image, images.as_deref(), json, key.as_deref()),
Command::Files {
image,
output,
json,
} => files_command(&image, output.as_deref(), json),
Command::Unsparse {
inputs,
output,
force,
} => sparse::run(&inputs, &output, force),
Command::Identify { paths, json } => identify(&paths, json),
Command::Partitions {
input,
sector_size,
json,
} => partitions_command(&input, sector_size, json),
Command::Dt { input, json } => dt_command(&input, json),
Command::Amlogic { input } => {
let img = amlogic::describe_file(&input)?;
print_out(&amlogic::to_text(&img, &display_name(&input)))
}
Command::Report { input, json } => {
let meta = info::read(&input)?;
let parts = extract::partition_names(&input)?;
let report = report::analyze(&meta, parts, report::today_days())?;
print_out(&report::render(&report, json)?)
}
Command::Doctor { action } => match action {
DoctorAction::Scan { input, json } => doctor_scan(&input, json),
DoctorAction::Install { agent, print_only } => doctor_install(agent, print_only),
},
Command::Mcp { verbose } => mcp::serve(verbose),
};
match result {
Ok(()) => Ok(()),
Err(e) => {
eprintln!(
"{}",
term::Renderer::new(term::Style::detect(no_color)).error(&e)
);
std::process::exit(1);
}
}
}
fn partitions_command(input: &std::path::Path, sector_size: u64, json: bool) -> anyhow::Result<()> {
let m = manifest::read(input, sector_size)?;
let text = if json {
serde_json::to_string_pretty(&manifest_json(&m))?
} else {
manifest::to_text(&m)
};
print_out(&text)?;
if !m.missing_images.is_empty() {
anyhow::bail!(
"{} manifest image(s) are missing from {}",
m.missing_images.len(),
input.display()
);
}
Ok(())
}
fn manifest_json(m: &manifest::Manifest) -> serde_json::Value {
serde_json::json!({
"format": m.kind.name(),
"sector_size": m.sector_size,
"partitions": m.partitions.iter().map(|p| serde_json::json!({
"label": p.label,
"filename": p.filename,
"start_sector": p.start_sector,
"num_sectors": p.num_sectors,
"size_bytes": p.num_sectors * m.sector_size,
"sparse": p.sparse,
})).collect::<Vec<_>>(),
"missing_images": m.missing_images,
"unreferenced_images": m.unreferenced_images,
})
}
fn dt_command(input: &std::path::Path, json: bool) -> anyhow::Result<()> {
let text = if json {
serde_json::to_string_pretty(&dt::to_json(input)?)?
} else {
dt::describe_file(input)?
};
print_out(&text)
}
fn display_name(p: &std::path::Path) -> String {
p.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| p.display().to_string())
}
fn files_command(
image: &std::path::Path,
output: Option<&std::path::Path>,
json: bool,
) -> anyhow::Result<()> {
let fs = tree::Tree::open(image)?;
let entries = match output {
Some(out) => fs.extract(out, None)?,
None => fs.entries()?,
};
let text = if json {
serde_json::to_string_pretty(&tree::manifest(&entries))?
} else {
entries
.iter()
.map(entry_line)
.collect::<Vec<_>>()
.join("\n")
};
print_out(&text)
}
fn vbmeta_command(
image: &std::path::Path,
images: Option<&std::path::Path>,
json: bool,
key: Option<&std::path::Path>,
) -> anyhow::Result<()> {
let external_key = if let Some(k) = key {
Some(avb::read_key(k)?)
} else {
None
};
let meta = avb::read_input(image, external_key.as_ref())?;
let checks = match images {
Some(dir) => avb::verify_images(&meta, dir)?,
None => Vec::new(),
};
let chained_findings: Vec<_> = images
.map(|d| meta.cross_check_chained(d))
.unwrap_or_default();
let text = if json {
let mut v = meta.to_json(&checks);
v["findings"] = serde_json::json!(
meta.findings()
.into_iter()
.chain(chained_findings.into_iter())
.map(|f| serde_json::json!({
"severity": f.severity.name(),
"rule": f.rule,
"detail": f.detail,
}))
.collect::<Vec<_>>()
);
serde_json::to_string_pretty(&v)?
} else {
let mut t = meta.to_text(&checks);
for f in &chained_findings {
t.push_str(&format!(
"\n [{}] {}: {}\n",
f.severity.name(),
f.rule,
f.detail
));
}
t
};
print_out(&text)?;
anyhow::ensure!(
!meta.any_failure(&checks),
"the digest, signature, or a partition hash does not match"
);
Ok(())
}
fn entry_line(e: &tree::Entry) -> String {
let label = e
.xattrs
.iter()
.find(|(k, _)| k == "security.selinux")
.map_or("", |(_, v)| v.as_str());
let link = e
.link
.as_deref()
.map(|l| format!(" -> {l}"))
.unwrap_or_default();
let name = if e.path.is_empty() {
"/"
} else {
e.path.as_str()
};
format!(
"{:7} {:04o} {:>5} {:>5} {:>11} {}{} {}",
e.kind.name(),
e.mode,
e.uid,
e.gid,
e.size,
name,
link,
label
)
}
fn ls_command(image: &std::path::Path, path: &str, json: bool) -> anyhow::Result<()> {
let entries = tree::Tree::open(image)?.list_dir(path)?;
let text = if json {
serde_json::to_string_pretty(&entries.iter().map(tree::entry_json).collect::<Vec<_>>())?
} else {
entries
.iter()
.map(entry_line)
.collect::<Vec<_>>()
.join("\n")
};
print_out(&text)
}
fn cat_command(image: &std::path::Path, path: &str) -> anyhow::Result<()> {
let fs = tree::Tree::open(image)?;
let mut out = std::io::stdout().lock();
match fs.cat_path(path, &mut out) {
Err(e)
if e.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::BrokenPipe) =>
{
Ok(())
}
r => r,
}
}
fn audit_command(images: &[PathBuf], json: bool, no_color: bool) -> anyhow::Result<()> {
let audits = audit::image_list(images)?
.iter()
.map(|p| audit::audit_image(p))
.collect::<anyhow::Result<Vec<_>>>()?;
let text = if json {
serde_json::to_string_pretty(&audit::to_json(&audits))?
} else {
audit::to_text(&audits, no_color)
};
print_out(&text)
}
fn info_command(input: &std::path::Path, json: bool, details: bool) -> anyhow::Result<()> {
let meta = info::read(input)?;
if !details {
return print_out(&info::render(&meta, json)?);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let d = otameta::read_details(input)?;
if json {
let mut v = d.to_json(now);
v["metadata"] = serde_json::to_value(&meta)?;
return print_out(&serde_json::to_string_pretty(&v)?);
}
print_out(&format!(
"{}\n\n{}",
info::render(&meta, false)?,
d.to_text(now)
))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
}