use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
pub const BUILD_SCRIPT: &str = include_str!("../bin/twa-build");
pub const SETUP_SCRIPT: &str = include_str!("../bin/setup-twa");
pub const INIT_JS: &str = include_str!("../twa/init.js");
pub const MANIFEST_TEMPLATE: &str = include_str!("../twa/twa-manifest.json.template");
pub const HOST_PACKAGES: [&str; 3] = ["curl", "unzip", "zip"];
pub const CHECKOUT_APK_PATH: &str = "web/static/install/mobux.apk";
pub const CHECKOUT_ASSETLINKS_PATH: &str = "web/static/.well-known/assetlinks.json";
pub fn work_dir(data_dir: &Path) -> PathBuf {
data_dir.join("twa")
}
pub fn apk_path(data_dir: &Path) -> PathBuf {
data_dir.join("install").join("mobux.apk")
}
pub fn build_record_path(data_dir: &Path) -> PathBuf {
data_dir.join("install").join("mobux.apk.json")
}
pub fn record_build(data_dir: &Path, domain: &str) -> Result<()> {
let path = build_record_path(data_dir);
let record = serde_json::json!({ "domain": domain });
std::fs::write(&path, record.to_string()).with_context(|| format!("writing {}", path.display()))
}
pub fn built_domain(data_dir: &Path) -> Option<String> {
let bytes = std::fs::read(build_record_path(data_dir)).ok()?;
let record: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
record["domain"].as_str().map(str::to_owned)
}
pub fn assetlinks_path(data_dir: &Path) -> PathBuf {
data_dir.join(".well-known").join("assetlinks.json")
}
pub fn resolve_artifact(built: PathBuf, checkout: &str) -> PathBuf {
if built.is_file() {
return built;
}
PathBuf::from(checkout)
}
pub struct Scripts {
pub setup: PathBuf,
pub build: PathBuf,
}
pub fn materialize(data_dir: &Path) -> Result<Scripts> {
let dir = work_dir(data_dir);
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating twa work dir: {}", dir.display()))?;
let build = dir.join("twa-build");
std::fs::write(&build, BUILD_SCRIPT).with_context(|| format!("writing {}", build.display()))?;
let setup = dir.join("setup-twa");
std::fs::write(&setup, SETUP_SCRIPT).with_context(|| format!("writing {}", setup.display()))?;
std::fs::write(dir.join("init.js"), INIT_JS).context("writing twa init.js")?;
std::fs::write(dir.join("twa-manifest.json.template"), MANIFEST_TEMPLATE)
.context("writing twa manifest template")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for script in [&build, &setup] {
std::fs::set_permissions(script, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod {}", script.display()))?;
}
}
Ok(Scripts { setup, build })
}
pub fn toolchain_check(build_script: &Path) -> std::process::Command {
let mut command = std::process::Command::new("bash");
command
.arg(build_script)
.arg("--check-toolchain")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
command
}
pub fn toolchain_present(build_script: &Path) -> bool {
toolchain_check(build_script)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
path.is_file()
}
pub fn on_path(tool: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| is_executable(&dir.join(tool)))
}
pub fn missing_host_packages(present: impl Fn(&str) -> bool) -> Vec<&'static str> {
HOST_PACKAGES
.into_iter()
.filter(|tool| !present(tool))
.collect()
}
pub fn missing_host_packages_on_path() -> Vec<&'static str> {
missing_host_packages(on_path)
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PackageManager {
Apt,
Dnf,
Pacman,
Zypper,
Apk,
}
impl PackageManager {
pub fn detect(present: impl Fn(&str) -> bool) -> Option<Self> {
[
("apt-get", Self::Apt),
("dnf", Self::Dnf),
("pacman", Self::Pacman),
("zypper", Self::Zypper),
("apk", Self::Apk),
]
.into_iter()
.find(|(tool, _)| present(tool))
.map(|(_, manager)| manager)
}
pub fn install_command(self, packages: &[&str]) -> String {
let list = packages.join(" ");
match self {
Self::Apt => format!("sudo apt-get install -y {list}"),
Self::Dnf => format!("sudo dnf install -y {list}"),
Self::Pacman => format!("sudo pacman -S --noconfirm {list}"),
Self::Zypper => format!("sudo zypper install -y {list}"),
Self::Apk => format!("sudo apk add {list}"),
}
}
}
pub struct HostPackageGap {
pub packages: Vec<String>,
pub install_command: Option<String>,
pub message: String,
}
fn english_list(items: &[&str]) -> String {
match items {
[] => String::new(),
[one] => (*one).to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
}
}
pub fn host_package_gap_with(manager: Option<PackageManager>, missing: &[&str]) -> HostPackageGap {
let them = if missing.len() == 1 { "it" } else { "them" };
HostPackageGap {
packages: missing.iter().map(|p| (*p).to_string()).collect(),
install_command: manager.map(|m| m.install_command(missing)),
message: format!(
"This host is missing {}. The Android build tools unpack their downloads with {them}, and only your system package manager can install {them}.",
english_list(missing),
),
}
}
pub fn host_package_gap(missing: &[&str]) -> HostPackageGap {
host_package_gap_with(PackageManager::detect(on_path), missing)
}
pub fn resolve_domain(
configured: Option<&str>,
host_header: Option<&str>,
) -> Result<String, String> {
let usable = |s: &Option<&str>| {
s.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
};
let raw = usable(&configured)
.or_else(|| usable(&host_header))
.ok_or_else(|| "no Host header on the request and app.domain is not set".to_string())?;
let domain = raw.strip_suffix(":443").unwrap_or(&raw);
validate_domain(domain).map(|_| domain.to_string())
}
pub fn pinned_domain(
access_hostname: Option<&str>,
configured: Option<&str>,
host_header: Option<&str>,
) -> Result<String, String> {
match access_hostname.map(str::trim).filter(|h| !h.is_empty()) {
Some(hostname) => resolve_domain(Some(hostname), None),
None => resolve_domain(configured, host_header),
}
}
fn validate_domain(domain: &str) -> Result<(), String> {
if domain.is_empty() {
return Err("empty domain".to_string());
}
let (host, port) = match domain.rsplit_once(':') {
Some((h, p)) => (h, Some(p)),
None => (domain, None),
};
if host.is_empty()
|| !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return Err(format!("{domain:?} is not a usable host name"));
}
if let Some(p) = port {
if p.parse::<u16>().is_err() {
return Err(format!("{domain:?} has an invalid port"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn domain_comes_from_the_host_header() {
assert_eq!(
resolve_domain(None, Some("box.example.com")).unwrap(),
"box.example.com"
);
assert_eq!(
resolve_domain(None, Some("sandbox:5152")).unwrap(),
"sandbox:5152"
);
}
#[test]
fn a_configured_domain_wins_over_the_host_header() {
assert_eq!(
resolve_domain(Some("pinned.example.com"), Some("box.example.com")).unwrap(),
"pinned.example.com"
);
assert_eq!(
resolve_domain(Some(" "), Some("box.example.com")).unwrap(),
"box.example.com"
);
}
#[test]
fn the_access_hostname_wins_over_the_configured_domain_and_the_host_header() {
assert_eq!(
pinned_domain(
Some("mobux.example.com"),
Some("pinned.example.com"),
Some("box.tailnet.ts.net:5151")
)
.unwrap(),
"mobux.example.com"
);
}
#[test]
fn without_an_access_hostname_the_package_follows_the_usual_domain() {
assert_eq!(
pinned_domain(None, Some(""), Some("box.tailnet.ts.net:5151")).unwrap(),
"box.tailnet.ts.net:5151"
);
assert_eq!(
pinned_domain(Some(" "), Some("pinned.example.com"), None).unwrap(),
"pinned.example.com"
);
}
#[test]
fn default_tls_port_is_dropped() {
assert_eq!(
resolve_domain(None, Some("box.example.com:443")).unwrap(),
"box.example.com"
);
}
#[test]
fn a_missing_host_header_is_an_error_not_a_guess() {
assert!(resolve_domain(None, None).is_err());
assert!(resolve_domain(None, Some(" ")).is_err());
}
#[test]
fn a_host_header_carrying_a_scheme_or_path_is_rejected() {
for bad in [
"https://box.example.com",
"box.example.com/app",
"box:notaport",
] {
assert!(
resolve_domain(None, Some(bad)).is_err(),
"should reject {bad:?}"
);
}
}
#[test]
fn a_finished_build_records_the_host_it_was_signed_for() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(built_domain(dir.path()), None);
std::fs::create_dir_all(dir.path().join("install")).unwrap();
record_build(dir.path(), "mobux.example.com").unwrap();
assert_eq!(
built_domain(dir.path()).as_deref(),
Some("mobux.example.com")
);
}
#[test]
fn materialize_writes_an_executable_script_and_its_inputs() {
let dir = tempfile::tempdir().unwrap();
let scripts = materialize(dir.path()).unwrap();
assert!(scripts.build.is_file());
assert!(scripts.setup.is_file());
assert!(work_dir(dir.path()).join("init.js").is_file());
assert!(work_dir(dir.path())
.join("twa-manifest.json.template")
.is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for script in [&scripts.build, &scripts.setup] {
let mode = std::fs::metadata(script).unwrap().permissions().mode();
assert_eq!(mode & 0o100, 0o100, "script must be owner-executable");
}
}
}
#[test]
fn the_preflight_names_only_the_packages_that_are_absent() {
assert!(missing_host_packages(|_| true).is_empty());
assert_eq!(missing_host_packages(|tool| tool != "zip"), vec!["zip"]);
assert_eq!(
missing_host_packages(|tool| tool == "curl"),
vec!["unzip", "zip"]
);
}
#[test]
fn the_install_command_matches_the_hosts_package_manager() {
let missing = ["unzip", "zip"];
for (tool, want) in [
("apt-get", "sudo apt-get install -y unzip zip"),
("dnf", "sudo dnf install -y unzip zip"),
("pacman", "sudo pacman -S --noconfirm unzip zip"),
("zypper", "sudo zypper install -y unzip zip"),
("apk", "sudo apk add unzip zip"),
] {
let manager = PackageManager::detect(|t| t == tool).expect("a known manager");
assert_eq!(manager.install_command(&missing), want);
}
}
#[test]
fn an_unknown_package_manager_yields_no_command_rather_than_a_guess() {
assert_eq!(PackageManager::detect(|_| false), None);
let gap = host_package_gap_with(None, &["curl", "unzip", "zip"]);
assert_eq!(gap.install_command, None);
assert!(
gap.message.contains("curl, unzip and zip"),
"{}",
gap.message
);
assert!(
gap.message.contains("system package manager"),
"{}",
gap.message
);
}
#[test]
fn the_preflight_reports_the_packages_and_the_command_separately() {
let gap = host_package_gap_with(Some(PackageManager::Apt), &["zip"]);
assert_eq!(gap.packages, vec!["zip".to_string()]);
assert_eq!(
gap.install_command.as_deref(),
Some("sudo apt-get install -y zip")
);
assert!(gap.message.contains("missing zip"), "{}", gap.message);
assert!(
!gap.message.contains("apt-get"),
"the command must not be duplicated into the prose: {}",
gap.message
);
}
#[test]
fn the_preflight_covers_every_host_tool_the_setup_script_requires() {
let required: Vec<&str> = SETUP_SCRIPT
.lines()
.map(str::trim)
.filter_map(|line| line.strip_prefix("require_host_tool "))
.map(str::trim)
.collect();
assert!(!required.is_empty(), "setup-twa must require host tools");
let mut sorted = required.clone();
sorted.sort_unstable();
let mut known = HOST_PACKAGES.to_vec();
known.sort_unstable();
assert_eq!(sorted, known);
}
#[cfg(unix)]
fn toolchain_check_with(tools: &[&str]) -> bool {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().unwrap();
let bin = tempfile::tempdir().unwrap();
for tool in ["bash", "dirname"] {
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!("command -v {tool}"))
.output()
.expect("resolving a host tool");
let target = String::from_utf8(out.stdout).unwrap().trim().to_string();
assert!(!target.is_empty(), "{tool} must exist to run this test");
std::os::unix::fs::symlink(target, bin.path().join(tool)).unwrap();
}
for tool in tools {
let stub = bin.path().join(tool);
std::fs::write(&stub, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let data_dir = tempfile::tempdir().unwrap();
let scripts = materialize(data_dir.path()).unwrap();
toolchain_check(&scripts.build)
.env("PATH", bin.path())
.env("HOME", home.path())
.env_remove("SDKMAN_DIR")
.env_remove("NVM_DIR")
.env_remove("ANDROID_SDK_ROOT")
.env_remove("ANDROID_HOME")
.env_remove("NPM_PREFIX")
.status()
.expect("running the toolchain check")
.success()
}
#[cfg(unix)]
#[test]
fn a_host_without_the_toolchain_reports_it_missing() {
assert!(!toolchain_check_with(&[]));
assert!(
!toolchain_check_with(&["node", "java", "keytool"]),
"a partial toolchain is still missing"
);
}
#[cfg(unix)]
#[test]
fn a_host_with_the_toolchain_reports_it_present() {
assert!(toolchain_check_with(&[
"node",
"java",
"keytool",
"bubblewrap"
]));
}
#[test]
fn the_generated_keystore_password_survives_pipefail() {
let marker = "generate_keystore_password() {";
let body = BUILD_SCRIPT
.split_once(marker)
.and_then(|(_, rest)| rest.split_once("\n}\n"))
.map(|(body, _)| body)
.expect("twa-build must define generate_keystore_password()");
let program = format!(
"set -euo pipefail\n{marker}{body}\n}}\nfor _ in $(seq 20); do generate_keystore_password; echo; done\n"
);
let out = std::process::Command::new("bash")
.arg("-c")
.arg(&program)
.output()
.expect("running bash");
assert_eq!(
out.status.code(),
Some(0),
"password generation exited {:?}: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("password must be ascii");
let passwords: Vec<&str> = stdout.lines().collect();
assert_eq!(passwords.len(), 20);
for password in passwords {
assert_eq!(password.len(), 32, "wrong length: {password:?}");
assert!(
password.chars().all(|c| c.is_ascii_alphanumeric()),
"not alphanumeric: {password:?}"
);
}
}
#[cfg(unix)]
const FAKE_SDKMANAGER: &str = r#"#!/usr/bin/env bash
PRG="$0"
while [ -h "$PRG" ]; do
ls=$(ls -ld "$PRG")
link=${ls#*' -> '}
case $link in
/*) PRG="$link" ;;
*) PRG="$(dirname "$PRG")/$link" ;;
esac
done
APP_HOME=$(cd "$(dirname "$PRG")/.." && pwd -P)
if [ ! -f "$APP_HOME/lib/sdkmanager-classpath.jar" ]; then
echo "Error: Could not find or load main class com.android.sdklib.tool.sdkmanager.SdkManagerCli" >&2
exit 1
fi
echo "12.0"
"#;
#[cfg(unix)]
fn fake_android_sdk() -> tempfile::TempDir {
use std::os::unix::fs::PermissionsExt;
let sdk = tempfile::tempdir().unwrap();
let tools = sdk.path().join("cmdline-tools").join("latest");
std::fs::create_dir_all(tools.join("bin")).unwrap();
std::fs::create_dir_all(tools.join("lib")).unwrap();
std::fs::write(tools.join("lib").join("sdkmanager-classpath.jar"), b"jar").unwrap();
let launcher = tools.join("bin").join("sdkmanager");
std::fs::write(&launcher, FAKE_SDKMANAGER).unwrap();
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
sdk
}
#[cfg(unix)]
fn bash(program: &str) -> std::process::Output {
std::process::Command::new("bash")
.arg("-c")
.arg(program)
.output()
.expect("running bash")
}
#[cfg(unix)]
fn link_android_bin(sdk: &Path) -> std::process::Output {
bash(&format!(
"set -euo pipefail\nexport ANDROID_SDK_ROOT='{}'\n. '{}'\nensure_android_bin_links\n",
sdk.display(),
concat!(env!("CARGO_MANIFEST_DIR"), "/bin/setup-twa"),
))
}
#[cfg(unix)]
fn sdkmanager_version(sdk: &Path) -> std::process::Output {
bash(&format!("'{}/bin/sdkmanager' --version", sdk.display()))
}
#[cfg(unix)]
#[test]
fn the_sdk_bin_dir_keeps_the_launchers_working() {
let sdk = fake_android_sdk();
let broken = bash(&format!(
"ln -s cmdline-tools/latest/bin '{}/bin'",
sdk.path().display()
));
assert!(broken.status.success());
let out = sdkmanager_version(sdk.path());
assert!(
!out.status.success() && String::from_utf8_lossy(&out.stderr).contains("SdkManagerCli"),
"a directory symlink must break the launcher, or this test proves nothing"
);
let linked = link_android_bin(sdk.path());
assert!(
linked.status.success(),
"link step failed: {}",
String::from_utf8_lossy(&linked.stderr)
);
assert!(!sdk.path().join("bin").is_symlink());
let out = sdkmanager_version(sdk.path());
assert!(
out.status.success(),
"sdkmanager failed through {}/bin: {}",
sdk.path().display(),
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "12.0");
}
#[cfg(unix)]
#[test]
fn re_running_setup_repairs_a_stale_link() {
let sdk = fake_android_sdk();
assert!(link_android_bin(sdk.path()).status.success());
let link = sdk.path().join("bin").join("sdkmanager");
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink("cmdline-tools/latest/bin/sdkmanager", &link).unwrap();
assert!(!sdkmanager_version(sdk.path()).status.success());
assert!(link_android_bin(sdk.path()).status.success());
assert!(sdkmanager_version(sdk.path()).status.success());
}
#[test]
fn artifact_resolution_prefers_the_built_copy() {
let dir = tempfile::tempdir().unwrap();
let built = apk_path(dir.path());
assert_eq!(
resolve_artifact(built.clone(), CHECKOUT_APK_PATH),
PathBuf::from(CHECKOUT_APK_PATH)
);
std::fs::create_dir_all(built.parent().unwrap()).unwrap();
std::fs::write(&built, b"apk").unwrap();
assert_eq!(resolve_artifact(built.clone(), CHECKOUT_APK_PATH), built);
}
}