use crate::dorkerfile::{self, Cmd, Dorkerfile};
use crate::errors;
use crate::images::{self, ImageMeta};
use crate::runtime::{self, BootMode};
use crate::sdk::Sdk;
use crate::store::{Instance, State, Store, new_id, now_secs};
use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
const FLUSH_DELAY: Duration = Duration::from_secs(6);
pub struct BuildArgs {
pub file: Option<PathBuf>,
pub tag: Option<String>,
pub context: PathBuf,
pub timeout: u64,
pub no_cache: bool,
}
pub struct ResolvedApk {
pub written: String,
pub path: PathBuf,
pub sha256: String,
}
pub fn sha256_file(path: &Path) -> Result<String> {
use std::io::Read;
let mut f = std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut h = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(hex(&h.finalize()))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
pub fn resolve_in_context(context: &Path, written: &str) -> Result<PathBuf> {
let joined = context.join(written);
let real = joined.canonicalize().with_context(|| {
format!(
"INSTALL path not found: {written} (looked for {})",
joined.display()
)
})?;
if !real.starts_with(context) {
bail!(
"INSTALL path {written:?} escapes the build context {}",
context.display()
);
}
if !real.is_file() {
bail!("INSTALL path {written:?} is not a file");
}
Ok(real)
}
pub struct Inputs<'a> {
pub df: &'a Dorkerfile,
pub system_image: &'a str,
pub system_image_revision: &'a str,
pub abi: &'a str,
pub flavour: &'a str,
pub emulator_version: &'a str,
pub apks: &'a [ResolvedApk],
}
pub fn fingerprint(i: &Inputs) -> String {
let d = i.df;
let mut s = String::from("dorker-build-v3\n");
let _ = writeln!(
s,
"system_image={}@{}",
i.system_image, i.system_image_revision
);
let _ = writeln!(s, "emulator={}", i.emulator_version);
let _ = writeln!(s, "abi={}", i.abi);
let _ = writeln!(
s,
"memory={} cpus={} disk={}",
d.memory_mb, d.cpus, d.disk_mb
);
let _ = writeln!(
s,
"device={} res={}x{} headless={} gpu={}",
d.device,
d.resolution.0,
d.resolution.1,
d.headless,
d.gpu_mode()
);
for a in i.apks {
let _ = writeln!(s, "apk={}", a.sha256);
}
for st in &d.settings {
let _ = writeln!(s, "setting={} {} {}", st.namespace, st.key, st.value);
}
let _ = writeln!(s, "snapshot={}", d.snapshot.as_deref().unwrap_or("-"));
let _ = writeln!(s, "flavour={}", i.flavour);
hex(&Sha256::digest(s.as_bytes()))
}
fn render_lock(i: &Inputs, fp: &str) -> String {
let d = i.df;
let mut s = String::new();
let _ = writeln!(
s,
"# Generated by dorker build. Commit this file to pin the environment."
);
let _ = writeln!(s, "version = 1\nfingerprint = \"{fp}\"\n");
let _ = writeln!(
s,
"[android]\napi = {}\nflavour = \"{}\"\nabi = \"{}\"",
d.from.api, i.flavour, i.abi
);
let _ = writeln!(
s,
"system_image = \"{}\"\nsystem_image_revision = \"{}\"\n",
i.system_image, i.system_image_revision
);
let _ = writeln!(s, "[emulator]\nversion = \"{}\"\n", i.emulator_version);
let _ = writeln!(
s,
"[device]\nprofile = \"{}\"\nresolution = \"{}x{}\"",
d.device, d.resolution.0, d.resolution.1
);
let _ = writeln!(
s,
"memory_mb = {}\ncpus = {}\ndisk_mb = {}\nheadless = {}\ngpu = \"{}\"",
d.memory_mb,
d.cpus,
d.disk_mb,
d.headless,
d.gpu_mode()
);
if let Some(n) = &d.snapshot {
let _ = writeln!(s, "snapshot = \"{n}\"");
}
for a in i.apks {
let _ = writeln!(
s,
"\n[[application]]\npath = \"{}\"\nsha256 = \"{}\"",
a.written, a.sha256
);
}
s
}
fn lock_value(lock: &str, section: &str, key: &str) -> Option<String> {
let mut current = String::new();
for l in lock.lines() {
let l = l.trim();
if let Some(sec) = l.strip_prefix('[') {
current = sec.trim_matches(|c| c == '[' || c == ']').to_string();
} else if let Some((k, v)) = l.split_once('=') {
if current == section && k.trim() == key {
return Some(v.trim().trim_matches('"').to_string());
}
}
}
None
}
fn warn_on_drift(lock_path: &Path, i: &Inputs) {
let Ok(old) = std::fs::read_to_string(lock_path) else {
return;
};
for (section, key, now) in [
("android", "system_image_revision", i.system_image_revision),
("emulator", "version", i.emulator_version),
] {
if let Some(was) = lock_value(&old, section, key) {
if was != now {
eprintln!(
"warning: {key} changed since Dorkerfile.lock was written ({was} -> {now}); updating the lockfile"
);
}
}
}
}
pub fn build(a: BuildArgs) -> Result<i32> {
let context = a
.context
.canonicalize()
.with_context(|| format!("build context not found: {}", a.context.display()))?;
let file = a.file.clone().unwrap_or_else(|| context.join("Dorkerfile"));
let source =
std::fs::read_to_string(&file).with_context(|| format!("reading {}", file.display()))?;
let display = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Dorkerfile".into());
let df =
dorkerfile::parse(&display, &source).map_err(|e| errors::with(errors::CONFIG)(e.into()))?;
let tag = a.tag.as_deref().map(images::normalize_tag).transpose()?;
let sdk = Sdk::locate().map_err(errors::with(errors::DEPENDENCY))?;
let sys = sdk
.resolve(&df.from)
.map_err(errors::with(errors::DEPENDENCY))?;
let revision = sdk.system_image_revision(&sys)?;
let emulator_version = sdk.emulator_version()?;
let host_cpus = std::thread::available_parallelism()
.map(|n| n.get() as u32)
.unwrap_or(1);
if df.cpus > host_cpus {
bail!(
"CPUS {} exceeds the {host_cpus} cores available on this host",
df.cpus
);
}
let apks: Vec<ResolvedApk> = df
.installs
.iter()
.map(|w| {
let path = resolve_in_context(&context, w).map_err(errors::with(errors::CONFIG))?;
let sha256 = sha256_file(&path)?;
Ok(ResolvedApk {
written: w.clone(),
path,
sha256,
})
})
.collect::<Result<_>>()?;
let inputs = Inputs {
df: &df,
system_image: &sys.package,
system_image_revision: &revision,
abi: &sys.abi,
flavour: &sys.flavour,
emulator_version: &emulator_version,
apks: &apks,
};
let fp = fingerprint(&inputs);
crate::log::vlog!(
"build fingerprint {fp} (system image {}@{}, emulator {}, {} apk(s), gpu {})",
sys.package,
revision,
emulator_version,
apks.len(),
df.gpu_mode()
);
let id = fp[..12].to_string();
let store = Store::open()?;
let lock_path = file.parent().unwrap_or(&context).join("Dorkerfile.lock");
warn_on_drift(&lock_path, &inputs);
let launch = df.cmd.as_ref().map(|Cmd::Launch(p)| p.clone());
let mut meta = ImageMeta {
id: id.clone(),
fingerprint: fp.clone(),
tags: vec![],
api: sys.api,
flavour: sys.flavour.clone(),
abi: sys.abi.clone(),
system_image: sys.package.clone(),
system_image_revision: revision.clone(),
emulator_version: emulator_version.clone(),
memory_mb: df.memory_mb,
cpus: df.cpus,
disk_mb: df.disk_mb,
device: df.device.clone(),
width: df.resolution.0,
height: df.resolution.1,
headless: df.headless,
gpu: df.gpu_mode(),
snapshot: df.snapshot.clone(),
launch,
created_at: now_secs(),
};
let cached = !a.no_cache && images::image_dir(&store, &id).join("image.json").is_file();
crate::log::vlog!(
"cache {} for image {id} (--no-cache: {})",
if cached { "hit" } else { "miss" },
a.no_cache
);
if cached {
meta = images::find(&store, &id)?;
eprintln!("Using cached image {id} (identical Dorkerfile, APKs and toolchain)");
} else {
runtime::ensure_free_space(&store.root(), df.memory_mb * 1024 * 1024, "build an image")?;
runtime::install_interrupt_handler();
build_new(&sdk, &store, &sys, &df, &apks, &mut meta, a.timeout)
.map_err(errors::with(errors::BUILD))?;
}
if let Some(t) = &tag {
images::apply_tag(&store, &mut meta, t)?;
}
std::fs::write(&lock_path, render_lock(&inputs, &fp))
.with_context(|| format!("writing {}", lock_path.display()))?;
eprintln!("Wrote {}", lock_path.display());
println!("{}", meta.id);
Ok(0)
}
fn build_new(
sdk: &Sdk,
store: &Store,
sys: &crate::sdk::SystemImage,
df: &Dorkerfile,
apks: &[ResolvedApk],
meta: &mut ImageMeta,
timeout: u64,
) -> Result<()> {
let lock = store.lock_create()?;
let bid = new_id();
let name = format!("build-{}", meta.id);
if store
.load_all()?
.iter()
.any(|i| i.name == name && i.state.expects_process())
{
return Err(errors::with(errors::CONFLICT)(anyhow::anyhow!(
"an identical build ({name}) is already running"
)));
}
if let Ok(old) = store.find(&name) {
let _ = store.remove(&old); }
let port = runtime::allocate_port(store, None)?;
let mut inst = Instance {
id: bid,
name,
image: format!("build:{}", meta.id),
api: sys.api,
flavour: sys.flavour.clone(),
abi: sys.abi.clone(),
memory_mb: df.memory_mb,
cpus: df.cpus,
disk_mb: df.disk_mb,
headless: df.headless,
auto_remove: false,
console_port: port,
pid: None,
state: State::Created,
created_at: now_secs(),
device: df.device.clone(),
width: df.resolution.0,
height: df.resolution.1,
gpu: df.gpu_mode(),
image_id: None,
snapshot: None,
};
store.save(&inst)?;
let started = (|| -> Result<()> {
runtime::write_avd(store, &inst, sys)?;
inst.set_state(State::Starting)?;
inst.pid = Some(runtime::spawn_emulator(
sdk,
store,
&inst,
&BootMode::ColdSaveable,
)?);
store.save(&inst)
})();
drop(lock);
let result = started.and_then(|()| provision(sdk, store, &mut inst, df, apks, timeout));
runtime::kill_instance(sdk, &inst, Duration::from_secs(20));
let published = result.and_then(|()| publish(store, &inst, meta));
let _ = store.remove(&inst);
published
}
fn adb_checked(sdk: &Sdk, serial: &str, args: &[&str], what: &str) -> Result<String> {
let out = runtime::adb(sdk, serial)
.args(args)
.output()
.with_context(|| format!("running adb for {what}"))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if !out.status.success() || text.contains("Failure [") {
bail!("{what} failed:\n{}", text.trim());
}
Ok(text)
}
fn provision(
sdk: &Sdk,
store: &Store,
inst: &mut Instance,
df: &Dorkerfile,
apks: &[ResolvedApk],
timeout: u64,
) -> Result<()> {
eprintln!("Booting build instance ({})...", inst.serial());
runtime::wait_ready(
sdk,
store,
inst,
Duration::from_secs(timeout),
runtime::interrupted,
|| {},
)?;
let serial = inst.serial();
for apk in apks {
eprintln!("Installing {}", apk.written);
adb_checked(
sdk,
&serial,
&["install", "-r", &apk.path.to_string_lossy()],
&format!("installing {}", apk.written),
)?;
}
for s in &df.settings {
eprintln!("Setting {} {} = {}", s.namespace, s.key, s.value);
adb_checked(
sdk,
&serial,
&["shell", "settings", "put", &s.namespace, &s.key, &s.value],
&format!("setting {}", s.key),
)?;
}
if !apks.is_empty() || !df.settings.is_empty() {
eprintln!("Persisting provisioned state (flush, reboot, verify)");
let flush_until = std::time::Instant::now() + FLUSH_DELAY;
while std::time::Instant::now() < flush_until && !runtime::interrupted() {
std::thread::sleep(Duration::from_millis(250));
}
runtime::reboot_and_wait(sdk, &serial, Duration::from_secs(timeout))?;
for s in &df.settings {
let out = adb_checked(
sdk,
&serial,
&["shell", "settings", "get", &s.namespace, &s.key],
&format!("reading setting {}", s.key),
)?;
if out.trim() != s.value {
bail!(
"setting {} {} did not persist across a reboot (expected {:?}, found {:?})",
s.namespace,
s.key,
s.value,
out.trim()
);
}
}
}
if let Some(name) = &df.snapshot {
eprintln!("Saving snapshot {name:?}");
let out = adb_checked(
sdk,
&serial,
&["emu", "avd", "snapshot", "save", name],
"saving snapshot",
)?;
if out.lines().any(|l| l.starts_with("KO")) {
bail!("emulator refused to save snapshot {name:?}: {}", out.trim());
}
let dir = store
.instance_dir(&inst.id)
.join("avd")
.join(format!("{}.avd", runtime::AVD_NAME))
.join("snapshots")
.join(name);
if !dir.is_dir() {
bail!(
"snapshot {name:?} was not created (expected {})",
dir.display()
);
}
}
Ok(())
}
fn publish(store: &Store, inst: &Instance, meta: &mut ImageMeta) -> Result<()> {
let final_dir = images::image_dir(store, &meta.id);
let partial = store.images_dir().join(format!(".{}.partial", meta.id));
let _ = std::fs::remove_dir_all(&partial);
std::fs::create_dir_all(&partial)?;
let inst_dir = store.instance_dir(&inst.id);
std::fs::rename(inst_dir.join("avd"), partial.join("avd"))?;
let _ = std::fs::copy(inst_dir.join("emulator.log"), partial.join("build.log"));
std::fs::write(
partial.join("image.json"),
serde_json::to_vec_pretty(&*meta)?,
)?;
if final_dir.exists() {
if let Ok(old) = images::find(store, &meta.id) {
meta.tags = old.tags;
std::fs::write(
partial.join("image.json"),
serde_json::to_vec_pretty(&*meta)?,
)?;
}
std::fs::remove_dir_all(&final_dir)?;
}
std::fs::rename(&partial, &final_dir)?;
eprintln!("Built image {}", meta.id);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::Dir;
fn inputs<'a>(
df: &'a Dorkerfile,
apks: &'a [ResolvedApk],
rev: &'a str,
emu: &'a str,
) -> Inputs<'a> {
Inputs {
df,
system_image: "system-images;android-35;google_apis;arm64-v8a",
system_image_revision: rev,
abi: "arm64-v8a",
flavour: "google_apis",
emulator_version: emu,
apks,
}
}
fn apk(path: &str, sha: &str) -> ResolvedApk {
ResolvedApk {
written: path.into(),
path: path.into(),
sha256: sha.into(),
}
}
#[test]
fn fingerprint_tracks_inputs() {
let df = dorkerfile::parse("D", "FROM android:35\nSETTING a 1\nSNAPSHOT ready\n").unwrap();
let a = [apk("./a.apk", "aa")];
let base = fingerprint(&inputs(&df, &a, "9", "1.0"));
assert_eq!(
base,
fingerprint(&inputs(&df, &a, "9", "1.0")),
"deterministic"
);
assert_eq!(
base,
fingerprint(&inputs(&df, &[apk("elsewhere/x.apk", "aa")], "9", "1.0"))
);
assert_ne!(
base,
fingerprint(&inputs(&df, &[apk("./a.apk", "bb")], "9", "1.0"))
);
assert_ne!(
base,
fingerprint(&inputs(&df, &a, "10", "1.0")),
"system image revision"
);
assert_ne!(
base,
fingerprint(&inputs(&df, &a, "9", "2.0")),
"emulator version"
);
let df2 = dorkerfile::parse("D", "FROM android:35\nSETTING a 2\nSNAPSHOT ready\n").unwrap();
assert_ne!(base, fingerprint(&inputs(&df2, &a, "9", "1.0")), "settings");
let gpu = dorkerfile::parse(
"D",
"FROM android:35\nSETTING a 1\nSNAPSHOT ready\nGPU host\n",
)
.unwrap();
assert_ne!(base, fingerprint(&inputs(&gpu, &a, "9", "1.0")), "gpu mode");
let df3 = dorkerfile::parse(
"D",
"FROM android:35\nMEMORY 3GB\nSETTING a 1\nSNAPSHOT ready\n",
)
.unwrap();
assert_ne!(
base,
fingerprint(&inputs(&df3, &a, "9", "1.0")),
"resources"
);
}
#[test]
fn context_paths_cannot_escape() {
let d = Dir::new();
let ctx = d.path().join("ctx");
std::fs::create_dir_all(ctx.join("sub")).unwrap();
std::fs::write(ctx.join("sub/app.apk"), b"x").unwrap();
std::fs::write(d.path().join("secret.apk"), b"x").unwrap();
let ctx = ctx.canonicalize().unwrap();
assert!(resolve_in_context(&ctx, "./sub/app.apk").is_ok());
assert!(
resolve_in_context(&ctx, "../secret.apk")
.unwrap_err()
.to_string()
.contains("escapes")
);
let abs = d.path().join("secret.apk");
assert!(resolve_in_context(&ctx, &abs.to_string_lossy()).is_err());
assert!(
resolve_in_context(&ctx, "missing.apk")
.unwrap_err()
.to_string()
.contains("not found")
);
assert!(
resolve_in_context(&ctx, "sub")
.unwrap_err()
.to_string()
.contains("not a file")
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.path().join("secret.apk"), ctx.join("link.apk")).unwrap();
assert!(
resolve_in_context(&ctx, "link.apk").is_err(),
"symlink out of context"
);
}
}
#[test]
fn sha256_matches_known_vector() {
let d = Dir::new();
let f = d.path().join("f");
std::fs::write(&f, b"abc").unwrap();
assert_eq!(
sha256_file(&f).unwrap(),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn lockfile_roundtrip_values() {
let df = dorkerfile::parse("D", "FROM android:35\nINSTALL a.apk\n").unwrap();
let a = [apk("./a.apk", "aa")];
let lock = render_lock(&inputs(&df, &a, "9.0.0", "37.2.12.0"), "fp");
assert_eq!(
lock_value(&lock, "android", "system_image_revision").as_deref(),
Some("9.0.0")
);
assert_eq!(
lock_value(&lock, "emulator", "version").as_deref(),
Some("37.2.12.0")
);
assert_eq!(lock_value(&lock, "", "version").as_deref(), Some("1"));
assert_eq!(
lock_value(&lock, "application", "sha256").as_deref(),
Some("aa")
);
assert!(lock.contains("[[application]]"));
}
}