use anyhow::{Context, Result, ensure};
use serde::Serialize;
use std::{fs, path::Path, process::Command};
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Compiled {
pub flat_bytes: u64,
}
pub(crate) fn aapt2() -> Option<&'static Path> {
static FOUND: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
FOUND.get_or_init(crate::tools::find_aapt2).as_deref()
}
pub(crate) fn compile(
aapt2: &Path,
directory: &str,
file_name: &str,
bytes: &[u8],
) -> Result<Compiled> {
let safe = |name: &str| {
!name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
&& !name.contains("..")
};
ensure!(
safe(directory) && safe(file_name),
"resource path is not a valid AAPT2 input: {directory}/{file_name}"
);
let work = tempfile::tempdir()?;
let source_directory = work.path().join("res").join(directory);
fs::create_dir_all(&source_directory)?;
let source = source_directory.join(file_name);
fs::write(&source, bytes)?;
let output = work.path().join("out");
fs::create_dir(&output)?;
let result = Command::new(aapt2)
.arg("compile")
.arg("-o")
.arg(&output)
.arg(&source)
.output()
.with_context(|| format!("running {}", aapt2.display()))?;
ensure!(
result.status.success(),
"aapt2 rejected {directory}/{file_name}: {}",
String::from_utf8_lossy(&result.stderr).trim()
);
let flat = fs::read_dir(&output)?
.filter_map(|entry| entry.ok())
.find(|entry| entry.path().extension().is_some_and(|e| e == "flat"))
.context("aapt2 produced no compiled resource")?;
Ok(Compiled {
flat_bytes: flat.metadata()?.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsafe_names_are_refused_before_running_anything() {
let missing = Path::new("/nonexistent/aapt2");
for (directory, file) in [("../x", "a.png"), ("drawable", "../a.png"), ("", "a.png")] {
let error = compile(missing, directory, file, b"")
.unwrap_err()
.to_string();
assert!(error.contains("not a valid AAPT2 input"), "{error}");
}
assert!(compile(missing, "drawable", "a.png", b"").is_err());
}
#[test]
fn aapt2_accepts_webp_and_rejects_a_broken_nine_patch() {
let Some(aapt2) = crate::tools::find_aapt2() else {
eprintln!("aapt2 not found; skipping");
return;
};
let mut png_bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png_bytes, 8, 8);
encoder.set_color(png::ColorType::Rgba);
encoder
.write_header()
.unwrap()
.write_image_data(&[200; 8 * 8 * 4])
.unwrap();
}
let webp_bytes = webp::Encoder::from_rgba(&[200; 8 * 8 * 4], 8, 8)
.encode_simple(true, 100.0)
.unwrap();
assert!(
compile(&aapt2, "drawable-xhdpi", "a.png", &png_bytes)
.unwrap()
.flat_bytes
> 0
);
assert!(
compile(&aapt2, "drawable-xhdpi", "a.webp", &webp_bytes)
.unwrap()
.flat_bytes
> 0
);
let error = compile(&aapt2, "drawable-xhdpi", "a.9.png", &png_bytes).unwrap_err();
assert!(error.to_string().contains("aapt2 rejected"), "{error}");
assert!(compile(&aapt2, "drawable-xhdpi", "a.png", b"not an image").is_err());
}
}