Package the Rust mobile core and generated Kotlin API for Android (#92)
Dependency security audit / rustsec (push) Failing after 24s
Dependency security audit / rustsec (push) Failing after 24s
This commit is contained in:
1 parent
e079b82b15
commit
fcb36a0684
42 files changed
+1586
-172
No files matched your search
@@ -1,6 +1,7 @@
|
||||
[package]
|
||||
name = "ironstorage-android-build"
|
||||
description = "Android fixture generation and architecture boundary checks"
|
||||
description = "Android native packaging, binding generation and architecture gates"
|
||||
default-run = "ironstorage-android-build"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
@@ -11,3 +12,10 @@ publish = false
|
||||
[dependencies]
|
||||
ironstorage.workspace = true
|
||||
regex.workspace = true
|
||||
uniffi = { workspace = true, features = ["cli"] }
|
||||
object = { version = "0.36.7", default-features = false, features = ["read", "elf"] }
|
||||
zip = { version = "2.4.2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[[bin]]
|
||||
name = "ironstorage-uniffi-bindgen"
|
||||
path = "src/bindgen.rs"
|
||||
@@ -0,0 +1,5 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
fn main() {
|
||||
uniffi::uniffi_bindgen_main();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Narrow, version-checked hardening of UniFFI 0.32's Kotlin diagnostics.
|
||||
//! Records remain immutable data classes with unchanged wire layouts/checksums.
|
||||
|
||||
use std::{error::Error, fs, path::Path};
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
pub fn prepare(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
if !input.is_dir() || input.is_symlink() || output.is_symlink() {
|
||||
return Err("binding input and output must be distinct directories".into());
|
||||
}
|
||||
let input = input.canonicalize()?;
|
||||
let destination = if output.exists() {
|
||||
output.canonicalize()?
|
||||
} else {
|
||||
output
|
||||
.parent()
|
||||
.ok_or("output needs a parent")?
|
||||
.canonicalize()?
|
||||
.join(output.file_name().ok_or("output needs a name")?)
|
||||
};
|
||||
if input.starts_with(&destination) || destination.starts_with(&input) {
|
||||
return Err("binding input/output directories must not overlap".into());
|
||||
}
|
||||
if output.exists() {
|
||||
fs::remove_dir_all(output)?;
|
||||
}
|
||||
copy(&input, output)
|
||||
}
|
||||
|
||||
fn copy(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
fs::create_dir_all(output)?;
|
||||
let mut found = false;
|
||||
for entry in fs::read_dir(input)? {
|
||||
let entry = entry?;
|
||||
let destination = output.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy(&entry.path(), &destination)?;
|
||||
found = true;
|
||||
} else if entry.file_type()?.is_file()
|
||||
&& entry.path().extension().is_some_and(|ext| ext == "kt")
|
||||
{
|
||||
let source = fs::read_to_string(entry.path())?;
|
||||
fs::write(destination, harden_source(&source)?)?;
|
||||
found = true;
|
||||
} else {
|
||||
return Err("unexpected file in generated Kotlin bindings".into());
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err("UniFFI produced no Kotlin bindings".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn harden_source(source: &str) -> Result<String, Box<dyn Error>> {
|
||||
let internal = "class InternalException(message: String) : kotlin.Exception(message)";
|
||||
let callback = "val err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }";
|
||||
if !source.contains(internal) || source.matches(callback).count() != 2 {
|
||||
return Err("UniFFI unexpected-error template changed; review secret redaction".into());
|
||||
}
|
||||
let source = source.replace(internal, "class InternalException(@Suppress(\"UNUSED_PARAMETER\") message: String) : kotlin.Exception(\"Native Rust failure ([REDACTED])\")")
|
||||
.replace(callback, "val err = \"Native callback failed ([REDACTED])\"");
|
||||
let records = Regex::new(r"(?ms)^data class (\w+) \((.*?)\n\)([^\{]*)\{\n")?;
|
||||
if !records.is_match(&source) {
|
||||
return Err("UniFFI record template changed; review diagnostic hardening".into());
|
||||
}
|
||||
let source = records.replace_all(&source, |captures: ®ex::Captures<'_>| {
|
||||
format!(
|
||||
"{} override fun toString(): String = \"{}([REDACTED])\"\n",
|
||||
&captures[0], &captures[1]
|
||||
)
|
||||
});
|
||||
let errors = Regex::new(r#"(?m)(override val message\n\s*)get\(\) = ".*"$"#)?;
|
||||
// Kotlin Exception.message is reserved; keep the Rust field/wire checksum unchanged.
|
||||
let source = source
|
||||
.replace(
|
||||
"val `message`: kotlin.String",
|
||||
"val `rustMessage`: kotlin.String",
|
||||
)
|
||||
.replace("value.`message`", "value.`rustMessage`");
|
||||
let source = errors.replace_all(
|
||||
&source,
|
||||
"${1}get() = \"Rust operation failed ([REDACTED])\"",
|
||||
);
|
||||
let contract = "throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\")";
|
||||
let checksum = "throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\")";
|
||||
if !source.contains(contract) || !source.contains(checksum) {
|
||||
return Err(
|
||||
"UniFFI integrity template changed; checksum checks must remain enabled".into(),
|
||||
);
|
||||
}
|
||||
let checksum_guard = Regex::new(
|
||||
r#"if \(lib\.(\w+)\(\) != (-?\d+)\) \{\s*throw RuntimeException\("UniFFI API checksum mismatch: try cleaning and rebuilding your project"\)\s*\}"#,
|
||||
)?;
|
||||
let contract_guard = Regex::new(
|
||||
r#"if \(bindings_contract_version != scaffolding_contract_version\) \{\s*throw RuntimeException\("UniFFI contract version mismatch: try cleaning and rebuilding your project"\)\s*\}"#,
|
||||
)?;
|
||||
if !checksum_guard.is_match(&source) || !contract_guard.is_match(&source) {
|
||||
return Err(
|
||||
"UniFFI integrity guard changed; refusing to generate unchecked bindings".into(),
|
||||
);
|
||||
}
|
||||
let output = checksum_guard.replace_all(&source, "de.rfc1437.ironstorage.bridge.NativeBindingChecks.requireMatch(lib.${1}().toLong(), ${2}.toLong(), de.rfc1437.ironstorage.bridge.NativeFailureKind.ChecksumMismatch)");
|
||||
let output = contract_guard.replace_all(&output, "de.rfc1437.ironstorage.bridge.NativeBindingChecks.requireMatch(scaffolding_contract_version.toLong(), bindings_contract_version.toLong(), de.rfc1437.ironstorage.bridge.NativeFailureKind.ContractMismatch)").into_owned();
|
||||
if output.contains(contract) || output.contains(checksum) {
|
||||
return Err("UniFFI integrity guard was not translated completely".into());
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SOURCE: &str = "data class Secret (\n val value: String\n)\n{\n companion object\n}\nclass Failure {\n override val message\n get() = \"value=${value}\"\n}\nif (bindings_contract_version != scaffolding_contract_version) { throw RuntimeException(\"UniFFI contract version mismatch: try cleaning and rebuilding your project\") }\nif (lib.checksum() != 1234) { throw RuntimeException(\"UniFFI API checksum mismatch: try cleaning and rebuilding your project\") }\nclass InternalException(message: String) : kotlin.Exception(message)\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\nval err = try { e.stackTraceToString() } catch(_: Throwable) { \"\" }\n";
|
||||
|
||||
#[test]
|
||||
fn redacts_record_and_error_diagnostics_without_changing_fields_or_checksums() {
|
||||
let hardened = harden_source(SOURCE).unwrap();
|
||||
assert!(hardened.contains("val value: String"));
|
||||
assert!(hardened.contains("Secret([REDACTED])"));
|
||||
assert!(!hardened.contains("get() = \"value="));
|
||||
assert!(hardened.contains("requireMatch(lib.checksum().toLong(), 1234.toLong()"));
|
||||
assert!(hardened.contains("NativeFailureKind.ChecksumMismatch"));
|
||||
assert!(hardened.contains("NativeFailureKind.ContractMismatch"));
|
||||
assert!(!hardened.contains("stackTraceToString"));
|
||||
assert!(!hardened.contains("kotlin.Exception(message)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_if_upstream_templates_or_integrity_checks_change() {
|
||||
assert!(harden_source("data class Secret(val value: String)").is_err());
|
||||
assert!(harden_source(&SOURCE.replace("UniFFI API checksum mismatch", "changed")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_rust_message_fields_without_colliding_with_kotlin_exception_message() {
|
||||
let source = SOURCE.replace("val value: String", "val `message`: kotlin.String")
|
||||
+ "FfiConverterString.write(value.`message`, buf)";
|
||||
let hardened = harden_source(&source).unwrap();
|
||||
assert!(hardened.contains("val `rustMessage`: kotlin.String"));
|
||||
assert!(hardened.contains("FfiConverterString.write(value.`rustMessage`, buf)"));
|
||||
assert!(hardened.contains("override val message"));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
use ironstorage::mobile::{MobileShell, MobileShellState};
|
||||
use std::{env, error::Error, fs, path::Path};
|
||||
|
||||
mod bindings;
|
||||
mod native;
|
||||
mod toolchain;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<_> = env::args_os().collect();
|
||||
match args.as_slice() {
|
||||
@@ -12,9 +16,38 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
fs::write(path, fixtures())?;
|
||||
}
|
||||
[_, command, path] if command == "check" => check(Path::new(path))?,
|
||||
[_, command, input, output] if command == "prepare-bindings" => {
|
||||
bindings::prepare(Path::new(input), Path::new(output))?;
|
||||
}
|
||||
[_, command, input, output] if command == "package-native" => {
|
||||
native::package_directory(Path::new(input), Path::new(output))?;
|
||||
}
|
||||
[_, command, path, abi] if command == "audit-elf" => {
|
||||
native::audit_elf(&fs::read(path)?, abi.to_str().ok_or("invalid ABI")?)?;
|
||||
}
|
||||
[_, command, path, abi] if command == "audit-apk-split" => {
|
||||
native::audit_split(Path::new(path), abi.to_str().ok_or("invalid ABI")?)?;
|
||||
}
|
||||
[_, command, paths @ ..] if command == "audit-package" && !paths.is_empty() => {
|
||||
for path in paths {
|
||||
native::audit_package(Path::new(path))?;
|
||||
}
|
||||
}
|
||||
[_, command, directory] if command == "audit-native" => {
|
||||
native::audit_directory(Path::new(directory))?
|
||||
}
|
||||
[_, command, cargo, rust, ndk, ndk_version, cargo_ndk] if command == "check-toolchain" => {
|
||||
toolchain::check(
|
||||
Path::new(cargo),
|
||||
rust.to_str().ok_or("invalid Rust version")?,
|
||||
Path::new(ndk),
|
||||
ndk_version.to_str().ok_or("invalid NDK version")?,
|
||||
cargo_ndk.to_str().ok_or("invalid cargo-ndk version")?,
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
"usage: ironstorage-android-build <fixtures output.kt | check android-dir>".into(),
|
||||
"usage: ironstorage-android-build <fixtures output.kt | check android-dir | prepare-bindings input output | package-native input output | audit-elf path abi | audit-native jniLibs | audit-apk-split apk abi | audit-package apk-or-aab | check-toolchain cargo rust ndk ndk-version cargo-ndk-version>".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! Verify every packaged native library, including JNA, against the ABI/page contract.
|
||||
|
||||
use std::{collections::BTreeMap, error::Error, fs::File, io::Read, path::Path};
|
||||
|
||||
use object::{Architecture, BinaryFormat, Object, ObjectSection, ObjectSegment, ObjectSymbol};
|
||||
|
||||
const PAGE_SIZE: u64 = 16_384;
|
||||
const ABIS: [&str; 3] = ["arm64-v8a", "armeabi-v7a", "x86_64"];
|
||||
|
||||
pub fn package_directory(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
audit_directory(input)?;
|
||||
let input = input.canonicalize()?;
|
||||
let output = if output.exists() {
|
||||
if output.is_symlink() {
|
||||
return Err("native output must not be a symlink".into());
|
||||
}
|
||||
output.canonicalize()?
|
||||
} else {
|
||||
output
|
||||
.parent()
|
||||
.ok_or("output needs a parent")?
|
||||
.canonicalize()?
|
||||
.join(output.file_name().ok_or("output needs a name")?)
|
||||
};
|
||||
if input.starts_with(&output) || output.starts_with(&input) {
|
||||
return Err("native input/output must not overlap".into());
|
||||
}
|
||||
if output.exists() {
|
||||
std::fs::remove_dir_all(&output)?;
|
||||
}
|
||||
for abi in ABIS {
|
||||
std::fs::create_dir_all(output.join(abi))?;
|
||||
// cargo-ndk also emits dependency cdylibs; ship only the Android boundary.
|
||||
std::fs::copy(
|
||||
input.join(abi).join("libironstorage_android.so"),
|
||||
output.join(abi).join("libironstorage_android.so"),
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn audit_directory(directory: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let mut expected = None;
|
||||
for abi in ABIS {
|
||||
let bytes = std::fs::read(directory.join(abi).join("libironstorage_android.so"))?;
|
||||
audit_elf(&bytes, abi)?;
|
||||
let library = object::File::parse(bytes.as_slice())?;
|
||||
let mut metadata = BTreeMap::new();
|
||||
for symbol in library.symbols() {
|
||||
let name = symbol.name()?;
|
||||
if name.starts_with("UNIFFI_META_") {
|
||||
let section = library.section_by_index(
|
||||
symbol
|
||||
.section_index()
|
||||
.ok_or("UniFFI metadata has no section")?,
|
||||
)?;
|
||||
let value = section
|
||||
.data_range(symbol.address(), symbol.size())?
|
||||
.ok_or("UniFFI metadata is outside its section")?;
|
||||
metadata.insert(name.to_owned(), value.to_vec());
|
||||
}
|
||||
}
|
||||
if metadata.is_empty() {
|
||||
return Err("native library has no UniFFI metadata".into());
|
||||
}
|
||||
if let Some(expected) = &expected {
|
||||
if expected != &metadata {
|
||||
return Err("UniFFI metadata differs across Android ABIs".into());
|
||||
}
|
||||
} else {
|
||||
expected = Some(metadata);
|
||||
}
|
||||
}
|
||||
println!("Verified three native ABIs, 16 KB ELF alignment and identical UniFFI metadata");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn audit_elf(bytes: &[u8], abi: &str) -> Result<(), Box<dyn Error>> {
|
||||
let expected = match abi {
|
||||
"arm64-v8a" => Architecture::Aarch64,
|
||||
"armeabi-v7a" => Architecture::Arm,
|
||||
"x86_64" => Architecture::X86_64,
|
||||
_ => return Err("unapproved native ABI".into()),
|
||||
};
|
||||
let library = object::File::parse(bytes)?;
|
||||
if library.format() != BinaryFormat::Elf || library.architecture() != expected {
|
||||
return Err("native library architecture does not match its ABI directory".into());
|
||||
}
|
||||
let mut segments = 0;
|
||||
for segment in library.segments() {
|
||||
let (offset, _) = segment.file_range();
|
||||
if segment.align() < PAGE_SIZE || segment.address() % PAGE_SIZE != offset % PAGE_SIZE {
|
||||
return Err("native ELF LOAD segment is not compatible with 16 KB pages".into());
|
||||
}
|
||||
segments += 1;
|
||||
}
|
||||
if segments == 0 {
|
||||
return Err("native ELF has no loadable segments".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn audit_package(path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
audit_package_abis(path, &ABIS)
|
||||
}
|
||||
|
||||
pub fn audit_split(path: &Path, abi: &str) -> Result<(), Box<dyn Error>> {
|
||||
if !ABIS.contains(&abi) || !path.extension().is_some_and(|ext| ext == "apk") {
|
||||
return Err("expected an APK split for an approved ABI".into());
|
||||
}
|
||||
audit_package_abis(path, &[abi])
|
||||
}
|
||||
|
||||
fn audit_package_abis(path: &Path, abis: &[&str]) -> Result<(), Box<dyn Error>> {
|
||||
let apk = path.extension().is_some_and(|ext| ext == "apk");
|
||||
if !apk && !path.extension().is_some_and(|ext| ext == "aab") {
|
||||
return Err("expected an APK or AAB".into());
|
||||
}
|
||||
let mut archive = zip::ZipArchive::new(File::open(path)?)?;
|
||||
let mut libraries = BTreeMap::<String, Vec<String>>::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive.by_index(index)?;
|
||||
let name = file.name().to_owned();
|
||||
let native = name.strip_prefix(if apk { "lib/" } else { "base/lib/" });
|
||||
let Some((abi, library)) = native.and_then(|name| name.split_once('/')) else {
|
||||
continue;
|
||||
};
|
||||
if !library.ends_with(".so") {
|
||||
continue;
|
||||
}
|
||||
if library == "libironstorage_apple.so" {
|
||||
return Err("Android package includes the redundant Apple boundary library".into());
|
||||
}
|
||||
if !abis.contains(&abi) || library.contains('/') {
|
||||
return Err("package contains an unapproved native library path".into());
|
||||
}
|
||||
if apk
|
||||
&& (file.compression() != zip::CompressionMethod::Stored
|
||||
|| file.data_start() % PAGE_SIZE != 0)
|
||||
{
|
||||
return Err(format!("{name}: APK native entry is not stored/aligned to 16 KB").into());
|
||||
}
|
||||
if file.size() > 512 * 1024 * 1024 {
|
||||
return Err("native library exceeds the package audit bound".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)?;
|
||||
audit_elf(&bytes, abi).map_err(|error| format!("{name}: {error}"))?;
|
||||
libraries
|
||||
.entry(abi.to_owned())
|
||||
.or_default()
|
||||
.push(library.to_owned());
|
||||
}
|
||||
for abi in abis {
|
||||
let found = libraries
|
||||
.get(*abi)
|
||||
.ok_or("package is missing a supported ABI")?;
|
||||
for required in ["libironstorage_android.so", "libjnidispatch.so"] {
|
||||
if !found.iter().any(|name| name == required) {
|
||||
return Err(format!("{abi}: missing {required}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Verified all packaged native libraries for {abis:?} in {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn elf(alignment: u64, address: u64) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 120];
|
||||
bytes[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
|
||||
bytes[16..18].copy_from_slice(&3_u16.to_le_bytes());
|
||||
bytes[18..20].copy_from_slice(&183_u16.to_le_bytes());
|
||||
bytes[20..24].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[32..40].copy_from_slice(&64_u64.to_le_bytes());
|
||||
bytes[52..54].copy_from_slice(&64_u16.to_le_bytes());
|
||||
bytes[54..56].copy_from_slice(&56_u16.to_le_bytes());
|
||||
bytes[56..58].copy_from_slice(&1_u16.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(&1_u32.to_le_bytes());
|
||||
bytes[68..72].copy_from_slice(&4_u32.to_le_bytes());
|
||||
bytes[80..88].copy_from_slice(&address.to_le_bytes());
|
||||
bytes[96..104].copy_from_slice(&120_u64.to_le_bytes());
|
||||
bytes[104..112].copy_from_slice(&120_u64.to_le_bytes());
|
||||
bytes[112..120].copy_from_slice(&alignment.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_real_load_alignment_congruence_and_directory_abi() {
|
||||
assert!(audit_elf(&elf(PAGE_SIZE, 0), "arm64-v8a").is_ok());
|
||||
assert!(audit_elf(&elf(4096, 0), "arm64-v8a").is_err());
|
||||
assert!(audit_elf(&elf(PAGE_SIZE, 4), "arm64-v8a").is_err());
|
||||
assert!(audit_elf(&elf(PAGE_SIZE, 0), "x86_64").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_elf_unapproved_abi_and_truncated_headers() {
|
||||
assert!(audit_elf(b"not an ELF", "arm64-v8a").is_err());
|
||||
assert!(audit_elf(b"\x7fELF", "x86_64").is_err());
|
||||
assert!(audit_elf(&[], "mips").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Build-time tools only; application crates never launch these processes.
|
||||
#![allow(
|
||||
clippy::disallowed_types,
|
||||
reason = "build-only verification of Cargo/cargo-ndk, never linked into the application"
|
||||
)]
|
||||
use std::{error::Error, fs, path::Path, process::Command};
|
||||
|
||||
pub fn check(
|
||||
cargo: &Path,
|
||||
rust: &str,
|
||||
ndk: &Path,
|
||||
ndk_version: &str,
|
||||
cargo_ndk: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
version(
|
||||
cargo,
|
||||
&[&format!("+{rust}"), "--version"],
|
||||
&format!("cargo {rust}"),
|
||||
)?;
|
||||
version(
|
||||
cargo,
|
||||
&[&format!("+{rust}"), "ndk", "--version"],
|
||||
&format!("cargo-ndk {cargo_ndk}"),
|
||||
)?;
|
||||
let properties = fs::read_to_string(ndk.join("source.properties"))?;
|
||||
if !properties
|
||||
.lines()
|
||||
.any(|line| line.trim() == format!("Pkg.Revision = {ndk_version}"))
|
||||
{
|
||||
return Err("Android NDK revision does not match the version catalog".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn version(command: &Path, args: &[&str], expected: &str) -> Result<(), Box<dyn Error>> {
|
||||
let result = Command::new(command).args(args).output()?;
|
||||
let value = std::str::from_utf8(&result.stdout)?;
|
||||
if !result.status.success() || !matches_version(value, expected) {
|
||||
return Err(format!("build tool must report {expected}").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_version(value: &str, expected: &str) -> bool {
|
||||
value
|
||||
.trim()
|
||||
.strip_prefix(expected)
|
||||
.is_some_and(|suffix| suffix.is_empty() || suffix.starts_with(' '))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rejects_nearby_and_failed_version_output() {
|
||||
assert!(matches_version("cargo 1.97.1 (abc)\n", "cargo 1.97.1"));
|
||||
assert!(matches_version("cargo-ndk 4.1.2\n", "cargo-ndk 4.1.2"));
|
||||
assert!(!matches_version("cargo-ndk 4.1.20", "cargo-ndk 4.1.2"));
|
||||
assert!(!matches_version("", "cargo-ndk 4.1.2"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user