Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ 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/<variant>` 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. 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

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<variant>`) 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`

Expand Down
17 changes: 17 additions & 0 deletions scripts/e2e_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<profile>/ next to unrelated
# files, so the flow points at a directory, not the file.
tgt = os.path.join(fix, "target", "release")
Expand Down Expand Up @@ -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")):
Expand Down
184 changes: 103 additions & 81 deletions src/cli/debug_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ 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`; 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<PathBuf>,

Expand Down Expand Up @@ -141,8 +145,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
Expand Down Expand Up @@ -1078,15 +1083,9 @@ pub(crate) fn discover_dsyms(paths: &[PathBuf], extra: &ExtraSuffixes) -> Vec<Pa
out
}

/// Run the upload for one or more pre-packaged native-debug-symbols archives.
///
/// Phase 1 scope: each path must be a regular file (the AGP-produced
/// `native-debug-symbols.zip`). The CLI hashes the bytes, POSTs metadata
/// with `transform = breakpad`, then PUTs the file as-is — no Zstd
/// re-compression in this phase. Walking a directory of `.so` files is
/// the Gradle plugin's job (it pre-zips the
/// `build/intermediates/native_debug_metadata/<variant>/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
Expand Down Expand Up @@ -1151,18 +1150,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
Expand Down Expand Up @@ -1194,73 +1181,111 @@ 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)?;
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<elf::ElfLib> = Vec::new();
for lib in libs {
if lib.build_id.is_some() {
uploadable.push(lib);
// 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<elf::ElfLib> = 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 `<index>_<base>`,
// 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.exists() {
input_not_found(format!("{} does not exist", input.display()))
} else if input.is_dir() {
input_invalid(format!("cannot scan {}: {e}", input.display()))
} 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 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}{hint}",
input.display()
))
}
})?;
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.)
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 {}{hint}",
input.display()
)));
}
tracing::info!(
libraries = total,
uploadable = uploadable.len(),
"extracted native libraries"
);

if total == 0 {
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!(
archive = %archive.display(),
"no entries named .so / .so.dbg / .so.sym (or an --extension suffix) — \
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"
);
continue;
}
if dry_run {
tracing::info!(
"dry-run: would register + upload {} libraries from {}",
uploadable.len(),
archive.display()
);
continue;
}
if uploadable.is_empty() {
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<elf::ElfLib> = Vec::new();
for lib in libs {
if lib.build_id.is_some() {
uploadable.push(lib);
} else {
skipped_no_build_id += 1;
tracing::warn!(
archive = %archive.display(),
"no native libraries with a GNU build-id — nothing to upload"
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."
);
continue;
}
}
tracing::info!(
libraries = total,
uploadable = uploadable.len(),
"collected native libraries"
);

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<presigned::Outcome>)> =
futures_util::stream::iter(uploadable)
.map(|lib| {
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, &work).await;
let outcome = upload_one_so(&client, ctx, &lib, i, &work).await;
(symbol_table_only, outcome)
}
})
Expand Down Expand Up @@ -1314,6 +1339,7 @@ async fn upload_one_so(
client: &reqwest::Client,
ctx: ElfUploadCtx<'_>,
lib: &elf::ElfLib,
index: usize,
work_dir: &Path,
) -> anyhow::Result<presigned::Outcome> {
let build_id = lib
Expand All @@ -1325,18 +1351,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
// (`<i>_<basename>`). 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)?;

Expand Down
Loading
Loading