From 6aa0078fce83dd187f2d6f76a9cf406604334784 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 15:17:46 +0500 Subject: [PATCH 1/4] feat(debug-files): --type elf accepts directories of .so files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk directories recursively for .so/.so.dbg/.so.sym (+ --extension), reading libraries in place instead of requiring a pre-built zip. Multiple paths (dirs and/or zips) are collected first so the one-per-build-id preference spans all of them. Each zip gets its own extraction dir; an unreadable directory root fails instead of exiting 0; a missing path is exit 10. Closes #69 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- README.md | 2 +- src/cli/debug_files.rs | 178 ++++++++++++++++++++-------------------- src/symbols/elf.rs | 160 ++++++++++++++++++++++++++++++++++-- src/symbols/mod.rs | 4 +- tests/elf_upload.rs | 180 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 0980d3e..495165f 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The upload flow itself. ProGuard, Rust, ELF, dSYM, PDB, sourcemap, and Unity IL2 #### Android NDK β€” `--type elf` -Takes AGP's pre-built `native-debug-symbols.zip` (a directory is rejected; the Gradle plugin zips the intermediates folder first). Every library inside is uploaded as its own symbol, keyed by its GNU build-id β€” `--uuid` (the SDK's `BUILD_UUID`) is still required but only correlates logs. Built-in entry names are `.so`, `.so.dbg` (`ndk.debugSymbolLevel = 'FULL'`) and `.so.sym` (`'SYMBOL_TABLE'`, function names only β€” no `file:line`). A library without a build-id is warned about and skipped; two entries with the same build-id upload once, preferring the one with DWARF. The server dedups on the build-id, so switching a library from `SYMBOL_TABLE` to `FULL` needs `--force` to replace the stored symbols. +Takes one or more paths: AGP's pre-built `native-debug-symbols.zip`, and/or directories, which are walked recursively for libraries (e.g. `build/intermediates/merged_native_libs/`) and read in place β€” nothing is re-zipped. Every library found is uploaded as its own symbol, keyed by its GNU build-id β€” `--uuid` (the SDK's `BUILD_UUID`) is still required but only correlates logs. Built-in entry names are `.so`, `.so.dbg` (`ndk.debugSymbolLevel = 'FULL'`) and `.so.sym` (`'SYMBOL_TABLE'`, function names only β€” no `file:line`). A library without a build-id is warned about and skipped; two entries with the same build-id upload once (across all the given paths), preferring the one with DWARF. The server dedups on the build-id, so switching a library from `SYMBOL_TABLE` to `FULL` needs `--force` to replace the stored symbols. #### Unity IL2CPP β€” `--type il2cpp-linemap` diff --git a/src/cli/debug_files.rs b/src/cli/debug_files.rs index 4d9dd29..3fc10fd 100644 --- a/src/cli/debug_files.rs +++ b/src/cli/debug_files.rs @@ -17,7 +17,9 @@ const DEFAULT_ENDPOINT: &str = "https://api.bugsee.com"; pub enum DebugFilesCommand { /// Discover, package, and upload debug information files from one or more paths. Upload { - /// One or more directories or files to scan. + /// One or more directories or files to scan. For `--type elf`, a directory + /// is walked recursively for native libraries (e.g. AGP's + /// `merged_native_libs`), and a file is a `native-debug-symbols.zip`. #[arg(required = true)] paths: Vec, @@ -141,8 +143,9 @@ pub enum DebugFilesCommand { /// such as `.so.sym` work. It only widens the NAME match: each type's content check /// still applies to what it picks up. /// - /// Per type: `elf` β€” entries in the native-debug-symbols zip (built in: `.so`, - /// `.so.dbg`, `.so.sym`; each still needs a GNU build-id). `proguard` β€” files in a + /// Per type: `elf` β€” files in a scanned directory or entries in the + /// native-debug-symbols zip (built in: `.so`, `.so.dbg`, `.so.sym`; each still + /// needs a GNU build-id). `proguard` β€” files in a /// scanned directory (built in: `mapping*.txt`). `sourcemaps` β€” files in a scanned /// directory (built in: `.map`). `pdb` β€” files in a scanned directory, compared /// case-insensitively (built in: `.pdb`; each still needs the MSF container @@ -1078,15 +1081,9 @@ pub(crate) fn discover_dsyms(paths: &[PathBuf], extra: &ExtraSuffixes) -> Vec/out` directory -/// before invoking the CLI). +/// Upload native libraries from one or more inputs: AGP's +/// `native-debug-symbols.zip` and/or directories walked recursively. Each +/// library becomes its own symbol document keyed by its GNU build-id. #[allow(clippy::too_many_arguments)] /// Max concurrent per-`.so` register+upload pipelines. A native archive can hold /// dozens of libraries across ABIs; uploading them serially would dominate CI @@ -1151,18 +1148,6 @@ async fn run_elf_upload( if paths.is_empty() { return Err(input_not_found("no input paths supplied")); } - for p in paths { - if !p.is_file() { - // Directory-walk + re-pack is out of scope β€” the Gradle plugin - // pre-zips the intermediate-folder case before invoking the CLI. - return Err(input_invalid(format!( - "--type elf expects a pre-built zip file (typically AGP's \ - native-debug-symbols.zip); got {}", - p.display() - ))); - } - } - // `build_uuid` (the SDK's runtime BUILD_UUID) is NO LONGER the native // symbol identity β€” each `.so` is keyed by its OWN GNU build-id (one file β†’ // one symbol document β†’ one S3 object). The BUILD_UUID belongs to the @@ -1194,10 +1179,42 @@ async fn run_elf_upload( let mut full_already_existed = 0u32; let mut skipped_no_build_id = 0u32; - for archive in paths { - tracing::info!(path = %archive.display(), "processing native-debug-symbols archive"); - let work_dir = tempfile::tempdir()?; - let libs = elf::extract_libs(archive, work_dir.path(), extra)?; + // Collect from EVERY input first so the one-per-build-id preference spans + // paths (e.g. the same library under two variant folders), then upload once. + let work_dir = tempfile::tempdir()?; + let mut libs: Vec = Vec::new(); + for (n, input) in paths.iter().enumerate() { + tracing::info!(path = %input.display(), "scanning for native libraries"); + // Own extraction dir per input: zip entries are staged as `_`, + // and indices restart at 0 in every archive. + let input_dir = work_dir.path().join(n.to_string()); + std::fs::create_dir_all(&input_dir)?; + let found = elf::collect_libs(input, &input_dir, extra).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + input_not_found(format!("{} does not exist", input.display())) + } else if input.is_dir() { + input_invalid(format!("cannot scan {}: {e}", input.display())) + } else { + input_invalid(format!( + "--type elf expects a directory or a native-debug-symbols zip; \ + cannot read {}: {e}", + input.display() + )) + } + })?; + if found.is_empty() { + // Nothing even matched by name β€” the one case `--extension` fixes. + // Ahead of the dry-run exit: a dry run is how a caller checks this. + tracing::warn!( + path = %input.display(), + "no files named .so / .so.dbg / .so.sym (or an --extension suffix) β€” \ + nothing to upload. If your toolchain emits another suffix, pass it \ + with --extension" + ); + } + libs.extend(found); + } + { let libs = elf::keep_richest_per_build_id(libs); let total = libs.len(); @@ -1220,61 +1237,45 @@ async fn run_elf_upload( tracing::info!( libraries = total, uploadable = uploadable.len(), - "extracted native libraries" + "collected native libraries" ); if total == 0 { - // Nothing even matched by name β€” the one case `--extension` fixes. - // Ahead of the dry-run exit: a dry run is how a caller checks this. - tracing::warn!( - archive = %archive.display(), - "no entries named .so / .so.dbg / .so.sym (or an --extension suffix) β€” \ - nothing to upload. If your toolchain emits another suffix, pass it \ - with --extension" - ); - continue; - } - if dry_run { + // Already warned per input above. + } else if dry_run { tracing::info!( - "dry-run: would register + upload {} libraries from {}", - uploadable.len(), - archive.display() + "dry-run: would register + upload {} libraries", + uploadable.len() ); - continue; - } - if uploadable.is_empty() { - tracing::warn!( - archive = %archive.display(), - "no native libraries with a GNU build-id β€” nothing to upload" - ); - continue; - } - - let client = client.as_ref().expect("client constructed when !dry_run"); - - // Run each `.so`'s pack β†’ register β†’ dedup-or-PUT pipeline concurrently. - let outcomes: Vec<(bool, anyhow::Result)> = - futures_util::stream::iter(uploadable) - .map(|lib| { - let client = client.clone(); - let work = work_dir.path().to_path_buf(); - async move { - let symbol_table_only = lib.name.ends_with(".so.sym"); - let outcome = upload_one_so(&client, ctx, &lib, &work).await; - (symbol_table_only, outcome) - } - }) - .buffer_unordered(ELF_UPLOAD_CONCURRENCY) - .collect() - .await; - - for (symbol_table_only, outcome) in outcomes { - match outcome? { - presigned::Outcome::Uploaded => uploaded += 1, - presigned::Outcome::AlreadyExists => { - already_existed += 1; - if !symbol_table_only { - full_already_existed += 1; + } else if uploadable.is_empty() { + tracing::warn!("no native libraries with a GNU build-id β€” nothing to upload"); + } else { + let client = client.as_ref().expect("client constructed when !dry_run"); + + // Run each `.so`'s pack β†’ register β†’ dedup-or-PUT pipeline concurrently. + let outcomes: Vec<(bool, anyhow::Result)> = + futures_util::stream::iter(uploadable.into_iter().enumerate()) + .map(|(i, lib)| { + let client = client.clone(); + let work = work_dir.path().to_path_buf(); + async move { + let symbol_table_only = lib.name.ends_with(".so.sym"); + let outcome = upload_one_so(&client, ctx, &lib, i, &work).await; + (symbol_table_only, outcome) + } + }) + .buffer_unordered(ELF_UPLOAD_CONCURRENCY) + .collect() + .await; + + for (symbol_table_only, outcome) in outcomes { + match outcome? { + presigned::Outcome::Uploaded => uploaded += 1, + presigned::Outcome::AlreadyExists => { + already_existed += 1; + if !symbol_table_only { + full_already_existed += 1; + } } } } @@ -1314,6 +1315,7 @@ async fn upload_one_so( client: &reqwest::Client, ctx: ElfUploadCtx<'_>, lib: &elf::ElfLib, + index: usize, work_dir: &Path, ) -> anyhow::Result { let build_id = lib @@ -1325,18 +1327,14 @@ async fn upload_one_so( .and_then(|n| n.to_str()) .unwrap_or("lib.so"); - // Name the staging ZIP after the extracted `.so`'s on-disk filename, which - // `elf::extract_libs` already made unique with an index prefix - // (`_`). Keying it on `build_id` instead would collide under - // the concurrent `buffer_unordered` upload if two libraries shared a - // build-id β€” two tasks would `File::create` the same path and interleave - // writes into a corrupt ZIP with the wrong SHA-1. - let zip_stem = lib - .path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("lib.so"); - let zip_path = work_dir.join(format!("{zip_stem}.zip")); + // Name the staging ZIP by the caller's per-library index, unique within the + // run. Keying it on `build_id` would collide under the concurrent + // `buffer_unordered` upload if two libraries shared a build-id, and the + // basename alone collides across ABIs (a directory scan reads libraries in + // place, so `lib/arm64-v8a/libfoo.so` and `lib/x86_64/libfoo.so` both end + // in `libfoo.so`) β€” two tasks would `File::create` the same path and + // interleave writes into a corrupt ZIP with the wrong SHA-1. + let zip_path = work_dir.join(format!("{index}_{entry_name}.zip")); compress::pack_single_entry(&lib.path, entry_name, &zip_path, ctx.strategy)?; let hash = elf::sha1_hex_of_file(&zip_path)?; diff --git a/src/symbols/elf.rs b/src/symbols/elf.rs index 7a3150c..0b05c70 100644 --- a/src/symbols/elf.rs +++ b/src/symbols/elf.rs @@ -2,7 +2,7 @@ //! //! The caller hands the CLI an already-packaged `native-debug-symbols.zip` //! (the artifact AGP writes under `build/outputs/native-debug-symbols/ -//! /`). We open it and, for EACH `.so` (or the `.so.dbg` / `.so.sym` +//! /`) or a directory of libraries. We open/walk it and, for EACH `.so` (or the `.so.dbg` / `.so.sym` //! variants, depending on `ndk.debugSymbolLevel`), read the GNU build-id //! (`code_id`) via `symbolic-debuginfo` β€” the SAME crate family (major 13) the //! worker uses, so the identifier is byte-identical producer↔consumer. @@ -21,9 +21,9 @@ //! matched at crash time, so it is warned about and skipped β€” never faked with //! the BUILD_UUID. //! -//! Out of scope: walking a loose directory of unstripped `.so`s (AGP's -//! intermediate-folder case) β€” the Gradle plugin pre-zips that before invoking -//! the CLI. +//! The input is either that zip or a directory, walked recursively for the same +//! entry names (AGP's `merged_native_libs` intermediates folder) β€” libraries +//! are read in place, so nothing is re-compressed just to be unpacked again. use sha1::{Digest as _, Sha1}; use std::io::Read; @@ -42,7 +42,7 @@ pub fn sha1_hex_of_file(path: &Path) -> std::io::Result { Ok(sha1_hex(&std::fs::read(path)?)) } -/// One native library extracted from an AGP `native-debug-symbols.zip`. +/// One native library found in an AGP `native-debug-symbols.zip` or a scanned directory. /// /// Each `.so` is its OWN symbol: one file β†’ one symbol document β†’ one S3 /// object, keyed by its own GNU build-id. (`images[]` within a single document @@ -59,7 +59,8 @@ pub struct ElfLib { /// Object architecture (e.g. `arm64`); diagnostic only β€” the worker /// reconciles the canonical arch from the ELF during processing. pub arch: String, - /// Temp file holding the extracted `.so` bytes, packed into its own upload. + /// File holding the library bytes, packed into its own upload: a temp copy + /// extracted from a zip, or the original file when scanned from a directory. pub path: PathBuf, /// How much this file can symbolicate: (DWARF debug info, symbol table, /// byte size), compared in that order by [`keep_richest_per_build_id`]. @@ -122,6 +123,79 @@ pub fn extract_libs( Ok(libs) } +/// Collect native libraries from `input`: a directory is walked recursively +/// (see [`scan_dir`]), anything else is read as a `native-debug-symbols.zip` +/// and extracted into `out_dir` (see [`extract_libs`]). +pub fn collect_libs( + input: &Path, + out_dir: &Path, + extra: &ExtraSuffixes, +) -> std::io::Result> { + if input.is_dir() { + scan_dir(input, extra) + } else { + extract_libs(input, out_dir, extra) + } +} + +/// Walk `dir` recursively for native libraries (same name rules as the zip +/// entries), reading each one's identity in place. `name` is the path relative +/// to `dir`. Symlinks are followed (loops are skipped); unreadable entries are +/// skipped with a warning rather than failing the whole scan. Sorted by path so +/// the "first entry wins" tie-break in [`keep_richest_per_build_id`] is stable. +pub fn scan_dir(dir: &Path, extra: &ExtraSuffixes) -> std::io::Result> { + let mut libs = Vec::new(); + for entry in walkdir::WalkDir::new(dir) + .follow_links(true) + .sort_by_file_name() + { + let entry = match entry { + Ok(e) => e, + // An unreadable root means nothing was scanned: fail, never exit 0 with + // a misleading "pass --extension" hint. + Err(e) if e.depth() == 0 => return Err(e.into()), + Err(e) => { + tracing::warn!(error = %e, "skipping unreadable path while scanning for native libraries"); + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let file_name = entry.file_name().to_string_lossy(); + if !(is_native_lib_entry(&file_name) || extra.matches(&file_name)) { + continue; + } + let bytes = match std::fs::read(entry.path()) { + Ok(b) => b, + Err(e) => { + tracing::warn!(path = %entry.path().display(), error = %e, "skipping unreadable native library"); + continue; + } + }; + let ElfIdentity { + build_id, + arch, + has_debug_info, + has_symbols, + } = parse_elf_identity(&bytes); + let name = entry + .path() + .strip_prefix(dir) + .unwrap_or(entry.path()) + .to_string_lossy() + .replace('\\', "/"); + libs.push(ElfLib { + name, + build_id, + arch, + path: entry.path().to_path_buf(), + richness: (has_debug_info, has_symbols, bytes.len() as u64), + }); + } + Ok(libs) +} + /// Whether a `native-debug-symbols.zip` entry is a native library to upload. /// The suffix depends on AGP's `ndk.debugSymbolLevel`: `FULL` packs unstripped /// `.so` (occasionally `.so.dbg`), while `SYMBOL_TABLE` packs `.so.sym` β€” an @@ -359,6 +433,80 @@ mod tests { assert_eq!(std::fs::read(&libs[0].path).unwrap(), b"not a real elf"); } + fn fixture_bytes() -> Vec { + std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"), + ) + .unwrap() + } + + #[test] + fn scan_dir_walks_recursively_in_place_and_skips_other_files() { + let dir = tempfile::tempdir().unwrap(); + let bytes = fixture_bytes(); + for rel in [ + "lib/arm64-v8a/libsymbol1.so", + "lib/x86_64/libsymbol1.so.sym", + "lib/x86_64/readme.txt", + ] { + let p = dir.path().join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, &bytes).unwrap(); + } + std::fs::write(dir.path().join("lib/x86_64/libbare.so"), b"not elf").unwrap(); + + let libs = scan_dir(dir.path(), &ExtraSuffixes::default()).unwrap(); + assert_eq!( + names(&libs), + [ + "lib/arm64-v8a/libsymbol1.so", + "lib/x86_64/libbare.so", + "lib/x86_64/libsymbol1.so.sym" + ] + ); + assert_eq!( + libs[0].build_id.as_deref(), + Some("bca64abfec40dbb631bb8f1c37414472") + ); + assert_eq!(libs[1].build_id, None, "non-ELF yields no build-id"); + assert_eq!( + libs[0].path, + dir.path().join("lib/arm64-v8a/libsymbol1.so"), + "read in place, not copied" + ); + + let extra = ExtraSuffixes::parse(&["txt".to_string()]).unwrap(); + assert_eq!(scan_dir(dir.path(), &extra).unwrap().len(), 4); + } + + #[test] + fn collect_libs_dispatches_on_directory_vs_zip() { + let dir = tempfile::tempdir().unwrap(); + let bytes = fixture_bytes(); + let libdir = dir.path().join("libs"); + std::fs::create_dir_all(&libdir).unwrap(); + std::fs::write(libdir.join("libsymbol1.so"), &bytes).unwrap(); + let zip_path = dir.path().join("n.zip"); + { + let mut zw = zip::ZipWriter::new(std::fs::File::create(&zip_path).unwrap()); + zw.start_file("arm64-v8a/libsymbol1.so", SimpleFileOptions::default()) + .unwrap(); + zw.write_all(&bytes).unwrap(); + zw.finish().unwrap(); + } + let out = dir.path().join("out"); + std::fs::create_dir_all(&out).unwrap(); + let x = ExtraSuffixes::default(); + assert_eq!( + names(&collect_libs(&libdir, &out, &x).unwrap()), + ["libsymbol1.so"] + ); + assert_eq!( + names(&collect_libs(&zip_path, &out, &x).unwrap()), + ["arm64-v8a/libsymbol1.so"] + ); + } + fn lib(name: &str, build_id: Option<&str>, richness: (bool, bool, u64)) -> ElfLib { ElfLib { name: name.to_string(), diff --git a/src/symbols/mod.rs b/src/symbols/mod.rs index 75838a9..d30ef4c 100644 --- a/src/symbols/mod.rs +++ b/src/symbols/mod.rs @@ -2,8 +2,8 @@ //! //! Submodules handle format-specific concerns: //! - `proguard` β€” R8 / ProGuard mapping files (v0.1) -//! - `elf` β€” NDK native-debug-symbols zip pass-through -//! (v0.1 pass-through; per-`.so` build-id extraction TBD) +//! - `elf` β€” NDK native libraries (a native-debug-symbols zip or a +//! directory), per-`.so` build-id keyed //! - `dsym` β€” Apple Mach-O dSYM bundles (v0.1: single bundle; //! multi-bundle directory walk + BCSymbolMap support TBD) //! - `pdb` β€” Windows PDB (MSF container); identity is the debug id diff --git a/tests/elf_upload.rs b/tests/elf_upload.rs index 7791c98..168429e 100644 --- a/tests/elf_upload.rs +++ b/tests/elf_upload.rs @@ -325,3 +325,183 @@ fn elf_dry_run_with_no_matching_entries_suggests_extension() { .success() .stderr(predicates::str::contains("pass it with --extension")); } + +fn elf_args<'a>(endpoint: &'a str, inputs: &'a [String]) -> Vec<&'a str> { + let mut a = vec![ + "--endpoint", + endpoint, + "--app-token", + "TKN", + "debug-files", + "upload", + "--type", + "elf", + "--version", + "1.0", + "--build", + "1", + "--uuid", + "00000000-0000-0000-0000-000000000000", + ]; + a.extend(inputs.iter().map(String::as_str)); + a +} + +/// A directory (AGP's `merged_native_libs` layout) is walked recursively. The +/// same library under two ABI folders, plus a zip of it, shares ONE build-id, +/// so exactly one POST + PUT go out; the non-library file is ignored. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn elf_upload_walks_a_directory_and_dedups_by_build_id_across_paths() { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"); + let root = tmp.path().join("merged_native_libs/release/out/lib"); + for abi in ["arm64-v8a", "x86_64"] { + std::fs::create_dir_all(root.join(abi)).unwrap(); + std::fs::copy(&fixture, root.join(abi).join("libsymbol1.so")).unwrap(); + } + std::fs::write(root.join("x86_64/notes.txt"), b"ignored").unwrap(); + let zip_path = pack_native_zip(tmp.path(), "arm64-v8a/libsymbol1.so"); + + let put_url = format!("{}/put/sym", server.uri()); + Mock::given(method("POST")) + .and(wm_path("/apps/TKN/symbols")) + .and(body_string_contains(FIXTURE_BUILD_ID)) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"code": 0, "endpoint": put_url})), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let endpoint = server.uri(); + let inputs = vec![ + tmp.path() + .join("merged_native_libs") + .to_string_lossy() + .into_owned(), + zip_path.to_string_lossy().into_owned(), + ]; + tokio::task::spawn_blocking(move || { + common::cli() + .args(elf_args(&endpoint, &inputs)) + .assert() + .success(); + }) + .await + .unwrap(); +} + +/// An input path that exists as neither a directory nor a readable zip still +/// fails (it is not silently skipped), with a message naming it. +#[test] +fn elf_upload_rejects_a_missing_path() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("nope").to_string_lossy().into_owned(); + let inputs = vec![missing]; + common::cli() + .args(elf_args("http://127.0.0.1:1", &inputs)) + .assert() + .code(10) + .stderr(predicates::str::contains("nope")); +} + +/// Two zips whose entries share a basename and archive index but carry DIFFERENT +/// build-ids must each upload their own bytes (extraction must not collide). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn elf_upload_keeps_same_named_entries_of_different_zips_apart() { + let server = MockServer::start().await; + let tmp = tempfile::tempdir().unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"); + let a = pack_native_zip(tmp.path(), "arm64-v8a/libsymbol1.so"); + let a = { + let d = tmp.path().join("a"); + std::fs::create_dir_all(&d).unwrap(); + let t = d.join("a.zip"); + std::fs::rename(a, &t).unwrap(); + t + }; + // Same fixture with the build-id's first byte flipped (found by value). + let mut other = std::fs::read(&fixture).unwrap(); + let id = hex::decode(FIXTURE_BUILD_ID).unwrap(); + let pos = other.windows(id.len()).position(|w| w == id).unwrap(); + other[pos] ^= 0xff; + let other_id = hex::encode(&other[pos..pos + id.len()]); + let b = tmp.path().join("b.zip"); + { + let mut zw = zip::ZipWriter::new(std::fs::File::create(&b).unwrap()); + zw.start_file( + "x86_64/libsymbol1.so", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zw.write_all(&other).unwrap(); + zw.finish().unwrap(); + } + + let put_url = format!("{}/put/sym", server.uri()); + Mock::given(method("POST")) + .and(wm_path("/apps/TKN/symbols")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"code": 0, "endpoint": put_url})), + ) + .expect(2) + .mount(&server) + .await; + Mock::given(method("PUT")) + .respond_with(ResponseTemplate::new(200)) + .expect(2) + .mount(&server) + .await; + + let endpoint = server.uri(); + let inputs = vec![ + a.to_string_lossy().into_owned(), + b.to_string_lossy().into_owned(), + ]; + tokio::task::spawn_blocking(move || { + common::cli() + .args(elf_args(&endpoint, &inputs)) + .assert() + .success(); + }) + .await + .unwrap(); + + let reqs = server.received_requests().await.unwrap(); + let posts: Vec = reqs + .iter() + .filter(|r| r.method.as_str() == "POST") + .map(|r| String::from_utf8_lossy(&r.body).into_owned()) + .collect(); + assert!(posts.iter().any(|p| p.contains(FIXTURE_BUILD_ID))); + assert!(posts.iter().any(|p| p.contains(&other_id))); + let puts = reqs.iter().filter(|r| r.method.as_str() == "PUT").count(); + assert_eq!(puts, 2); +} + +/// An unreadable/nonexistent directory root must not exit 0. +#[cfg(unix)] +#[test] +fn elf_upload_fails_on_an_unreadable_directory() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let locked = tmp.path().join("locked"); + std::fs::create_dir_all(&locked).unwrap(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Root bypasses permission checks; nothing to assert then. + let readable = std::fs::read_dir(&locked).is_ok(); + let inputs = vec![locked.to_string_lossy().into_owned()]; + let assert = common::cli() + .args(elf_args("http://127.0.0.1:1", &inputs)) + .assert(); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); + if !readable { + assert.failure(); + } +} From 7ace775f9131a51fb48ead06464f1b0b91ab8142 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 15:28:44 +0500 Subject: [PATCH 2/4] fix(debug-files): empty ELF directory exits 10; don't descend dir symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #70: a directory with no matching libraries is a miswired path, not success (exit 10; empty zip still warns). The walk no longer follows directory symlinks (file symlinks are still read). Add changelog entry and tests. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 7 +++++++ src/cli/debug_files.rs | 10 ++++++++++ src/symbols/elf.rs | 9 ++++++--- tests/elf_upload.rs | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f73ab..1a585bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **`debug-files upload --type elf` accepts directories.** One or more directories (and/or + `native-debug-symbols.zip` files) are scanned; directories are walked recursively for `.so` / + `.so.dbg` / `.so.sym` (+ `--extension`) and read in place, so a Gradle build can point it at + `merged_native_libs/` without re-zipping. One upload per GNU build-id across all + paths. A directory with no matching libraries exits 10; an empty zip still only warns. See #69. + ## [0.7.13] - 2026-10-07 ### Added diff --git a/src/cli/debug_files.rs b/src/cli/debug_files.rs index 3fc10fd..dacb029 100644 --- a/src/cli/debug_files.rs +++ b/src/cli/debug_files.rs @@ -1202,6 +1202,16 @@ async fn run_elf_upload( )) } })?; + if found.is_empty() && input.is_dir() { + // A directory is the build's own output: empty usually means a + // miswired path or a task that ran before the libraries were merged, + // and exit 0 would let the build go green with no native symbols. + // (An empty ZIP stays a warning, as it always was.) + return Err(input_not_found(format!( + "no .so / .so.dbg / .so.sym files (or --extension suffix) under {}", + input.display() + ))); + } if found.is_empty() { // Nothing even matched by name β€” the one case `--extension` fixes. // Ahead of the dry-run exit: a dry run is how a caller checks this. diff --git a/src/symbols/elf.rs b/src/symbols/elf.rs index 0b05c70..5e9b809 100644 --- a/src/symbols/elf.rs +++ b/src/symbols/elf.rs @@ -140,13 +140,13 @@ pub fn collect_libs( /// Walk `dir` recursively for native libraries (same name rules as the zip /// entries), reading each one's identity in place. `name` is the path relative -/// to `dir`. Symlinks are followed (loops are skipped); unreadable entries are +/// to `dir`. Directory symlinks are not descended, file symlinks are read; unreadable entries are /// skipped with a warning rather than failing the whole scan. Sorted by path so /// the "first entry wins" tie-break in [`keep_richest_per_build_id`] is stable. pub fn scan_dir(dir: &Path, extra: &ExtraSuffixes) -> std::io::Result> { let mut libs = Vec::new(); for entry in walkdir::WalkDir::new(dir) - .follow_links(true) + .follow_links(false) .sort_by_file_name() { let entry = match entry { @@ -159,7 +159,10 @@ pub fn scan_dir(dir: &Path, extra: &ExtraSuffixes) -> std::io::Result Date: Wed, 7 Oct 2026 16:04:41 +0500 Subject: [PATCH 3/4] fix(debug-files): fail ELF dir scan on any I/O error; harden tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address adversarial review on #70: - any I/O error while scanning a directory (unreadable subdir/library, dangling link) now fails with exit 11 instead of silently uploading a partial set - the symlink test used same-build-id copies so dedup masked it; the escaped library now has its own id (verified to fail if links are followed) - pin exit 11 for unreadable inputs; hints for a zip-in-directory and a bare .so; NotFound only maps to exit 10 when the input itself is missing - e2e harness gets a directory flow; changelog/help/README updated; remove a leftover block scope πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 10 ++- README.md | 2 +- scripts/e2e_flows.py | 17 +++++ src/cli/debug_files.rs | 138 +++++++++++++++++++++++------------------ src/symbols/elf.rs | 30 +++------ tests/elf_upload.rs | 108 +++++++++++++++++++++++++++----- 6 files changed, 207 insertions(+), 98 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a585bd..a517bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `native-debug-symbols.zip` files) are scanned; directories are walked recursively for `.so` / `.so.dbg` / `.so.sym` (+ `--extension`) and read in place, so a Gradle build can point it at `merged_native_libs/` without re-zipping. One upload per GNU build-id across all - paths. A directory with no matching libraries exits 10; an empty zip still only warns. See #69. + paths. A directory with no matching libraries exits 10; an empty zip still only warns. Directory + symlinks are not descended (file symlinks are read), and any I/O error while scanning (an + unreadable subdirectory or library, a dangling link) fails with exit 11 rather than uploading a + partial set. See #69. + +### Changed +- **`--type elf` collects every input before uploading anything.** A corrupt second zip now aborts + (exit 11) before the first zip uploads, instead of after. A path that does not exist now exits + 10 (input not found) rather than 11; both are in the no-fallback range. ## [0.7.13] - 2026-10-07 diff --git a/README.md b/README.md index 495165f..2c36897 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The upload flow itself. ProGuard, Rust, ELF, dSYM, PDB, sourcemap, and Unity IL2 #### Android NDK β€” `--type elf` -Takes one or more paths: AGP's pre-built `native-debug-symbols.zip`, and/or directories, which are walked recursively for libraries (e.g. `build/intermediates/merged_native_libs/`) and read in place β€” nothing is re-zipped. Every library found is uploaded as its own symbol, keyed by its GNU build-id β€” `--uuid` (the SDK's `BUILD_UUID`) is still required but only correlates logs. Built-in entry names are `.so`, `.so.dbg` (`ndk.debugSymbolLevel = 'FULL'`) and `.so.sym` (`'SYMBOL_TABLE'`, function names only β€” no `file:line`). A library without a build-id is warned about and skipped; two entries with the same build-id upload once (across all the given paths), preferring the one with DWARF. The server dedups on the build-id, so switching a library from `SYMBOL_TABLE` to `FULL` needs `--force` to replace the stored symbols. +Takes one or more paths: AGP's pre-built `native-debug-symbols.zip`, and/or directories, which are walked recursively for libraries (e.g. `build/intermediates/merged_native_libs/`) and read in place β€” nothing is re-zipped. Directory symlinks are not descended; an unreadable entry fails the run (exit 11) and a directory with no libraries exits 10. Every library found is uploaded as its own symbol, keyed by its GNU build-id β€” `--uuid` (the SDK's `BUILD_UUID`) is still required but only correlates logs. Built-in entry names are `.so`, `.so.dbg` (`ndk.debugSymbolLevel = 'FULL'`) and `.so.sym` (`'SYMBOL_TABLE'`, function names only β€” no `file:line`). A library without a build-id is warned about and skipped; two entries with the same build-id upload once (across all the given paths), preferring the one with DWARF. The server dedups on the build-id, so switching a library from `SYMBOL_TABLE` to `FULL` needs `--force` to replace the stored symbols. #### Unity IL2CPP β€” `--type il2cpp-linemap` diff --git a/scripts/e2e_flows.py b/scripts/e2e_flows.py index 2e586e0..4cd6e83 100644 --- a/scripts/e2e_flows.py +++ b/scripts/e2e_flows.py @@ -241,6 +241,12 @@ def make_fixtures(fix): import zipfile as zf with zf.ZipFile(os.path.join(fix, "native-debug-symbols.zip"), "w") as z: z.writestr("arm64-v8a/libfoo.so", b"\x7fELF" + b"\x02\x01\x01\x00" + b"\x00" * 256) + # AGP's merged_native_libs layout: a directory of unstripped libraries (a REAL + # ELF with a GNU build-id, so the flow registers + uploads one symbol). + libdir = os.path.join(fix, "native_libs", "lib", "arm64-v8a") + os.makedirs(libdir, exist_ok=True) + shutil.copy(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", + "tests", "fixtures", "elf", "libsymbol1.so"), libdir) # A Rust MSVC build drops the PDB into target// next to unrelated # files, so the flow points at a directory, not the file. tgt = os.path.join(fix, "target", "release") @@ -488,6 +494,17 @@ def main(): results["elf"] = run(binpath, "elf", ["debug-files", "upload", "--type", "elf", os.path.join(fix, "native-debug-symbols.zip"), "--uuid", "11111111-2222-3333-4444-555555555555"] + v) + results["elf_dir"] = run(binpath, "elf_dir", ["debug-files", "upload", "--type", "elf", + os.path.join(fix, "native_libs"), + "--uuid", "11111111-2222-3333-4444-555555555555"] + v) + try: + posted = json.load(open(cappath("elf_dir__symbols_post.json"))) + ok = (posted.get("uuid") == "bca64abfec40dbb631bb8f1c37414472" + and STATE["puts"].get("elf_dir") == 1) + except Exception as e: + print(" [FAIL] could not verify the elf_dir flow:", e) + ok = False + results["elf_dir_keyed_by_build_id"] = ok results["pdb"] = run(binpath, "pdb", ["debug-files", "upload", "--type", "pdb", os.path.join(fix, "target", "release")] + v) if os.path.isdir(os.path.join(fix, "App.dSYM")): diff --git a/src/cli/debug_files.rs b/src/cli/debug_files.rs index dacb029..f5ebf75 100644 --- a/src/cli/debug_files.rs +++ b/src/cli/debug_files.rs @@ -19,7 +19,9 @@ pub enum DebugFilesCommand { Upload { /// One or more directories or files to scan. For `--type elf`, a directory /// is walked recursively for native libraries (e.g. AGP's - /// `merged_native_libs`), and a file is a `native-debug-symbols.zip`. + /// `merged_native_libs`; directory symlinks are not descended, an I/O error + /// fails the run, and no match exits 10), and a file is a + /// `native-debug-symbols.zip`. #[arg(required = true)] paths: Vec, @@ -1190,14 +1192,19 @@ async fn run_elf_upload( let input_dir = work_dir.path().join(n.to_string()); std::fs::create_dir_all(&input_dir)?; let found = elf::collect_libs(input, &input_dir, extra).map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { + if e.kind() == std::io::ErrorKind::NotFound && !input.exists() { input_not_found(format!("{} does not exist", input.display())) } else if input.is_dir() { input_invalid(format!("cannot scan {}: {e}", input.display())) } else { + let hint = if input.extension().is_some_and(|x| x == "so") { + " (a single library is not accepted: pass its directory)" + } else { + "" + }; input_invalid(format!( "--type elf expects a directory or a native-debug-symbols zip; \ - cannot read {}: {e}", + cannot read {}: {e}{hint}", input.display() )) } @@ -1207,8 +1214,16 @@ async fn run_elf_upload( // miswired path or a task that ran before the libraries were merged, // and exit 0 would let the build go green with no native symbols. // (An empty ZIP stays a warning, as it always was.) + let has_zip = std::fs::read_dir(input).is_ok_and(|mut d| { + d.any(|e| e.is_ok_and(|e| e.path().extension().is_some_and(|x| x == "zip"))) + }); + let hint = if has_zip { + " (it contains a .zip: zips inside a directory are not opened; pass the zip itself)" + } else { + "" + }; return Err(input_not_found(format!( - "no .so / .so.dbg / .so.sym files (or --extension suffix) under {}", + "no .so / .so.dbg / .so.sym files (or --extension suffix) under {}{hint}", input.display() ))); } @@ -1224,68 +1239,67 @@ async fn run_elf_upload( } libs.extend(found); } - { - let libs = elf::keep_richest_per_build_id(libs); - let total = libs.len(); - - // A `.so` with no GNU build-id can never be matched at crash time, so - // warn + skip rather than fake an identity (never the build UUID). - let mut uploadable: Vec = Vec::new(); - for lib in libs { - if lib.build_id.is_some() { - uploadable.push(lib); - } else { - skipped_no_build_id += 1; - tracing::warn!( - lib = %lib.name, - arch = %lib.arch, - "native library has no GNU build-id; skipping β€” it cannot be \ - symbolicated. Build the library with -Wl,--build-id." - ); - } + + let libs = elf::keep_richest_per_build_id(libs); + let total = libs.len(); + + // A `.so` with no GNU build-id can never be matched at crash time, so + // warn + skip rather than fake an identity (never the build UUID). + let mut uploadable: Vec = Vec::new(); + for lib in libs { + if lib.build_id.is_some() { + uploadable.push(lib); + } else { + skipped_no_build_id += 1; + tracing::warn!( + lib = %lib.name, + arch = %lib.arch, + "native library has no GNU build-id; skipping β€” it cannot be \ + symbolicated. Build the library with -Wl,--build-id." + ); } + } + tracing::info!( + libraries = total, + uploadable = uploadable.len(), + "collected native libraries" + ); + + if total == 0 { + // Already warned per input above. + } else if dry_run { tracing::info!( - libraries = total, - uploadable = uploadable.len(), - "collected native libraries" + "dry-run: would register + upload {} libraries", + uploadable.len() ); + } else if uploadable.is_empty() { + tracing::warn!("no native libraries with a GNU build-id β€” nothing to upload"); + } else { + let client = client.as_ref().expect("client constructed when !dry_run"); - if total == 0 { - // Already warned per input above. - } else if dry_run { - tracing::info!( - "dry-run: would register + upload {} libraries", - uploadable.len() - ); - } else if uploadable.is_empty() { - tracing::warn!("no native libraries with a GNU build-id β€” nothing to upload"); - } else { - let client = client.as_ref().expect("client constructed when !dry_run"); - - // Run each `.so`'s pack β†’ register β†’ dedup-or-PUT pipeline concurrently. - let outcomes: Vec<(bool, anyhow::Result)> = - futures_util::stream::iter(uploadable.into_iter().enumerate()) - .map(|(i, lib)| { - let client = client.clone(); - let work = work_dir.path().to_path_buf(); - async move { - let symbol_table_only = lib.name.ends_with(".so.sym"); - let outcome = upload_one_so(&client, ctx, &lib, i, &work).await; - (symbol_table_only, outcome) - } - }) - .buffer_unordered(ELF_UPLOAD_CONCURRENCY) - .collect() - .await; - - for (symbol_table_only, outcome) in outcomes { - match outcome? { - presigned::Outcome::Uploaded => uploaded += 1, - presigned::Outcome::AlreadyExists => { - already_existed += 1; - if !symbol_table_only { - full_already_existed += 1; - } + // Run each `.so`'s pack β†’ register β†’ dedup-or-PUT pipeline concurrently. + let outcomes: Vec<(bool, anyhow::Result)> = + futures_util::stream::iter(uploadable.into_iter().enumerate()) + .map(|(i, lib)| { + let client = client.clone(); + let work = work_dir.path().to_path_buf(); + async move { + let symbol_table_only = lib.name.ends_with(".so.sym"); + let outcome = upload_one_so(&client, ctx, &lib, i, &work).await; + (symbol_table_only, outcome) + } + }) + .buffer_unordered(ELF_UPLOAD_CONCURRENCY) + .collect() + .await; + + for (symbol_table_only, outcome) in outcomes { + match outcome? { + presigned::Outcome::Uploaded => uploaded += 1, + presigned::Outcome::AlreadyExists => { + already_existed += 1; + if !symbol_table_only { + full_already_existed += 1; } } } diff --git a/src/symbols/elf.rs b/src/symbols/elf.rs index 5e9b809..57a3dec 100644 --- a/src/symbols/elf.rs +++ b/src/symbols/elf.rs @@ -140,25 +140,19 @@ pub fn collect_libs( /// Walk `dir` recursively for native libraries (same name rules as the zip /// entries), reading each one's identity in place. `name` is the path relative -/// to `dir`. Directory symlinks are not descended, file symlinks are read; unreadable entries are -/// skipped with a warning rather than failing the whole scan. Sorted by path so -/// the "first entry wins" tie-break in [`keep_richest_per_build_id`] is stable. +/// to `dir`. Directory symlinks are not descended, file symlinks are read. Any +/// I/O error (unreadable subdirectory, unreadable library, dangling link) fails +/// the scan: skipping would upload a partial set (a whole ABI missing) and let +/// the build go green, the same way the zip path fails on a read error. Sorted +/// by path so the "first entry wins" tie-break in [`keep_richest_per_build_id`] +/// is stable. pub fn scan_dir(dir: &Path, extra: &ExtraSuffixes) -> std::io::Result> { let mut libs = Vec::new(); for entry in walkdir::WalkDir::new(dir) .follow_links(false) .sort_by_file_name() { - let entry = match entry { - Ok(e) => e, - // An unreadable root means nothing was scanned: fail, never exit 0 with - // a misleading "pass --extension" hint. - Err(e) if e.depth() == 0 => return Err(e.into()), - Err(e) => { - tracing::warn!(error = %e, "skipping unreadable path while scanning for native libraries"); - continue; - } - }; + let entry = entry?; // Directory symlinks are never descended (a walk must stay under the // root); a symlink to a FILE is a candidate and `read` follows it. let ft = entry.file_type(); @@ -169,13 +163,9 @@ pub fn scan_dir(dir: &Path, extra: &ExtraSuffixes) -> std::io::Result b, - Err(e) => { - tracing::warn!(path = %entry.path().display(), error = %e, "skipping unreadable native library"); - continue; - } - }; + let bytes = std::fs::read(entry.path()).map_err(|e| { + std::io::Error::new(e.kind(), format!("{}: {e}", entry.path().display())) + })?; let ElfIdentity { build_id, arch, diff --git a/tests/elf_upload.rs b/tests/elf_upload.rs index 54a1628..bbe7954 100644 --- a/tests/elf_upload.rs +++ b/tests/elf_upload.rs @@ -486,24 +486,87 @@ async fn elf_upload_keeps_same_named_entries_of_different_zips_apart() { } /// An unreadable/nonexistent directory root must not exit 0. +/// Any I/O error while scanning a directory fails the run (exit 11): skipping +/// would upload a partial set β€” a whole ABI missing β€” behind a green build. #[cfg(unix)] #[test] -fn elf_upload_fails_on_an_unreadable_directory() { +fn elf_upload_fails_on_unreadable_directory_content() { use std::os::unix::fs::PermissionsExt; + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"); + let lock = |p: &Path| { + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o000)).unwrap(); + // Root bypasses permission checks; nothing to assert then. + std::fs::read_dir(p).is_err() && std::fs::read(p).is_err() + }; + let unlock = |p: &Path| { + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + }; + let run = |root: &Path| { + let inputs = vec![root.to_string_lossy().into_owned()]; + common::cli() + .args(elf_args("http://127.0.0.1:1", &inputs)) + .assert() + .code(11) + }; + + // unreadable root let tmp = tempfile::tempdir().unwrap(); - let locked = tmp.path().join("locked"); - std::fs::create_dir_all(&locked).unwrap(); - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); - // Root bypasses permission checks; nothing to assert then. - let readable = std::fs::read_dir(&locked).is_ok(); - let inputs = vec![locked.to_string_lossy().into_owned()]; - let assert = common::cli() - .args(elf_args("http://127.0.0.1:1", &inputs)) - .assert(); - std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap(); - if !readable { - assert.failure(); + let root = tmp.path().join("locked"); + std::fs::create_dir_all(&root).unwrap(); + if lock(&root) { + run(&root); } + unlock(&root); + + // a good library plus an unreadable ABI subdirectory + let root = tmp.path().join("r2"); + std::fs::create_dir_all(root.join("x86_64")).unwrap(); + std::fs::copy(&fixture, root.join("libok.so")).unwrap(); + if lock(&root.join("x86_64")) { + run(&root); + } + unlock(&root.join("x86_64")); + + // a good library plus an unreadable library file + let root = tmp.path().join("r3"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::copy(&fixture, root.join("libok.so")).unwrap(); + std::fs::copy(&fixture, root.join("libbad.so")).unwrap(); + if lock(&root.join("libbad.so")) { + run(&root); + } + unlock(&root.join("libbad.so")); + + // a dangling symlink named like a library + let root = tmp.path().join("r4"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::copy(&fixture, root.join("libok.so")).unwrap(); + std::os::unix::fs::symlink(tmp.path().join("gone"), root.join("libdangling.so")).unwrap(); + run(&root); +} + +/// A directory holding only a zip, and a bare `.so`, get pointed at the right input. +#[test] +fn elf_upload_hints_for_a_zip_in_a_directory_and_a_bare_so() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("native-debug-symbols"); + std::fs::create_dir_all(&dir).unwrap(); + pack_native_zip(&dir, "arm64-v8a/libsymbol1.so"); + let inputs = vec![dir.to_string_lossy().into_owned()]; + common::cli() + .args(elf_args("http://127.0.0.1:1", &inputs)) + .assert() + .code(10) + .stderr(predicates::str::contains("pass the zip itself")); + + let so = tmp.path().join("libx.so"); + std::fs::write(&so, b"x").unwrap(); + let inputs = vec![so.to_string_lossy().into_owned()]; + common::cli() + .args(elf_args("http://127.0.0.1:1", &inputs)) + .assert() + .code(11) + .stderr(predicates::str::contains("pass its directory")); } /// A directory that exists but holds no libraries is a miswired path / too-early @@ -519,7 +582,20 @@ fn elf_upload_empty_directory_is_input_not_found() { .stderr(predicates::str::contains("no .so")); } +/// The fixture with its GNU build-id's first byte flipped: same ELF, different identity. +fn fixture_with_other_build_id() -> (Vec, String) { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"); + let mut bytes = std::fs::read(&fixture).unwrap(); + let id = hex::decode(FIXTURE_BUILD_ID).unwrap(); + let pos = bytes.windows(id.len()).position(|w| w == id).unwrap(); + bytes[pos] ^= 0xff; + let other = hex::encode(&bytes[pos..pos + id.len()]); + (bytes, other) +} + /// Directory symlinks are not descended; a symlink to a library file is read. +/// The escaped library has its OWN build-id, so following the link would make it +/// 2 libraries (same-id copies would be collapsed by dedup and prove nothing). #[cfg(unix)] #[test] fn elf_dry_run_does_not_descend_directory_symlinks_but_reads_file_symlinks() { @@ -527,7 +603,11 @@ fn elf_dry_run_does_not_descend_directory_symlinks_but_reads_file_symlinks() { let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"); let outside = tmp.path().join("outside"); std::fs::create_dir_all(&outside).unwrap(); - std::fs::copy(&fixture, outside.join("libescaped.so")).unwrap(); + std::fs::write( + outside.join("libescaped.so"), + fixture_with_other_build_id().0, + ) + .unwrap(); let root = tmp.path().join("root"); std::fs::create_dir_all(&root).unwrap(); std::os::unix::fs::symlink(&outside, root.join("linkdir")).unwrap(); From 01e17d9c20b57cd5b8b8181776eea14f87821b60 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 7 Oct 2026 17:26:04 +0500 Subject: [PATCH 4/4] test(elf): gate the unix-only fixture helper (dead code is an error on Windows CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's only user is a #[cfg(unix)] test, so the Windows test jobs failed to compile under -D warnings. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- tests/elf_upload.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/elf_upload.rs b/tests/elf_upload.rs index bbe7954..62eeebd 100644 --- a/tests/elf_upload.rs +++ b/tests/elf_upload.rs @@ -583,6 +583,9 @@ fn elf_upload_empty_directory_is_input_not_found() { } /// The fixture with its GNU build-id's first byte flipped: same ELF, different identity. +/// (Unix-only because its sole user is: an unused helper is a hard error under CI's +/// `-D warnings` on Windows.) +#[cfg(unix)] fn fixture_with_other_build_id() -> (Vec, String) { let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/elf/libsymbol1.so"); let mut bytes = std::fs::read(&fixture).unwrap();