Find the SDK without an environment, and say what broke
A service manager hands its process a scrubbed environment -- measured in
the Gentoo guest, an OpenRC user service gets 19 variables and neither
ANDROID_HOME nor ANDROID_NDK_HOME among them -- so the tool lookups have
to stand on their own. Two ways they did not:
- $ANDROID_HOME was taken as given. This machine exports a system-wide
/opt/android-sdk whose build-tools/36.0.0 holds nothing but
package.xml, so the download failed with "failed to spawn
.../zipalign", naming the tool rather than the root that was wrong.
Each lookup now takes the first candidate that actually contains what
it needs, and names every place it looked.
- The NDK search took the newest child of ~/Android and filtered it
afterwards, which can only reject that one directory. It worked by
the luck of `Sdk` sorting before `android-ndk-r27c`; a lowercase
neighbour would have hidden an NDK that was sitting right there.
newest_child_where filters before taking the max.
And the failure had nowhere to go: ApiError::Internal answered with an
empty body, so a missing NDK reached the phone as "Server returned HTTP
500" while the message naming the path stayed in a log on a machine the
person holding the phone cannot see. It now answers with the same
{err:#} chain it logs. Every route is behind the bearer token of a
device somebody enrolled themselves, so there is no third party to
withhold it from.
Diagnosis from the tdep-survey session, which measured the OpenRC
environment and the /opt/android-sdk contents.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c35f84a373
commit
0e9c7842f7
3 files changed
+244
-31
No files matched your search
+43
-3
@@ -158,10 +158,23 @@ impl IntoResponse for ApiError {
|
||||
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
Self::RangeNotSatisfiable => StatusCode::RANGE_NOT_SATISFIABLE,
|
||||
Self::Internal(err) => {
|
||||
// The only variant whose real cause isn't safe to hand back
|
||||
// verbatim, and the only one worth a log line.
|
||||
// The one variant worth a log line, because it is the one
|
||||
// nobody wrote for a reader. It still answers with its
|
||||
// message: this used to be a bare 500 with an empty body,
|
||||
// on the grounds that an internal cause is not safe to
|
||||
// hand back -- but every route here is behind the bearer
|
||||
// token of a device somebody enrolled themselves, so
|
||||
// there is no third party to be careful of, and what
|
||||
// these actually say is which tool on the build machine
|
||||
// could not be found or would not run. Withholding that
|
||||
// left the phone reporting "HTTP 500" for a missing NDK,
|
||||
// with the only explanation in a log on a machine the
|
||||
// person holding it cannot see.
|
||||
//
|
||||
// The whole `{err:#}` chain rather than the top context,
|
||||
// because the tail is the part that names the path.
|
||||
tracing::error!("{err:#}");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{err:#}")).into_response();
|
||||
}
|
||||
};
|
||||
(status, self.to_string()).into_response()
|
||||
@@ -1576,3 +1589,30 @@ async fn serve_apk(
|
||||
.body(Body::from_stream(stream))
|
||||
.expect("response with a streaming body is valid"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A tool that could not be found on the build machine is a
|
||||
/// configuration problem there, and the person who can fix it is
|
||||
/// holding the phone rather than reading this server's log. The
|
||||
/// message travelled nowhere for a while -- a bare 500 with an empty
|
||||
/// body -- and the app, which shows a failure's body whenever there
|
||||
/// is one, could only say "Server returned HTTP 500".
|
||||
#[tokio::test]
|
||||
async fn an_internal_failure_still_tells_the_phone_what_broke() {
|
||||
let err = anyhow::anyhow!("llvm-strip not found -- looked under /home/x/Android")
|
||||
.context("stripping app-dioxus");
|
||||
let response = ApiError::Internal(err).into_response();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("utf-8");
|
||||
// The whole chain: the tail is the half that names the path.
|
||||
assert!(body.contains("stripping app-dioxus"), "{body}");
|
||||
assert!(body.contains("/home/x/Android"), "{body}");
|
||||
}
|
||||
}
|
||||
+168
-27
@@ -23,42 +23,105 @@ fn home_dir() -> PathBuf {
|
||||
std::env::home_dir().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Honors `$ANDROID_HOME`/`$ANDROID_SDK_ROOT` before falling back to
|
||||
/// Android Studio's default location on Linux, so this works on a machine
|
||||
/// that keeps its SDK somewhere else without needing a flag.
|
||||
fn sdk_root() -> PathBuf {
|
||||
std::env::var_os("ANDROID_HOME")
|
||||
.or_else(|| std::env::var_os("ANDROID_SDK_ROOT"))
|
||||
/// Everywhere an SDK might be, best guess first: what the environment
|
||||
/// says, then Android Studio's default location on Linux.
|
||||
///
|
||||
/// Candidates rather than one answer, because **an `$ANDROID_HOME` that
|
||||
/// exists is not the same thing as one with the tools in it**, and taking
|
||||
/// it as the answer is a failure that names the wrong thing. This machine
|
||||
/// exports a system-wide `ANDROID_HOME=/opt/android-sdk` holding a
|
||||
/// `build-tools/36.0.0` with no `aapt2`, `zipalign` or `apksigner` under
|
||||
/// it at all; a server that inherited that env -- which a service started
|
||||
/// by a service manager rather than from a shell is the likely way to --
|
||||
/// answered a download with `failed to spawn
|
||||
/// /opt/android-sdk/build-tools/36.0.0/zipalign`, blaming the tool for a
|
||||
/// root that should never have been chosen. So each lookup below takes
|
||||
/// the first candidate that actually holds what it needs, and names every
|
||||
/// place it looked when none does.
|
||||
///
|
||||
/// Listing places nobody named is only safe *because* of that check, and
|
||||
/// it is what makes this work from a service: a service manager hands its
|
||||
/// process a scrubbed environment -- measured in the Gentoo guest, an
|
||||
/// OpenRC user service gets 19 variables and neither `$ANDROID_HOME` nor
|
||||
/// `$ANDROID_NDK_HOME` among them, and a systemd user unit inherits an
|
||||
/// equally bare one unless somebody has imported theirs. `HOME` does
|
||||
/// survive, which is why the home-relative candidates are the ones that
|
||||
/// carry this.
|
||||
fn sdk_roots() -> Vec<PathBuf> {
|
||||
["ANDROID_HOME", "ANDROID_SDK_ROOT"]
|
||||
.into_iter()
|
||||
.filter_map(std::env::var_os)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir().join("Android/Sdk"))
|
||||
.chain([
|
||||
home_dir().join("Android/Sdk"),
|
||||
PathBuf::from("/opt/android-sdk"),
|
||||
])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Highest-versioned entry directly under `dir`, by plain name sort --
|
||||
/// good enough for SDK/NDK release directories, which sort correctly as
|
||||
/// strings within a single major version scheme.
|
||||
fn newest_child(dir: &Path) -> Option<PathBuf> {
|
||||
/// Highest-versioned entry directly under `dir` that `keep` accepts, by
|
||||
/// plain name sort -- good enough for SDK/NDK release directories, which
|
||||
/// sort correctly as strings within a single major version scheme.
|
||||
///
|
||||
/// The filter is applied *before* the max is taken, which is the whole
|
||||
/// reason it is a parameter rather than something the caller does to the
|
||||
/// answer: filtering afterwards can only reject the one directory that
|
||||
/// happened to sort last, so a single unrelated neighbour hides an
|
||||
/// install that is sitting right there. `~/Android` holds `Sdk` beside
|
||||
/// `android-ndk-r27c` and worked only by the luck of `S` sorting before
|
||||
/// `a`; a lowercase `sdk`, an `avd`, or a second toolchain would have
|
||||
/// made the NDK undiscoverable while the error said "install one".
|
||||
fn newest_child_where(dir: &Path, keep: impl Fn(&Path) -> bool) -> Option<PathBuf> {
|
||||
let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.filter(|path| path.is_dir() && keep(path))
|
||||
.collect();
|
||||
children.sort();
|
||||
children.pop()
|
||||
}
|
||||
|
||||
/// The newest installed `build-tools/<version>/` directory, which is where
|
||||
/// `aapt2`, `zipalign` and `apksigner` live.
|
||||
/// What this server runs out of a build-tools directory, and therefore
|
||||
/// what makes one usable. Named once here rather than at the three call
|
||||
/// sites, so a directory cannot be chosen for a tool nobody checked was
|
||||
/// in it.
|
||||
const BUILD_TOOLS: [&str; 3] = ["aapt2", "zipalign", "apksigner"];
|
||||
|
||||
/// The newest `<version>/` directory under any of `bases` that has every
|
||||
/// one of [`BUILD_TOOLS`] in it, best base first.
|
||||
///
|
||||
/// Its own function so the rule can be exercised with directories rather
|
||||
/// than through an environment and a process-wide cache.
|
||||
fn newest_usable_build_tools(bases: &[PathBuf]) -> Option<PathBuf> {
|
||||
bases.iter().find_map(|base| {
|
||||
newest_child_where(base, |dir| {
|
||||
BUILD_TOOLS.iter().all(|tool| dir.join(tool).is_file())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest installed `build-tools/<version>/` directory that has all of
|
||||
/// [`BUILD_TOOLS`] in it, from the first SDK candidate that offers one.
|
||||
pub fn build_tools_dir() -> Result<&'static Path> {
|
||||
static DIR: OnceLock<Result<PathBuf, String>> = OnceLock::new();
|
||||
match DIR.get_or_init(|| {
|
||||
let base = sdk_root().join("build-tools");
|
||||
newest_child(&base).ok_or_else(|| {
|
||||
let bases: Vec<PathBuf> = sdk_roots()
|
||||
.iter()
|
||||
.map(|sdk| sdk.join("build-tools"))
|
||||
.collect();
|
||||
newest_usable_build_tools(&bases).ok_or_else(|| {
|
||||
format!(
|
||||
"no Android SDK build-tools found under {} -- install one \
|
||||
(`android sdk install build-tools/37.0.0`, or source \
|
||||
../app/android-env.sh, which does it for you)",
|
||||
base.display(),
|
||||
"no Android SDK build-tools with {} in them found -- looked under {}. \
|
||||
Install one (`android sdk install build-tools/37.0.0`, or source \
|
||||
../app/android-env.sh, which does it for you), or point $ANDROID_HOME \
|
||||
at an SDK that has one.",
|
||||
BUILD_TOOLS.join(", "),
|
||||
bases
|
||||
.iter()
|
||||
.map(|base| base.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
})
|
||||
}) {
|
||||
@@ -67,12 +130,10 @@ pub fn build_tools_dir() -> Result<&'static Path> {
|
||||
}
|
||||
}
|
||||
|
||||
/// No existence check of its own: [`build_tools_dir`] only ever answers
|
||||
/// with a directory it has already found every one of [`BUILD_TOOLS`] in.
|
||||
pub fn aapt2_path() -> Result<PathBuf> {
|
||||
let path = build_tools_dir()?.join("aapt2");
|
||||
if !path.is_file() {
|
||||
bail!("aapt2 not found at {}", path.display());
|
||||
}
|
||||
Ok(path)
|
||||
Ok(build_tools_dir()?.join("aapt2"))
|
||||
}
|
||||
|
||||
/// `llvm-strip` ships with the NDK, not the SDK, so this looks in the two
|
||||
@@ -85,8 +146,13 @@ pub fn llvm_strip_path() -> Result<PathBuf> {
|
||||
let roots: Vec<PathBuf> = std::env::var_os("ANDROID_NDK_HOME")
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.chain(newest_child(&sdk_root().join("ndk")))
|
||||
.chain(newest_child(&home_dir().join("Android")).filter(|path| {
|
||||
.chain(
|
||||
sdk_roots()
|
||||
.iter()
|
||||
.filter_map(|sdk| newest_child_where(&sdk.join("ndk"), |_| true))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.chain(newest_child_where(&home_dir().join("Android"), |path| {
|
||||
path.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("android-ndk-"))
|
||||
}))
|
||||
@@ -149,3 +215,78 @@ pub fn run_checked(cmd: &mut std::process::Command) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(path: PathBuf) -> PathBuf {
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn build_tools(base: &Path, version: &str, tools: &[&str]) {
|
||||
let dir = dir(base.join(version));
|
||||
for tool in tools {
|
||||
std::fs::write(dir.join(tool), "#!/bin/sh\n").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The `~/Android` case: an SDK sitting beside the NDK is not the NDK,
|
||||
/// and it must not be able to hide it by sorting last. Filtering the
|
||||
/// single newest child instead of the children rejects that one
|
||||
/// directory and answers "nothing here" with the NDK in plain view.
|
||||
#[test]
|
||||
fn an_unrelated_neighbour_does_not_hide_the_newest_match() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
dir(home.path().join("android-ndk-r27c"));
|
||||
dir(home.path().join("sdk"));
|
||||
dir(home.path().join("zz-scratch"));
|
||||
|
||||
let found = newest_child_where(home.path(), |path| {
|
||||
path.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("android-ndk-"))
|
||||
});
|
||||
assert_eq!(found, Some(home.path().join("android-ndk-r27c")));
|
||||
}
|
||||
|
||||
/// What `$ANDROID_HOME=/opt/android-sdk` looks like on this machine:
|
||||
/// the directory is there and has a plausibly-named build-tools
|
||||
/// version in it, with none of the tools inside. Taking it produces a
|
||||
/// download that fails with "failed to spawn .../zipalign", naming
|
||||
/// the tool rather than the root that was wrong.
|
||||
#[test]
|
||||
fn a_build_tools_directory_without_the_tools_is_not_used() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let empty = dir(root.path().join("opt/android-sdk/build-tools"));
|
||||
build_tools(&empty, "36.0.0", &["package.xml"]);
|
||||
let real = dir(root.path().join("home/Android/Sdk/build-tools"));
|
||||
build_tools(&real, "37.0.0", &BUILD_TOOLS);
|
||||
|
||||
let bases = vec![empty, real.clone()];
|
||||
assert_eq!(
|
||||
newest_usable_build_tools(&bases),
|
||||
Some(real.join("37.0.0")),
|
||||
"the first candidate that exists is not the first that works"
|
||||
);
|
||||
}
|
||||
|
||||
/// And a half-populated one is no better than an empty one: every
|
||||
/// tool this server runs has to be in the directory it picks, or the
|
||||
/// failure lands on whichever one it happens to spawn first.
|
||||
#[test]
|
||||
fn every_tool_has_to_be_present_not_just_the_first() {
|
||||
let base = tempfile::tempdir().unwrap();
|
||||
build_tools(base.path(), "36.0.0", &["aapt2"]);
|
||||
assert_eq!(
|
||||
newest_usable_build_tools(&[base.path().to_path_buf()]),
|
||||
None
|
||||
);
|
||||
|
||||
build_tools(base.path(), "36.0.0", &BUILD_TOOLS);
|
||||
assert_eq!(
|
||||
newest_usable_build_tools(&[base.path().to_path_buf()]),
|
||||
Some(base.path().join("36.0.0")),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user