diff --git a/Cargo.lock b/Cargo.lock index cb2c406a..ba423a89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -186,6 +192,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.9.1" @@ -275,6 +287,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.41" @@ -488,6 +506,17 @@ dependencies = [ "syn", ] +[[package]] +name = "debuginfod-client" +version = "0.1.4" +dependencies = [ + "anyhow", + "clap", + "ghostscope-debuginfod", + "object", + "tokio", +] + [[package]] name = "digest" version = "0.10.7" @@ -519,6 +548,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dwarf-tool" version = "0.1.4" @@ -584,6 +624,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.31" @@ -690,8 +739,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -701,9 +752,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -717,6 +770,7 @@ dependencies = [ "dirs", "futures", "ghostscope-compiler", + "ghostscope-debuginfod", "ghostscope-dwarf", "ghostscope-loader", "ghostscope-process", @@ -751,6 +805,19 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ghostscope-debuginfod" +version = "0.1.4" +dependencies = [ + "futures-util", + "reqwest", + "tempfile", + "thiserror 2.0.12", + "tokio", + "tracing", + "url", +] + [[package]] name = "ghostscope-dwarf" version = "0.1.4" @@ -760,6 +827,7 @@ dependencies = [ "crc32fast", "dirs", "futures", + "ghostscope-debuginfod", "ghostscope-platform", "ghostscope-process", "ghostscope-protocol", @@ -916,6 +984,104 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -940,12 +1106,114 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.11.0" @@ -1011,6 +1279,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1076,6 +1350,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "llvm-sys" version = "181.2.0" @@ -1115,6 +1395,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.1.0" @@ -1256,6 +1542,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.0" @@ -1313,6 +1605,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1322,6 +1632,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -1337,6 +1702,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "ratatui" version = "0.28.1" @@ -1448,12 +1842,73 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustix" version = "0.38.44" @@ -1480,6 +1935,41 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1572,6 +2062,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -1715,6 +2217,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.101" @@ -1726,6 +2234,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -1789,6 +2317,31 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.47.1" @@ -1820,6 +2373,29 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1861,6 +2437,51 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.41" @@ -1922,6 +2543,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "twox-hash" version = "1.6.3" @@ -1973,6 +2600,30 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1991,6 +2642,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2041,6 +2701,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -2073,6 +2746,48 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2163,6 +2878,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2382,6 +3106,35 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -2401,3 +3154,63 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 7b7c47ba..e90ea1af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,9 @@ members = [ "ghostscope-platform", "ghostscope", "ghostscope-dwarf", + "ghostscope-debuginfod", "ghostscope-process", + "bins/debuginfod-client", "bins/dwarf-tool", "e2e-tests", ] @@ -20,7 +22,9 @@ default-members = [ "ghostscope-platform", "ghostscope", "ghostscope-dwarf", + "ghostscope-debuginfod", "ghostscope-process", + "bins/debuginfod-client", "bins/dwarf-tool", ] diff --git a/bins/debuginfod-client/Cargo.toml b/bins/debuginfod-client/Cargo.toml new file mode 100644 index 00000000..1b36b36b --- /dev/null +++ b/bins/debuginfod-client/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "debuginfod-client" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Standalone debuginfod client for exercising GhostScope's async debuginfod library." +publish = false + +[[bin]] +name = "debuginfod-client" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +ghostscope-debuginfod = { version = "0.1.4", path = "../../ghostscope-debuginfod" } +object.workspace = true +tokio.workspace = true diff --git a/bins/debuginfod-client/src/main.rs b/bins/debuginfod-client/src/main.rs new file mode 100644 index 00000000..9cea9130 --- /dev/null +++ b/bins/debuginfod-client/src/main.rs @@ -0,0 +1,154 @@ +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args, Parser, Subcommand}; +use ghostscope_debuginfod::{DebuginfodClient, DebuginfodConfig, FetchedFile}; +use object::Object; +use std::{ + fs, + path::{Path, PathBuf}, + time::Duration, +}; + +const UBUNTU_DEBUGINFOD_URL: &str = "https://debuginfod.ubuntu.com"; + +#[derive(Debug, Parser)] +#[command(about = "Fetch debuginfod artifacts with GhostScope's async client")] +struct Cli { + /// Debuginfod server URL. May be passed more than once. + #[arg(long = "url", value_name = "URL", default_value = UBUNTU_DEBUGINFOD_URL)] + urls: Vec, + + /// Local cache directory for downloaded artifacts. + #[arg( + long, + value_name = "DIR", + default_value = "target/debuginfod-client-cache" + )] + cache_dir: PathBuf, + + /// Request timeout in seconds. Use 0 to disable reqwest's global request timeout. + #[arg(long, default_value_t = ghostscope_debuginfod::DEFAULT_TIMEOUT_SECS)] + timeout_secs: u64, + + /// Maximum response size in bytes. Omit for no explicit client-side cap. + #[arg(long, value_name = "BYTES")] + max_size: Option, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Fetch /buildid//debuginfo. + Debuginfo(BuildIdInput), + /// Fetch /buildid//executable. + Executable(BuildIdInput), + /// Fetch /buildid//source/. + Source(SourceInput), +} + +#[derive(Debug, Args)] +struct BuildIdInput { + /// Hex build-id to query. + #[arg(long, conflicts_with = "file")] + build_id: Option, + + /// ELF file to read the GNU build-id from. + #[arg(long, value_name = "ELF", conflicts_with = "build_id")] + file: Option, +} + +#[derive(Debug, Args)] +struct SourceInput { + #[command(flatten)] + build_id: BuildIdInput, + + /// Absolute source path as recorded or resolved from DWARF. + #[arg(long, value_name = "PATH")] + path: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let mut config = DebuginfodConfig::new(cli.urls, &cli.cache_dir)?; + if cli.timeout_secs == 0 { + config = config.without_timeout(); + } else { + config = config.with_timeout(Duration::from_secs(cli.timeout_secs)); + } + config = config.with_max_size(cli.max_size); + + let client = DebuginfodClient::new(config)?; + let fetched = match cli.command { + Command::Debuginfo(input) => { + let build_id = input.resolve_build_id()?; + client.fetch_debuginfo(&build_id).await? + } + Command::Executable(input) => { + let build_id = input.resolve_build_id()?; + client.fetch_executable(&build_id).await? + } + Command::Source(input) => { + let build_id = input.build_id.resolve_build_id()?; + client.fetch_source(&build_id, &input.path).await? + } + }; + + match fetched { + Some(file) => print_fetched_file(&file), + None => bail!("artifact not found on any configured debuginfod server"), + } + + Ok(()) +} + +impl BuildIdInput { + fn resolve_build_id(&self) -> Result> { + match (&self.build_id, &self.file) { + (Some(build_id), None) => parse_build_id_hex(build_id), + (None, Some(file)) => read_build_id_from_elf(file), + (None, None) => Err(anyhow!("pass either --build-id or --file ")), + (Some(_), Some(_)) => unreachable!("clap enforces conflicts_with"), + } + } +} + +fn print_fetched_file(file: &FetchedFile) { + println!("build-id: {}", file.build_id); + println!("path: {}", file.path.display()); + println!("from-cache: {}", file.from_cache); + if let Some(url) = &file.url { + println!("url: {url}"); + } +} + +fn read_build_id_from_elf(path: &Path) -> Result> { + let bytes = + fs::read(path).with_context(|| format!("failed to read ELF file {}", path.display()))?; + let object = object::File::parse(&bytes[..]) + .with_context(|| format!("failed to parse ELF file {}", path.display()))?; + object + .build_id() + .context("failed to read GNU build-id note")? + .map(|build_id| build_id.to_vec()) + .ok_or_else(|| anyhow!("ELF file has no GNU build-id: {}", path.display())) +} + +fn parse_build_id_hex(raw: &str) -> Result> { + let raw = raw.trim(); + if raw.is_empty() { + bail!("build-id must not be empty"); + } + if raw.len() % 2 != 0 { + bail!("build-id hex must contain an even number of digits"); + } + + let mut bytes = Vec::with_capacity(raw.len() / 2); + for idx in (0..raw.len()).step_by(2) { + let byte = u8::from_str_radix(&raw[idx..idx + 2], 16) + .with_context(|| format!("invalid build-id hex at byte {}", idx / 2))?; + bytes.push(byte); + } + Ok(bytes) +} diff --git a/config-zh.toml b/config-zh.toml index 28efe65f..53ccb2b3 100644 --- a/config-zh.toml +++ b/config-zh.toml @@ -99,6 +99,41 @@ search_paths = [ # 也会继续使用该独立调试文件(会记录警告日志)。仅建议在排障或环境不规范时短期启用。 allow_loose_debug_match = false +[dwarf.debuginfod] +# debuginfod 调试信息回退。 +# 模式参考 GDB: +# - "off":完全不使用 debuginfod,也不读取 debuginfod 相关环境变量 +# - "on":嵌入式 DWARF 和本地独立调试文件都失败后,允许使用 debuginfod +# - "ask":预留给未来 TUI 交互确认(当前不会使用) +# +# 默认:"off"(除非显式开启,否则不会联网) +enabled = "off" + +# 服务器 URL。开启后如果此列表为空,GhostScope 会回退读取 DEBUGINFOD_URLS。 +# 环境变量中是空白分隔;配置文件中使用 TOML 数组。 +# +# 示例: +# - "https://debuginfod.ubuntu.com" +# - "https://debuginfod.archlinux.org" +urls = [ + # "https://debuginfod.ubuntu.com", +] + +# 下载的 debuginfo/source 本地缓存目录。 +# 未设置或留空时使用: +# 1. DEBUGINFOD_CACHE_PATH +# 2. $XDG_CACHE_HOME/debuginfod_client +# 3. ~/.cache/debuginfod_client +# cache_dir = "~/.cache/debuginfod_client" + +# 请求超时时间(秒)。优先级:命令行 > 配置文件 > DEBUGINFOD_TIMEOUT > 内置默认 5 秒。 +# 设置为 0 表示不设置请求超时。 +timeout_secs = 5 + +# 最大响应大小(字节)。优先级:命令行 > 配置文件 > DEBUGINFOD_MAXSIZE。 +# 设置为 0 表示不设置客户端大小上限。 +max_size_bytes = 0 + [files] # 文件保存选项(可通过 --save-*/--no-save-* 参数覆盖) # 格式:{ debug = bool, release = bool } diff --git a/config.toml b/config.toml index b2af3ed1..0048e000 100644 --- a/config.toml +++ b/config.toml @@ -104,6 +104,43 @@ search_paths = [ # ad-hoc environments; may cause inaccurate symbol/line info. allow_loose_debug_match = false +[dwarf.debuginfod] +# debuginfod fallback for separate debug information. +# This follows GDB-style modes: +# - "off": never use debuginfod and do not read debuginfod environment variables +# - "on": allow debuginfod after embedded DWARF and local debug files fail +# - "ask": reserved for future TUI confirmation support (currently not used) +# +# Default: "off" (no network access unless explicitly enabled) +enabled = "off" + +# Server URLs. When enabled and this list is empty, GhostScope falls back to +# DEBUGINFOD_URLS. Values are whitespace-separated in the environment but are +# configured as a TOML array here. +# +# Examples: +# - "https://debuginfod.ubuntu.com" +# - "https://debuginfod.archlinux.org" +urls = [ + # "https://debuginfod.ubuntu.com", +] + +# Cache directory for downloaded debuginfo/source artifacts. +# Leave unset or empty to use: +# 1. DEBUGINFOD_CACHE_PATH +# 2. $XDG_CACHE_HOME/debuginfod_client +# 3. ~/.cache/debuginfod_client +# cache_dir = "~/.cache/debuginfod_client" + +# Request timeout in seconds. CLI overrides this, then config, then +# DEBUGINFOD_TIMEOUT, then the built-in default of 5 seconds. Use 0 for no +# request timeout. +timeout_secs = 5 + +# Maximum response size in bytes. CLI overrides this, then config, then +# DEBUGINFOD_MAXSIZE. Use 0 for no explicit client-side cap. +max_size_bytes = 0 + [files] # File saving options (overridden by --save-*/--no-save-* flags) # Format: { debug = bool, release = bool } diff --git a/docs/configuration.md b/docs/configuration.md index 8f143f16..b1deef8e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -226,6 +226,11 @@ Behavior: | `--no-status` | | Disable interactive DWARF/script/attach stderr status prompts | Off override | | `--script-timestamp ` | | Pretty output timestamp: local, boot, none | local | | `--debug-file ` | `-d` | Debug info file path | Auto-detect | +| `--debuginfod ` | | debuginfod mode: off, on, ask | off | +| `--debuginfod-url ` | | debuginfod server URL; may be repeated | None | +| `--debuginfod-cache-dir ` | | debuginfod cache directory | debuginfod-compatible default | +| `--debuginfod-timeout-secs ` | | debuginfod request timeout; 0 disables timeout | 5 | +| `--debuginfod-max-size ` | | debuginfod maximum response size; 0 disables cap | 0 | | `--tui` | | Start in TUI mode | Auto | | `--log` | | Enable file logging | Script: off, TUI: on | | `--no-log` | | Disable all logging | - | @@ -340,6 +345,40 @@ search_paths = [ # symbol/line information. Prefer leaving this off unless you know what you are doing. allow_loose_debug_match = false +[dwarf.debuginfod] +# Optional debuginfod fallback for separate debug information. +# Modes follow GDB's model: +# - "off": never use debuginfod and do not read debuginfod environment variables +# - "on": allow debuginfod after embedded DWARF and local debug files fail +# - "ask": reserved for future TUI confirmation support (currently not used) +# +# Default: "off" (no network access unless explicitly enabled) +enabled = "off" + +# Server URLs. When enabled and this list is empty, GhostScope falls back to +# DEBUGINFOD_URLS. Values are whitespace-separated in the environment but are +# configured as a TOML array here. +urls = [ + # "https://debuginfod.ubuntu.com", + # "https://debuginfod.archlinux.org", +] + +# Cache directory for downloaded debuginfo/source artifacts. +# Leave unset to use: +# 1. DEBUGINFOD_CACHE_PATH +# 2. $XDG_CACHE_HOME/debuginfod_client +# 3. ~/.cache/debuginfod_client +# cache_dir = "~/.cache/debuginfod_client" + +# Request timeout in seconds. Priority: +# CLI > config file > DEBUGINFOD_TIMEOUT > built-in default (5). +# Use 0 for no request timeout. +timeout_secs = 5 + +# Maximum response size in bytes. Priority: +# CLI > config file > DEBUGINFOD_MAXSIZE. Use 0 for no explicit cap. +max_size_bytes = 0 + [files] # Save LLVM IR files [files.save_llvm_ir] diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 91e3827c..0c4ec8df 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -227,6 +227,11 @@ ghostscope bpffs prune --dry-run --json | `--no-status` | | 禁用交互式 DWARF/脚本/attach stderr 状态提示 | 关闭覆盖 | | `--script-timestamp ` | | pretty 输出时间戳:local, boot, none | local | | `--debug-file ` | `-d` | 调试信息文件路径 | 自动检测 | +| `--debuginfod ` | | debuginfod 模式:off, on, ask | off | +| `--debuginfod-url ` | | debuginfod 服务 URL,可重复传递 | 无 | +| `--debuginfod-cache-dir ` | | debuginfod 缓存目录 | debuginfod 兼容默认值 | +| `--debuginfod-timeout-secs ` | | debuginfod 请求超时;0 表示不设置超时 | 5 | +| `--debuginfod-max-size ` | | debuginfod 最大响应大小;0 表示不设置上限 | 0 | | `--tui` | | 以 TUI 模式启动 | 自动 | | `--log` | | 启用文件日志 | Script: 关, TUI: 开 | | `--no-log` | | 禁用所有日志 | - | @@ -338,6 +343,39 @@ search_paths = [ # 也会继续使用该独立调试文件(会记录警告日志)。仅建议在排障或环境不规范时短期启用。 allow_loose_debug_match = false +[dwarf.debuginfod] +# 可选的 debuginfod 调试信息回退。 +# 模式参考 GDB: +# - "off":完全不使用 debuginfod,也不读取 debuginfod 相关环境变量 +# - "on":嵌入式 DWARF 和本地独立调试文件都失败后,允许使用 debuginfod +# - "ask":预留给未来 TUI 交互确认(当前不会使用) +# +# 默认:"off"(除非显式开启,否则不会联网) +enabled = "off" + +# 服务器 URL。开启后如果此列表为空,GhostScope 会回退读取 DEBUGINFOD_URLS。 +# 环境变量中是空白分隔;配置文件中使用 TOML 数组。 +urls = [ + # "https://debuginfod.ubuntu.com", + # "https://debuginfod.archlinux.org", +] + +# 下载的 debuginfo/source 本地缓存目录。 +# 未设置时使用: +# 1. DEBUGINFOD_CACHE_PATH +# 2. $XDG_CACHE_HOME/debuginfod_client +# 3. ~/.cache/debuginfod_client +# cache_dir = "~/.cache/debuginfod_client" + +# 请求超时时间(秒)。优先级: +# 命令行 > 配置文件 > DEBUGINFOD_TIMEOUT > 内置默认 5 秒。 +# 设置为 0 表示不设置请求超时。 +timeout_secs = 5 + +# 最大响应大小(字节)。优先级: +# 命令行 > 配置文件 > DEBUGINFOD_MAXSIZE。设置为 0 表示不设置客户端大小上限。 +max_size_bytes = 0 + [files] # 保存 LLVM IR 文件 [files.save_llvm_ir] diff --git a/e2e-tests/tests/common/mod.rs b/e2e-tests/tests/common/mod.rs index d56ecd5e..6e364f86 100644 --- a/e2e-tests/tests/common/mod.rs +++ b/e2e-tests/tests/common/mod.rs @@ -229,6 +229,7 @@ pub fn init() { // Exercise runner builder methods so they are referenced in all bins. let _ = runner::GhostscopeRunner::new() .with_target("/") + .with_cli_args([std::ffi::OsString::from("--help")]) .force_perf_event_array(false) .enable_sysmon_shared_lib(false); diff --git a/e2e-tests/tests/common/runner.rs b/e2e-tests/tests/common/runner.rs index 92c7e14f..f1f0b745 100644 --- a/e2e-tests/tests/common/runner.rs +++ b/e2e-tests/tests/common/runner.rs @@ -53,6 +53,7 @@ pub struct GhostscopeRunner { enable_file_logging: bool, enable_console_logging: bool, sandbox: Option, + extra_args: Vec, } impl Default for GhostscopeRunner { @@ -69,6 +70,7 @@ impl Default for GhostscopeRunner { enable_file_logging: false, enable_console_logging: false, sandbox: None, + extra_args: Vec::new(), } } } @@ -139,6 +141,15 @@ impl GhostscopeRunner { self } + pub fn with_cli_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.extra_args.extend(args.into_iter().map(Into::into)); + self + } + pub async fn run(self) -> Result<(i32, String, String)> { let (exit_code, stdout, stderr, ()) = self .run_internal( @@ -249,6 +260,7 @@ impl GhostscopeRunner { if logging.enable_console_logging { args.push(OsString::from("--log-console")); } + args.extend(self.extra_args); let launch = sandbox.ghostscope_runner_command(&args)?; let mut cmd = Command::new(&launch.program); diff --git a/e2e-tests/tests/debuginfod_execution.rs b/e2e-tests/tests/debuginfod_execution.rs new file mode 100644 index 00000000..059d201b --- /dev/null +++ b/e2e-tests/tests/debuginfod_execution.rs @@ -0,0 +1,240 @@ +//! debuginfod integration tests. + +mod common; + +use anyhow::{Context, Result}; +use common::{init, OptimizationLevel, FIXTURES}; +use object::Object; +use std::ffi::OsString; +use std::fs; +use std::path::Path; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use tempfile::TempDir; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +#[tokio::test] +#[serial_test::serial] +async fn test_stripped_binary_fetches_debug_info_from_debuginfod() -> Result<()> { + init(); + + if !is_host_topology() { + println!("skipping debuginfod e2e outside host->host topology"); + return Ok(()); + } + + common::ensure_test_program_compiled_with_opt(OptimizationLevel::Stripped)?; + + let fixture_binary = + FIXTURES.get_test_binary_with_opt("sample_program", OptimizationLevel::Stripped)?; + let fixture_debug = fixture_binary.with_file_name("sample_program_stripped.debug"); + anyhow::ensure!( + fixture_debug.exists(), + "debug file should exist before debuginfod test: {}", + fixture_debug.display() + ); + + let work_dir = TempDir::new().context("failed to create debuginfod e2e temp dir")?; + let target_binary = work_dir.path().join("sample_program_debuginfod"); + fs::copy(&fixture_binary, &target_binary).with_context(|| { + format!( + "failed to copy stripped fixture {} to {}", + fixture_binary.display(), + target_binary.display() + ) + })?; + copy_executable_permissions(&fixture_binary, &target_binary)?; + + let build_id = read_build_id_hex(&target_binary)?; + let cache_dir = work_dir.path().join("cache"); + let debug_file_bytes = Arc::new(fs::read(&fixture_debug).with_context(|| { + format!( + "failed to read debuginfod fixture debug file {}", + fixture_debug.display() + ) + })?); + + let mock = MockDebuginfod::start(build_id.clone(), debug_file_bytes).await?; + let _mock_guard = scopeguard::guard(mock.task, |task| task.abort()); + + let target = common::targets::TargetLauncher::binary(&target_binary) + .spawn() + .await?; + + let script_content = r#" +trace add_numbers { + print "DEBUGINFOD_STRIPPED: add_numbers called with a={} b={}", a, b; +} +"#; + + let cli_args = vec![ + OsString::from("--debuginfod"), + OsString::from("on"), + OsString::from("--debuginfod-url"), + OsString::from(mock.url.clone()), + OsString::from("--debuginfod-cache-dir"), + cache_dir.clone().into_os_string(), + OsString::from("--debuginfod-timeout-secs"), + OsString::from("2"), + ]; + + let (exit_code, stdout, stderr) = common::runner::GhostscopeRunner::new() + .with_script(script_content) + .attach_to(&target) + .timeout_secs(4) + .enable_sysmon_shared_lib(false) + .with_cli_args(cli_args) + .run() + .await?; + + target.terminate().await?; + + if exit_code != 0 && stderr.contains("BPF_PROG_LOAD") { + return Ok(()); + } + + assert_eq!( + exit_code, 0, + "ghostscope failed while using mock debuginfod\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ); + assert!( + stdout.contains("DEBUGINFOD_STRIPPED"), + "expected trace output from debuginfod-backed stripped binary\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ); + assert!( + mock.target_request_count.load(Ordering::SeqCst) > 0, + "mock debuginfod did not receive a request for target build-id {build_id}" + ); + assert!( + cache_dir.join(&build_id).join("debuginfo").exists(), + "debuginfod cache did not contain downloaded debug info for {build_id}" + ); + + Ok(()) +} + +struct MockDebuginfod { + url: String, + target_request_count: Arc, + task: JoinHandle<()>, +} + +impl MockDebuginfod { + async fn start(build_id: String, debug_file_bytes: Arc>) -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .context("failed to bind mock debuginfod server")?; + let addr = listener.local_addr()?; + let target_request_count = Arc::new(AtomicUsize::new(0)); + let request_count = Arc::clone(&target_request_count); + let expected_path = format!("/buildid/{build_id}/debuginfo"); + + let task = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let expected_path = expected_path.clone(); + let debug_file_bytes = Arc::clone(&debug_file_bytes); + let request_count = Arc::clone(&request_count); + tokio::spawn(async move { + let mut buffer = [0_u8; 4096]; + let Ok(read) = stream.read(&mut buffer).await else { + return; + }; + if read == 0 { + return; + } + + let request = String::from_utf8_lossy(&buffer[..read]); + let request_path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or(""); + + if request_path == expected_path { + request_count.fetch_add(1, Ordering::SeqCst); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + debug_file_bytes.len() + ); + let _ = stream.write_all(header.as_bytes()).await; + let _ = stream.write_all(&debug_file_bytes).await; + } else { + let body = b"not found"; + let header = format!( + "HTTP/1.1 404 Not Found\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(header.as_bytes()).await; + let _ = stream.write_all(body).await; + } + }); + } + }); + + Ok(Self { + url: format!("http://{addr}"), + target_request_count, + task, + }) + } +} + +fn read_build_id_hex(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read ELF {}", path.display()))?; + let object = object::File::parse(&bytes[..]) + .with_context(|| format!("failed to parse ELF {}", path.display()))?; + let build_id = object + .build_id() + .context("failed to read GNU build-id note")? + .with_context(|| format!("ELF has no build-id: {}", path.display()))?; + Ok(build_id_to_hex(build_id)) +} + +fn build_id_to_hex(build_id: &[u8]) -> String { + let mut hex = String::with_capacity(build_id.len() * 2); + for byte in build_id { + use std::fmt::Write; + let _ = write!(&mut hex, "{byte:02x}"); + } + hex +} + +#[cfg(unix)] +fn copy_executable_permissions(from: &Path, to: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mode = fs::metadata(from)?.permissions().mode(); + fs::set_permissions(to, fs::Permissions::from_mode(mode))?; + Ok(()) +} + +#[cfg(not(unix))] +fn copy_executable_permissions(_from: &Path, _to: &Path) -> Result<()> { + Ok(()) +} + +fn is_host_topology() -> bool { + env_is_unset_or("E2E_GHOSTSCOPE_SANDBOX", "host") + && env_is_unset_or("E2E_TARGET_SANDBOX", "host") + && std::env::var("E2E_TARGET_MODE") + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "direct" | "same" | "same-sandbox" + ) + }) + .unwrap_or(true) +} + +fn env_is_unset_or(name: &str, expected: &str) -> bool { + std::env::var(name) + .map(|value| value.trim().eq_ignore_ascii_case(expected)) + .unwrap_or(true) +} diff --git a/ghostscope-debuginfod/Cargo.toml b/ghostscope-debuginfod/Cargo.toml new file mode 100644 index 00000000..b30ccf4c --- /dev/null +++ b/ghostscope-debuginfod/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "ghostscope-debuginfod" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +keywords.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +categories.workspace = true +description = "Async debuginfod client used by GhostScope to fetch debug information and source files by build-id." + +[dependencies] +futures-util.workspace = true +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +url = "2.5" + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["full"] } diff --git a/ghostscope-debuginfod/src/lib.rs b/ghostscope-debuginfod/src/lib.rs new file mode 100644 index 00000000..a3a7d125 --- /dev/null +++ b/ghostscope-debuginfod/src/lib.rs @@ -0,0 +1,692 @@ +//! Async debuginfod client. +//! +//! This crate implements the small HTTP client-side subset GhostScope needs: +//! debuginfo, executable, and source lookups by build-id. It does not implement +//! a debuginfod server. +//! +//! The HTTP paths implemented here come from the debuginfod web API documented +//! by elfutils/debuginfod(8): +//! +//! and distro man pages such as: +//! . + +use futures_util::StreamExt; +use reqwest::{StatusCode, Url}; +use std::{ + ffi::OsStr, + path::{Component, Path, PathBuf}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; +use tokio::{fs, io::AsyncWriteExt}; + +pub const DEFAULT_TIMEOUT_SECS: u64 = 5; + +const USER_AGENT: &str = concat!("ghostscope/", env!("CARGO_PKG_VERSION")); + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum DebuginfodError { + #[error("invalid debuginfod URL '{raw}': {source}")] + InvalidUrl { + raw: String, + source: url::ParseError, + }, + + #[error("unsupported debuginfod URL scheme '{scheme}' in '{raw}'")] + UnsupportedUrlScheme { raw: String, scheme: String }, + + #[error("invalid build-id: build-id must not be empty")] + EmptyBuildId, + + #[error("invalid source path '{0}': debuginfod source queries require an absolute path")] + InvalidSourcePath(String), + + #[error("debuginfod HTTP client error: {0}")] + Http(#[from] reqwest::Error), + + #[error("I/O error for {path}: {source}")] + Io { + path: PathBuf, + source: std::io::Error, + }, + + #[error("debuginfod response exceeded configured maximum size ({max_size} bytes)")] + ResponseTooLarge { max_size: u64 }, +} + +#[derive(Debug, Clone)] +pub struct DebuginfodConfig { + urls: Vec, + cache_dir: PathBuf, + timeout: Option, + max_size: Option, + user_agent: String, +} + +impl DebuginfodConfig { + pub fn new(urls: I, cache_dir: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let urls = parse_url_list(urls)?; + Ok(Self { + urls, + cache_dir: cache_dir.into(), + timeout: Some(Duration::from_secs(DEFAULT_TIMEOUT_SECS)), + max_size: None, + user_agent: USER_AGENT.to_string(), + }) + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + pub fn without_timeout(mut self) -> Self { + self.timeout = None; + self + } + + pub fn with_max_size(mut self, max_size: Option) -> Self { + self.max_size = max_size.filter(|size| *size > 0); + self + } + + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = user_agent.into(); + self + } + + pub fn urls(&self) -> &[Url] { + &self.urls + } + + pub fn cache_dir(&self) -> &Path { + &self.cache_dir + } + + pub fn timeout(&self) -> Option { + self.timeout + } + + pub fn max_size(&self) -> Option { + self.max_size + } +} + +#[derive(Debug, Clone)] +pub struct DebuginfodClient { + config: DebuginfodConfig, + http: reqwest::Client, +} + +impl DebuginfodClient { + pub fn new(config: DebuginfodConfig) -> Result { + let mut builder = reqwest::Client::builder().user_agent(config.user_agent.clone()); + if let Some(timeout) = config.timeout { + builder = builder.timeout(timeout); + } + let http = builder.build()?; + Ok(Self { config, http }) + } + + pub fn config(&self) -> &DebuginfodConfig { + &self.config + } + + pub async fn fetch_debuginfo(&self, build_id: &[u8]) -> Result> { + self.fetch_artifact(build_id, Artifact::Debuginfo).await + } + + pub async fn fetch_executable(&self, build_id: &[u8]) -> Result> { + self.fetch_artifact(build_id, Artifact::Executable).await + } + + pub async fn fetch_source( + &self, + build_id: &[u8], + source_path: impl AsRef, + ) -> Result> { + self.fetch_artifact( + build_id, + Artifact::Source { + path: source_path.as_ref(), + }, + ) + .await + } + + async fn fetch_artifact( + &self, + build_id: &[u8], + artifact: Artifact<'_>, + ) -> Result> { + if build_id.is_empty() { + return Err(DebuginfodError::EmptyBuildId); + } + + let build_id_hex = build_id_to_hex(build_id); + let cache_path = artifact.cache_path(self.config.cache_dir(), &build_id_hex)?; + if fs::metadata(&cache_path).await.is_ok() { + return Ok(Some(FetchedFile { + path: cache_path, + build_id: build_id_hex, + from_cache: true, + url: None, + })); + } + + let endpoint = artifact.endpoint_path(&build_id_hex)?; + for base_url in self.config.urls() { + let url = build_url(base_url, &endpoint)?; + tracing::debug!(%url, "querying debuginfod"); + + let response = match self.http.get(url.clone()).send().await { + Ok(response) => response, + Err(err) => { + tracing::warn!(%url, error=%err, "debuginfod request failed"); + continue; + } + }; + + match response.status() { + StatusCode::OK => { + match stream_response_to_cache(response, &cache_path, self.config.max_size()) + .await + { + Ok(()) => { + return Ok(Some(FetchedFile { + path: cache_path, + build_id: build_id_hex, + from_cache: false, + url: Some(url.to_string()), + })); + } + Err(DebuginfodError::Http(err)) => { + tracing::warn!(%url, error=%err, "debuginfod response download failed"); + continue; + } + Err(err) => return Err(err), + } + } + StatusCode::NOT_FOUND => { + tracing::debug!(%url, "debuginfod artifact not found"); + } + status => { + tracing::warn!(%url, %status, "debuginfod returned non-success status"); + } + } + } + + Ok(None) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FetchedFile { + pub path: PathBuf, + pub build_id: String, + pub from_cache: bool, + pub url: Option, +} + +#[derive(Debug, Copy, Clone)] +enum Artifact<'a> { + Debuginfo, + Executable, + Source { path: &'a str }, +} + +impl Artifact<'_> { + fn endpoint_path(&self, build_id_hex: &str) -> Result { + // debuginfod(8) defines: + // /buildid/BUILDID/debuginfo + // /buildid/BUILDID/executable + // /buildid/BUILDID/source/SOURCE/FILE + // where BUILDID is hexadecimal and SOURCE/FILE should be absolute. + match self { + Self::Debuginfo => Ok(format!("/buildid/{build_id_hex}/debuginfo")), + Self::Executable => Ok(format!("/buildid/{build_id_hex}/executable")), + Self::Source { path } => Ok(format!( + "/buildid/{}/source/{}", + build_id_hex, + encode_source_path_for_url(path)? + )), + } + } + + fn cache_path(&self, cache_dir: &Path, build_id_hex: &str) -> Result { + let build_id_dir = cache_dir.join(build_id_hex); + match self { + Self::Debuginfo => Ok(build_id_dir.join("debuginfo")), + Self::Executable => Ok(build_id_dir.join("executable")), + Self::Source { path } => source_cache_path(&build_id_dir, path), + } + } +} + +pub fn build_id_to_hex(build_id: &[u8]) -> String { + let mut hex = String::with_capacity(build_id.len() * 2); + for byte in build_id { + use std::fmt::Write; + let _ = write!(&mut hex, "{byte:02x}"); + } + hex +} + +pub fn parse_url_list(urls: I) -> Result> +where + I: IntoIterator, + S: AsRef, +{ + let mut parsed = Vec::new(); + for raw in urls { + let raw = raw.as_ref().trim(); + if raw.is_empty() || raw.starts_with("ima:") { + continue; + } + + let mut url = Url::parse(raw).map_err(|source| DebuginfodError::InvalidUrl { + raw: raw.to_string(), + source, + })?; + match url.scheme() { + "http" | "https" => {} + scheme => { + return Err(DebuginfodError::UnsupportedUrlScheme { + raw: raw.to_string(), + scheme: scheme.to_string(), + }); + } + } + + url.set_query(None); + url.set_fragment(None); + parsed.push(url); + } + Ok(parsed) +} + +fn build_url(base_url: &Url, endpoint_path: &str) -> Result { + let base = base_url.as_str().trim_end_matches('/'); + let endpoint = endpoint_path.trim_start_matches('/'); + let raw = format!("{base}/{endpoint}"); + Url::parse(&raw).map_err(|source| DebuginfodError::InvalidUrl { raw, source }) +} + +fn encode_source_path_for_url(source_path: &str) -> Result { + let rest = source_path + .strip_prefix('/') + .ok_or_else(|| DebuginfodError::InvalidSourcePath(source_path.to_string()))?; + + if rest.is_empty() { + return Err(DebuginfodError::InvalidSourcePath(source_path.to_string())); + } + + // debuginfod(8) says clients should %-escape source path bytes that are not + // RFC3986 section 2.3 "unreserved" characters. Slash stays unescaped here + // because the debuginfod endpoint treats SOURCE/FILE as path segments. + let mut encoded = String::with_capacity(rest.len()); + for byte in rest.bytes() { + match byte { + b'/' => encoded.push('/'), + b if is_unreserved_uri_byte(b) => encoded.push(byte as char), + b => { + use std::fmt::Write; + let _ = write!(&mut encoded, "%{b:02X}"); + } + } + } + Ok(encoded) +} + +fn source_cache_path(build_id_dir: &Path, source_path: &str) -> Result { + if !source_path.starts_with('/') { + return Err(DebuginfodError::InvalidSourcePath(source_path.to_string())); + } + + let mut path = build_id_dir.join("source"); + let mut saw_component = false; + for component in Path::new(source_path).components() { + match component { + Component::RootDir => {} + Component::Normal(part) => { + path.push(encode_path_component(part)); + saw_component = true; + } + Component::CurDir => { + path.push("%2E"); + saw_component = true; + } + Component::ParentDir => { + path.push("%2E%2E"); + saw_component = true; + } + Component::Prefix(_) => { + return Err(DebuginfodError::InvalidSourcePath(source_path.to_string())); + } + } + } + + if !saw_component { + return Err(DebuginfodError::InvalidSourcePath(source_path.to_string())); + } + + Ok(path) +} + +#[cfg(unix)] +fn encode_path_component(component: &OsStr) -> String { + use std::os::unix::ffi::OsStrExt; + percent_encode_component(component.as_bytes()) +} + +#[cfg(not(unix))] +fn encode_path_component(component: &OsStr) -> String { + percent_encode_component(component.to_string_lossy().as_bytes()) +} + +fn percent_encode_component(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(bytes.len()); + for &byte in bytes { + if is_unreserved_uri_byte(byte) { + encoded.push(byte as char); + } else { + use std::fmt::Write; + let _ = write!(&mut encoded, "%{byte:02X}"); + } + } + encoded +} + +fn is_unreserved_uri_byte(byte: u8) -> bool { + matches!( + byte, + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'.' + | b'_' + | b'~' + ) +} + +async fn stream_response_to_cache( + response: reqwest::Response, + cache_path: &Path, + max_size: Option, +) -> Result<()> { + if let Some(max_size) = max_size { + if response + .content_length() + .is_some_and(|content_length| content_length > max_size) + { + return Err(DebuginfodError::ResponseTooLarge { max_size }); + } + } + + let parent = cache_path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .await + .map_err(|source| DebuginfodError::Io { + path: parent.to_path_buf(), + source, + })?; + + let tmp_path = temporary_path(cache_path); + let result = write_stream_to_file(response, &tmp_path, max_size).await; + if let Err(error) = result { + let _ = fs::remove_file(&tmp_path).await; + return Err(error); + } + + fs::rename(&tmp_path, cache_path) + .await + .map_err(|source| DebuginfodError::Io { + path: cache_path.to_path_buf(), + source, + })?; + Ok(()) +} + +async fn write_stream_to_file( + response: reqwest::Response, + tmp_path: &Path, + max_size: Option, +) -> Result<()> { + let mut file = fs::File::create(tmp_path) + .await + .map_err(|source| DebuginfodError::Io { + path: tmp_path.to_path_buf(), + source, + })?; + let mut stream = response.bytes_stream(); + let mut total = 0_u64; + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + total = total.saturating_add(chunk.len() as u64); + if let Some(max_size) = max_size { + if total > max_size { + return Err(DebuginfodError::ResponseTooLarge { max_size }); + } + } + file.write_all(&chunk) + .await + .map_err(|source| DebuginfodError::Io { + path: tmp_path.to_path_buf(), + source, + })?; + } + + file.flush().await.map_err(|source| DebuginfodError::Io { + path: tmp_path.to_path_buf(), + source, + })?; + Ok(()) +} + +fn temporary_path(cache_path: &Path) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let filename = cache_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("debuginfod"); + cache_path.with_file_name(format!("{filename}.tmp-{}-{suffix}", std::process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use tempfile::TempDir; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + #[test] + fn build_id_hex_is_lowercase() { + assert_eq!(build_id_to_hex(&[0xab, 0xcd, 0x01, 0xef]), "abcd01ef"); + } + + #[test] + fn parse_urls_skips_ima_tags_and_trims_fragments() { + let urls = parse_url_list([ + "https://debuginfod.example/", + "ima:enforcing", + "http://localhost:8002/path?ignored=yes#fragment", + ]) + .unwrap(); + + assert_eq!(urls.len(), 2); + assert_eq!(urls[0].as_str(), "https://debuginfod.example/"); + assert_eq!(urls[1].as_str(), "http://localhost:8002/path"); + } + + #[test] + fn source_endpoint_percent_encodes_non_unreserved_chars() { + let endpoint = Artifact::Source { + path: "/usr/src/foo bar+/main.c", + } + .endpoint_path("abc123") + .unwrap(); + + assert_eq!( + endpoint, + "/buildid/abc123/source/usr/src/foo%20bar%2B/main.c" + ); + } + + #[test] + fn build_url_preserves_already_escaped_source_path() { + let base = Url::parse("https://debuginfod.example/prefix/").unwrap(); + let endpoint = Artifact::Source { + path: "/usr/src/foo bar+/main.c", + } + .endpoint_path("abc123") + .unwrap(); + let url = build_url(&base, &endpoint).unwrap(); + + assert_eq!( + url.as_str(), + "https://debuginfod.example/prefix/buildid/abc123/source/usr/src/foo%20bar%2B/main.c" + ); + } + + #[test] + fn source_cache_path_never_uses_parent_dir_components() { + let path = source_cache_path(Path::new("/cache/abc123"), "/zoo//../bar/foo.c").unwrap(); + + assert_eq!(path, Path::new("/cache/abc123/source/zoo/%2E%2E/bar/foo.c")); + } + + #[test] + fn source_path_must_be_absolute() { + let err = Artifact::Source { path: "relative.c" } + .endpoint_path("abc123") + .unwrap_err(); + + assert!(matches!(err, DebuginfodError::InvalidSourcePath(_))); + } + + #[tokio::test] + async fn fetch_debuginfo_downloads_and_reuses_cache() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let request_count = Arc::clone(&requests); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0_u8; 1024]; + let read = stream.read(&mut buffer).await.unwrap(); + let request = String::from_utf8_lossy(&buffer[..read]); + assert!(request.starts_with("GET /buildid/abcd/debuginfo HTTP/1.1")); + request_count.fetch_add(1, Ordering::SeqCst); + + let body = b"debug-data"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + }); + + let cache = TempDir::new().unwrap(); + let config = DebuginfodConfig::new([format!("http://{addr}")], cache.path()).unwrap(); + let client = DebuginfodClient::new(config).unwrap(); + + let first = client + .fetch_debuginfo(&[0xab, 0xcd]) + .await + .unwrap() + .unwrap(); + assert!(!first.from_cache); + assert_eq!(fs::read(&first.path).await.unwrap(), b"debug-data"); + + let second = client + .fetch_debuginfo(&[0xab, 0xcd]) + .await + .unwrap() + .unwrap(); + assert!(second.from_cache); + assert_eq!(first.path, second.path); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn max_size_rejects_large_response_before_cache_commit() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer).await.unwrap(); + + let body = b"too-large"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(response.as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + }); + + let cache = TempDir::new().unwrap(); + let config = DebuginfodConfig::new([format!("http://{addr}")], cache.path()) + .unwrap() + .with_max_size(Some(4)); + let client = DebuginfodClient::new(config).unwrap(); + + let err = client.fetch_debuginfo(&[0xab, 0xcd]).await.unwrap_err(); + assert!(matches!( + err, + DebuginfodError::ResponseTooLarge { max_size: 4 } + )); + assert!(!cache.path().join("abcd").join("debuginfo").exists()); + } + + #[tokio::test] + async fn request_timeout_returns_not_found_fallback() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0_u8; 1024]; + let _ = stream.read(&mut buffer).await.unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + let body = b"debug-data"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.write_all(body).await; + }); + + let cache = TempDir::new().unwrap(); + let config = DebuginfodConfig::new([format!("http://{addr}")], cache.path()) + .unwrap() + .with_timeout(Duration::from_millis(50)); + let client = DebuginfodClient::new(config).unwrap(); + + let result = client.fetch_debuginfo(&[0xab, 0xcd]).await.unwrap(); + assert!(result.is_none()); + assert!(!cache.path().join("abcd").join("debuginfo").exists()); + } +} diff --git a/ghostscope-dwarf/Cargo.toml b/ghostscope-dwarf/Cargo.toml index 8509d78d..468e9cc9 100644 --- a/ghostscope-dwarf/Cargo.toml +++ b/ghostscope-dwarf/Cargo.toml @@ -23,6 +23,7 @@ thiserror.workspace = true ghostscope-platform = { version = "0.1.4", path = "../ghostscope-platform" } ghostscope-protocol = { version = "0.1.4", path = "../ghostscope-protocol" } ghostscope-process = { version = "0.1.4", path = "../ghostscope-process" } +ghostscope-debuginfod = { version = "0.1.4", path = "../ghostscope-debuginfod" } rustc-demangle = "0.1" cpp_demangle = "0.4" rayon = "1.10" diff --git a/ghostscope-dwarf/src/analyzer/mod.rs b/ghostscope-dwarf/src/analyzer/mod.rs index 73d8ef01..e5346d4f 100644 --- a/ghostscope-dwarf/src/analyzer/mod.rs +++ b/ghostscope-dwarf/src/analyzer/mod.rs @@ -8,9 +8,11 @@ use crate::{ objfile::LoadedObjfile, semantics::{CompactUnwindRow, CompactUnwindTable, PcContext, VisibleVariable}, }; +use ghostscope_debuginfod::DebuginfodClient; use object::{Object, ObjectSection}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Arc; mod plan_global; mod plan_pc; @@ -250,6 +252,27 @@ impl DwarfAnalyzer { allow_loose_debug_match: bool, progress_callback: F, ) -> Result + where + F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, + { + Self::from_pid_parallel_with_config_and_debuginfod( + pid, + debug_search_paths, + allow_loose_debug_match, + None, + progress_callback, + ) + .await + } + + /// Create DWARF analyzer from PID with debug search paths, debuginfod, and progress callback. + pub async fn from_pid_parallel_with_config_and_debuginfod( + pid: u32, + debug_search_paths: &[String], + allow_loose_debug_match: bool, + debuginfod_client: Option>, + progress_callback: F, + ) -> Result where F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, { @@ -297,6 +320,7 @@ impl DwarfAnalyzer { loader = loader.with_debug_search_paths(debug_search_paths.to_vec()); } loader = loader.with_loose_debug_match(allow_loose_debug_match); + loader = loader.with_debuginfod_client(debuginfod_client); let modules = loader .with_progress_callback(progress_callback) @@ -323,10 +347,27 @@ impl DwarfAnalyzer { debug_search_paths: &[String], allow_loose_debug_match: bool, ) -> Result { - Self::from_exec_path_with_config_and_progress( + Self::from_exec_path_with_config_and_debuginfod( + exec_path, + debug_search_paths, + allow_loose_debug_match, + None, + ) + .await + } + + /// Create DWARF analyzer from executable path with debug search paths and debuginfod. + pub async fn from_exec_path_with_config_and_debuginfod>( + exec_path: P, + debug_search_paths: &[String], + allow_loose_debug_match: bool, + debuginfod_client: Option>, + ) -> Result { + Self::from_exec_path_with_config_and_debuginfod_and_progress( exec_path, debug_search_paths, allow_loose_debug_match, + debuginfod_client, |_event| {}, ) .await @@ -339,6 +380,28 @@ impl DwarfAnalyzer { allow_loose_debug_match: bool, progress_callback: F, ) -> Result + where + P: AsRef, + F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, + { + Self::from_exec_path_with_config_and_debuginfod_and_progress( + exec_path, + debug_search_paths, + allow_loose_debug_match, + None, + progress_callback, + ) + .await + } + + /// Create DWARF analyzer from executable path with debug search paths, debuginfod, and progress callback. + pub async fn from_exec_path_with_config_and_debuginfod_and_progress( + exec_path: P, + debug_search_paths: &[String], + allow_loose_debug_match: bool, + debuginfod_client: Option>, + progress_callback: F, + ) -> Result where P: AsRef, F: Fn(ModuleLoadingEvent) + Send + Sync + 'static, @@ -380,6 +443,7 @@ impl DwarfAnalyzer { module_mapping, debug_search_paths, allow_loose_debug_match, + debuginfod_client, ) .await { diff --git a/ghostscope-dwarf/src/binary/mapped_file.rs b/ghostscope-dwarf/src/binary/mapped_file.rs index f0dc6811..64cbfd43 100644 --- a/ghostscope-dwarf/src/binary/mapped_file.rs +++ b/ghostscope-dwarf/src/binary/mapped_file.rs @@ -82,7 +82,6 @@ pub(crate) fn dwarf_reader_from_arc(bytes: Arc<[u8]>) -> DwarfReader { dwarf_reader_from_arc_with_endian(bytes, gimli::RunTimeEndian::Little) } -#[cfg(test)] pub(crate) fn dwarf_reader_from_arc_with_endian( bytes: Arc<[u8]>, endian: DwarfEndian, diff --git a/ghostscope-dwarf/src/binary/mod.rs b/ghostscope-dwarf/src/binary/mod.rs index d84fc340..0187515a 100644 --- a/ghostscope-dwarf/src/binary/mod.rs +++ b/ghostscope-dwarf/src/binary/mod.rs @@ -2,9 +2,9 @@ pub(crate) mod debuglink; pub(crate) mod mapped_file; pub(crate) use debuglink::try_load_debug_file; +#[cfg(test)] +pub(crate) use mapped_file::dwarf_reader_from_arc; pub(crate) use mapped_file::{ - dwarf_endian_from_object, empty_dwarf_reader_with_endian, DwarfData, DwarfEndian, DwarfReader, - MappedFile, + dwarf_endian_from_object, dwarf_reader_from_arc_with_endian, empty_dwarf_reader_with_endian, + DwarfData, DwarfEndian, DwarfReader, MappedFile, }; -#[cfg(test)] -pub(crate) use mapped_file::{dwarf_reader_from_arc, dwarf_reader_from_arc_with_endian}; diff --git a/ghostscope-dwarf/src/loader.rs b/ghostscope-dwarf/src/loader.rs index b387e109..a7100726 100644 --- a/ghostscope-dwarf/src/loader.rs +++ b/ghostscope-dwarf/src/loader.rs @@ -5,6 +5,7 @@ use crate::{ core::{mapping::ModuleMapping, Result}, objfile::LoadedObjfile, }; +use ghostscope_debuginfod::DebuginfodClient; use std::sync::Arc; use tokio::task; @@ -17,6 +18,8 @@ pub struct LoadConfig { pub debug_search_paths: Vec, /// Allow non-strict debug file matching (CRC/Build-ID) pub allow_loose_debug_match: bool, + /// Optional debuginfod client for build-id based debug file lookup. + pub debuginfod_client: Option>, } impl Default for LoadConfig { @@ -25,6 +28,7 @@ impl Default for LoadConfig { max_module_concurrency: num_cpus::get(), debug_search_paths: Vec::new(), allow_loose_debug_match: false, + debuginfod_client: None, } } } @@ -36,6 +40,7 @@ impl LoadConfig { max_module_concurrency: num_cpus::get(), debug_search_paths: Vec::new(), allow_loose_debug_match: false, + debuginfod_client: None, } } } @@ -73,6 +78,12 @@ impl ModuleLoader { self } + /// Set optional debuginfod client for build-id based debug file fallback. + pub fn with_debuginfod_client(mut self, client: Option>) -> Self { + self.config.debuginfod_client = client; + self + } + /// Load with progress callback - always parallel pub async fn load_with_progress(self, progress_callback: F) -> Result> where @@ -110,6 +121,7 @@ impl ModuleLoader { let progress_callback = Arc::new(progress_callback); let debug_search_paths = Arc::new(self.config.debug_search_paths.clone()); let allow_loose = self.config.allow_loose_debug_match; + let debuginfod_client = self.config.debuginfod_client.clone(); let tasks: Vec<_> = self .mappings @@ -119,6 +131,7 @@ impl ModuleLoader { let semaphore = semaphore.clone(); let progress_callback = progress_callback.clone(); let debug_search_paths = debug_search_paths.clone(); + let debuginfod_client = debuginfod_client.clone(); task::spawn(async move { let _permit = semaphore.acquire().await.unwrap(); @@ -134,9 +147,13 @@ impl ModuleLoader { let start_time = std::time::Instant::now(); - let result = - LoadedObjfile::load_parallel(mapping, &debug_search_paths, allow_loose) - .await; + let result = LoadedObjfile::load_parallel( + mapping, + &debug_search_paths, + allow_loose, + debuginfod_client, + ) + .await; let load_time_ms = start_time.elapsed().as_millis() as u64; diff --git a/ghostscope-dwarf/src/objfile/function_lookup.rs b/ghostscope-dwarf/src/objfile/function_lookup.rs index 46f518b5..028009ce 100644 --- a/ghostscope-dwarf/src/objfile/function_lookup.rs +++ b/ghostscope-dwarf/src/objfile/function_lookup.rs @@ -20,6 +20,17 @@ impl LoadedObjfile { addresses.extend(self.compute_addresses_for_entry(entry)); } + if addresses.is_empty() { + if let Some(symbol_starts) = self.text_symbol_starts_by_name.get(name) { + tracing::debug!( + "LoadedObjfile: function '{}' has no concrete DWARF ranges; falling back to {} text symbol address(es)", + name, + symbol_starts.len() + ); + addresses.extend(symbol_starts.iter().copied()); + } + } + tracing::debug!( "LoadedObjfile: function '{}' resolved to {} addresses: {:?}", name, diff --git a/ghostscope-dwarf/src/objfile/loading.rs b/ghostscope-dwarf/src/objfile/loading.rs index 4465d01b..0e28871d 100644 --- a/ghostscope-dwarf/src/objfile/loading.rs +++ b/ghostscope-dwarf/src/objfile/loading.rs @@ -1,15 +1,16 @@ use super::LoadedObjfile; use crate::{ binary::{ - dwarf_endian_from_object, empty_dwarf_reader_with_endian, try_load_debug_file, DwarfData, - MappedFile, + dwarf_endian_from_object, dwarf_reader_from_arc_with_endian, + empty_dwarf_reader_with_endian, try_load_debug_file, DwarfData, MappedFile, }, core::{mapping::ModuleMapping, Result}, index::{BlockIndex, CfiIndex, TypeNameIndex}, parser::DetailedParser, }; +use ghostscope_debuginfod::{build_id_to_hex, DebuginfodClient}; use object::{Object, ObjectSection, ObjectSymbol, SymbolKind}; -use std::{collections::HashMap, sync::Arc, time::Instant}; +use std::{borrow::Cow, collections::HashMap, path::Path, sync::Arc, time::Instant}; impl LoadedObjfile { /// Parallel loading: debug_info || debug_line || CFI simultaneously @@ -17,10 +18,16 @@ impl LoadedObjfile { module_mapping: ModuleMapping, debug_search_paths: &[String], allow_loose_debug_match: bool, + debuginfod_client: Option>, ) -> Result { tracing::info!("Parallel loading for: {}", module_mapping.path.display()); - Self::load_internal_parallel(module_mapping, debug_search_paths, allow_loose_debug_match) - .await + Self::load_internal_parallel( + module_mapping, + debug_search_paths, + allow_loose_debug_match, + debuginfod_client, + ) + .await } /// Parallel internal load implementation - true parallelism for debug_info || debug_line || CFI @@ -28,6 +35,7 @@ impl LoadedObjfile { module_mapping: ModuleMapping, debug_search_paths: &[String], allow_loose_debug_match: bool, + debuginfod_client: Option>, ) -> Result { let load_started_at = Instant::now(); tracing::debug!( @@ -66,11 +74,24 @@ impl LoadedObjfile { (Arc::new(debug_dwarf), debug_mapped) } None => { - tracing::warn!( - "No separate debug file found for: {}", - module_mapping.path.display() - ); - (Arc::new(dwarf_data), Arc::clone(&binary_mapped)) + match Self::try_load_debuginfod_debug_file( + debuginfod_client.as_ref(), + &binary_mapped, + &module_mapping.path, + ) + .await + { + Some((debug_dwarf, debug_mapped)) => { + (Arc::new(debug_dwarf), debug_mapped) + } + None => { + tracing::warn!( + "No separate debug file found for: {}", + module_mapping.path.display() + ); + (Arc::new(dwarf_data), Arc::clone(&binary_mapped)) + } + } } } } @@ -242,6 +263,191 @@ impl LoadedObjfile { matches!(dwarf.units().next(), Ok(Some(_))) } + async fn try_load_debuginfod_debug_file( + debuginfod_client: Option<&Arc>, + binary_mapped: &Arc, + module_path: &Path, + ) -> Option<(DwarfData, Arc)> { + let client = debuginfod_client?; + let build_id = match Self::build_id_for_debuginfod(binary_mapped, module_path) { + Some(build_id) => build_id, + None => return None, + }; + let build_id_hex = build_id_to_hex(&build_id); + + tracing::info!( + "Trying debuginfod debug info for {} (build-id={})", + module_path.display(), + build_id_hex + ); + + let fetched = match client.fetch_debuginfo(&build_id).await { + Ok(Some(fetched)) => fetched, + Ok(None) => { + tracing::debug!( + "debuginfod had no debug info for {} (build-id={})", + module_path.display(), + build_id_hex + ); + return None; + } + Err(err) => { + tracing::warn!( + "debuginfod lookup failed for {} (build-id={}): {}", + module_path.display(), + build_id_hex, + err + ); + return None; + } + }; + + tracing::info!( + "debuginfod returned debug info for {} from {} (path={}, from_cache={})", + module_path.display(), + fetched.url.as_deref().unwrap_or(""), + fetched.path.display(), + fetched.from_cache + ); + + let debug_mapped = match MappedFile::open(&fetched.path) { + Ok(mapped) => Arc::new(mapped), + Err(err) => { + tracing::warn!( + "Failed to open debuginfod debug file {} for {}: {}", + fetched.path.display(), + module_path.display(), + err + ); + return None; + } + }; + + if !Self::debuginfod_debug_file_matches_build_id(&debug_mapped, &build_id, module_path) { + return None; + } + + let debug_dwarf = match Self::load_dwarf_sections(&debug_mapped) { + Ok(dwarf) => dwarf, + Err(err) => { + tracing::warn!( + "Failed to load DWARF sections from debuginfod debug file {} for {}: {}", + debug_mapped.path.display(), + module_path.display(), + err + ); + return None; + } + }; + + if !Self::has_debug_info(&debug_dwarf) { + tracing::warn!( + "Ignoring debuginfod debug file {} for {} because it contains no .debug_info", + debug_mapped.path.display(), + module_path.display() + ); + return None; + } + + tracing::info!( + "Loading DWARF from debuginfod debug file: {}", + debug_mapped.path.display() + ); + Some((debug_dwarf, debug_mapped)) + } + + fn build_id_for_debuginfod(binary_mapped: &MappedFile, module_path: &Path) -> Option> { + let object = match binary_mapped.parse_object() { + Ok(object) => object, + Err(err) => { + tracing::warn!( + "Failed to parse object while reading build-id for {}: {}", + module_path.display(), + err + ); + return None; + } + }; + + match object.build_id() { + Ok(Some(build_id)) => Some(build_id.to_vec()), + Ok(None) => { + tracing::debug!( + "No build-id in {}; skipping debuginfod", + module_path.display() + ); + None + } + Err(err) => { + tracing::warn!( + "Failed to read build-id from {}; skipping debuginfod: {}", + module_path.display(), + err + ); + None + } + } + } + + fn debuginfod_debug_file_matches_build_id( + debug_mapped: &MappedFile, + expected_build_id: &[u8], + module_path: &Path, + ) -> bool { + let expected = build_id_to_hex(expected_build_id); + let object = match debug_mapped.parse_object() { + Ok(object) => object, + Err(err) => { + tracing::warn!( + "Ignoring debuginfod debug file {} for {}: failed to parse object: {}", + debug_mapped.path.display(), + module_path.display(), + err + ); + return false; + } + }; + + match object.build_id() { + Ok(Some(actual_build_id)) if actual_build_id == expected_build_id => { + tracing::info!( + "debuginfod build-id verification passed for {}: {}", + debug_mapped.path.display(), + expected + ); + true + } + Ok(Some(actual_build_id)) => { + tracing::warn!( + "Ignoring debuginfod debug file {} for {}: build-id mismatch expected={}, actual={}", + debug_mapped.path.display(), + module_path.display(), + expected, + build_id_to_hex(actual_build_id) + ); + false + } + Ok(None) => { + tracing::warn!( + "Ignoring debuginfod debug file {} for {}: missing build-id, expected={}", + debug_mapped.path.display(), + module_path.display(), + expected + ); + false + } + Err(err) => { + tracing::warn!( + "Ignoring debuginfod debug file {} for {}: failed to read build-id: {}", + debug_mapped.path.display(), + module_path.display(), + err + ); + false + } + } + } + fn collect_text_symbol_starts(binary_mapped: &MappedFile) -> HashMap> { let object = match binary_mapped.parse_object() { Ok(object) => object, @@ -256,18 +462,26 @@ impl LoadedObjfile { }; let mut by_name: HashMap> = HashMap::new(); - for symbol in object.symbols() { + let mut collect_symbol = |symbol: object::Symbol<'_, '_, &[u8]>| { if symbol.kind() != SymbolKind::Text { - continue; + return; } let Ok(name) = symbol.name() else { - continue; + return; }; by_name .entry(name.to_string()) .or_default() .push(symbol.address()); + }; + + for symbol in object.symbols() { + collect_symbol(symbol); + } + + for symbol in object.dynamic_symbols() { + collect_symbol(symbol); } for starts in by_name.values_mut() { @@ -284,7 +498,22 @@ impl LoadedObjfile { let load_section = |id: gimli::SectionId| -> Result<_> { if let Some(section) = object.section_by_name(id.name()) { - if let Some((start, size)) = section.file_range() { + let compressed_range = section.compressed_file_range()?; + if compressed_range.format != object::read::CompressionFormat::None { + let data = section.uncompressed_data().map_err(|err| { + anyhow::anyhow!( + "Failed to decompress DWARF section {} in {}: {}", + id.name(), + file_data.path.display(), + err + ) + })?; + let bytes: Arc<[u8]> = match data { + Cow::Borrowed(bytes) => Arc::from(bytes), + Cow::Owned(bytes) => Arc::from(bytes), + }; + Ok(dwarf_reader_from_arc_with_endian(bytes, endian)) + } else if let Some((start, size)) = section.file_range() { MappedFile::dwarf_reader_range(Arc::clone(file_data), start, size, endian) .ok_or_else(|| { anyhow::anyhow!("Invalid DWARF section range for {}", id.name()) diff --git a/ghostscope/Cargo.toml b/ghostscope/Cargo.toml index 774af13f..afe7aa17 100644 --- a/ghostscope/Cargo.toml +++ b/ghostscope/Cargo.toml @@ -18,6 +18,7 @@ ghostscope-compiler = { version = "0.1.4", path = "../ghostscope-compiler" } ghostscope-loader = { version = "0.1.4", path = "../ghostscope-loader" } ghostscope-ui = { version = "0.1.4", path = "../ghostscope-ui" } ghostscope-dwarf = { version = "0.1.4", path = "../ghostscope-dwarf" } +ghostscope-debuginfod = { version = "0.1.4", path = "../ghostscope-debuginfod" } ghostscope-protocol = { version = "0.1.4", path = "../ghostscope-protocol" } ghostscope-process = { version = "0.1.4", path = "../ghostscope-process" } dirs = "5.0" diff --git a/ghostscope/src/cli/script_runtime.rs b/ghostscope/src/cli/script_runtime.rs index e8985a98..5683ab1a 100644 --- a/ghostscope/src/cli/script_runtime.rs +++ b/ghostscope/src/cli/script_runtime.rs @@ -408,6 +408,7 @@ mod tests { ebpf_max_messages: 1000, dwarf_search_paths: Vec::new(), dwarf_allow_loose_debug_match: false, + dwarf_debuginfod: Default::default(), ebpf_config: EbpfConfig::default(), source: Default::default(), config_file_path: None, diff --git a/ghostscope/src/config/args.rs b/ghostscope/src/config/args.rs index e6a5a240..a8c7b9d3 100644 --- a/ghostscope/src/config/args.rs +++ b/ghostscope/src/config/args.rs @@ -3,6 +3,8 @@ use clap::{Args as ClapArgs, CommandFactory, FromArgMatches, Parser, Subcommand, use std::path::PathBuf; use tracing::warn; +use crate::config::settings::DebuginfodMode; + #[derive(Debug, Clone, Copy, PartialEq, ValueEnum, serde::Serialize, serde::Deserialize)] pub enum LayoutMode { /// Horizontal layout: panels arranged side by side (4:3:3 ratio) @@ -188,6 +190,26 @@ pub struct Args { #[arg(long, action = clap::ArgAction::SetTrue)] pub allow_loose_debug_match: bool, + /// debuginfod mode: off, on, or ask. ask is reserved for future TUI support. + #[arg(long, value_name = "MODE", value_enum)] + pub debuginfod: Option, + + /// debuginfod server URL. May be passed more than once. + #[arg(long = "debuginfod-url", value_name = "URL")] + pub debuginfod_urls: Vec, + + /// debuginfod cache directory. + #[arg(long = "debuginfod-cache-dir", value_name = "DIR")] + pub debuginfod_cache_dir: Option, + + /// debuginfod request timeout in seconds. Use 0 for no request timeout. + #[arg(long = "debuginfod-timeout-secs", value_name = "SECONDS")] + pub debuginfod_timeout_secs: Option, + + /// debuginfod maximum response size in bytes. Use 0 for no explicit cap. + #[arg(long = "debuginfod-max-size", value_name = "BYTES")] + pub debuginfod_max_size: Option, + /// Script to execute (inline script - optional for TUI mode) #[arg(long, short = 's', value_name = "SCRIPT")] pub script: Option, @@ -308,6 +330,11 @@ pub struct ParsedArgs { pub force_perf_event_array: bool, pub enable_sysmon_for_shared_lib: bool, pub allow_loose_debug_match: bool, + pub debuginfod: Option, + pub debuginfod_urls: Vec, + pub debuginfod_cache_dir: Option, + pub debuginfod_timeout_secs: Option, + pub debuginfod_max_size: Option, pub source_panel: bool, pub no_source_panel: bool, } @@ -479,6 +506,11 @@ impl Args { force_perf_event_array: parsed.force_perf_event_array, enable_sysmon_for_shared_lib: parsed.enable_sysmon_shared_lib, allow_loose_debug_match: parsed.allow_loose_debug_match, + debuginfod: parsed.debuginfod, + debuginfod_urls: parsed.debuginfod_urls, + debuginfod_cache_dir: parsed.debuginfod_cache_dir, + debuginfod_timeout_secs: parsed.debuginfod_timeout_secs, + debuginfod_max_size: parsed.debuginfod_max_size, source_panel: parsed.source_panel, no_source_panel: parsed.no_source_panel, } @@ -652,6 +684,7 @@ mod tests { use super::{ Args, BpffsCommand, BpffsPruneArgs, ParsedCommand, ScriptOutputMode, ScriptTimestampFormat, }; + use crate::config::settings::DebuginfodMode; #[test] fn parses_bpffs_prune_subcommand() { @@ -817,6 +850,47 @@ mod tests { } } + #[test] + fn parses_debuginfod_flags() { + let parsed = Args::parse_args_from(vec![ + "ghostscope".to_string(), + "--pid".to_string(), + "1234".to_string(), + "--debuginfod".to_string(), + "on".to_string(), + "--debuginfod-url".to_string(), + "https://debuginfod.ubuntu.com".to_string(), + "--debuginfod-url".to_string(), + "https://debuginfod.archlinux.org".to_string(), + "--debuginfod-cache-dir".to_string(), + "/tmp/gs-debug-cache".to_string(), + "--debuginfod-timeout-secs".to_string(), + "5".to_string(), + "--debuginfod-max-size".to_string(), + "1048576".to_string(), + ]); + + match parsed { + ParsedCommand::Trace(args) => { + assert_eq!(args.debuginfod, Some(DebuginfodMode::On)); + assert_eq!( + args.debuginfod_urls, + vec![ + "https://debuginfod.ubuntu.com".to_string(), + "https://debuginfod.archlinux.org".to_string() + ] + ); + assert_eq!( + args.debuginfod_cache_dir, + Some(PathBuf::from("/tmp/gs-debug-cache")) + ); + assert_eq!(args.debuginfod_timeout_secs, Some(5)); + assert_eq!(args.debuginfod_max_size, Some(1048576)); + } + other => panic!("unexpected parse result: {other:?}"), + } + } + #[test] fn parses_status_flags() { let parsed = Args::parse_args_from(vec![ diff --git a/ghostscope/src/config/settings.rs b/ghostscope/src/config/settings.rs index 2de66e02..c5352598 100644 --- a/ghostscope/src/config/settings.rs +++ b/ghostscope/src/config/settings.rs @@ -144,6 +144,42 @@ pub struct DwarfConfig { /// Default: false (strict). When true, CRC/Build-ID mismatches are allowed with WARN logs. #[serde(default)] pub allow_loose_debug_match: bool, + /// debuginfod client configuration. + #[serde(default)] + pub debuginfod: DwarfDebuginfodConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, clap::ValueEnum, Default)] +#[serde(rename_all = "lowercase")] +pub enum DebuginfodMode { + /// Never use debuginfod. + #[default] + Off, + /// Use debuginfod when URLs are available. + On, + /// Reserved for future TUI confirmation support. + Ask, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct DwarfDebuginfodConfig { + /// Whether debuginfod is allowed. + #[serde(default)] + pub enabled: DebuginfodMode, + /// debuginfod server URLs. Empty means use DEBUGINFOD_URLS when enabled. + #[serde(default)] + pub urls: Vec, + /// debuginfod cache directory. Empty/absent means use debuginfod-compatible defaults. + #[serde(default)] + pub cache_dir: Option, + /// Request timeout in seconds. Absent means DEBUGINFOD_TIMEOUT or built-in default. + /// Zero means no request timeout. + #[serde(default)] + pub timeout_secs: Option, + /// Maximum response size in bytes. Absent means DEBUGINFOD_MAXSIZE or no cap. + /// Zero means no explicit cap. + #[serde(default)] + pub max_size_bytes: Option, } /// Source code path configuration @@ -404,6 +440,7 @@ impl Default for DwarfConfig { Self { search_paths: default_debug_search_paths(), allow_loose_debug_match: false, + debuginfod: DwarfDebuginfodConfig::default(), } } } diff --git a/ghostscope/src/config/user.rs b/ghostscope/src/config/user.rs index c2ae18c8..618dca5e 100644 --- a/ghostscope/src/config/user.rs +++ b/ghostscope/src/config/user.rs @@ -1,10 +1,11 @@ use std::path::PathBuf; use anyhow::Result; -use tracing::info; +use tracing::{info, warn}; use crate::config::{ - CliColorMode, Config, LayoutMode, ParsedArgs, ScriptOutputMode, ScriptTimestampFormat, + settings::DebuginfodMode, CliColorMode, Config, LayoutMode, ParsedArgs, ScriptOutputMode, + ScriptTimestampFormat, }; /// Immutable user-provided configuration merged from CLI arguments and config file. @@ -48,6 +49,7 @@ pub struct UserConfig { // DWARF configuration pub dwarf_search_paths: Vec, pub dwarf_allow_loose_debug_match: bool, + pub dwarf_debuginfod: ResolvedDebuginfodConfig, // eBPF configuration pub ebpf_config: crate::config::settings::EbpfConfig, @@ -62,6 +64,7 @@ pub struct UserConfig { impl UserConfig { /// Create merged user configuration from parsed arguments and config file. pub fn new(args: ParsedArgs, config: Config) -> Self { + let dwarf_debuginfod = ResolvedDebuginfodConfig::resolve(&args, &config); let log_file = args .log_file .unwrap_or_else(|| PathBuf::from(&config.general.log_file)); @@ -180,6 +183,7 @@ impl UserConfig { } else { config.dwarf.allow_loose_debug_match }, + dwarf_debuginfod, ebpf_config: { let mut ebpf_config = config.ebpf; if args.force_perf_event_array { @@ -284,6 +288,170 @@ impl UserConfig { } } +/// Fully resolved debuginfod configuration. +/// +/// The debuginfod client crate is deliberately policy-free. GhostScope resolves +/// CLI/config/env/default precedence here, then later passes explicit values to +/// `ghostscope-debuginfod`. +#[derive(Debug, Clone)] +pub struct ResolvedDebuginfodConfig { + pub mode: DebuginfodMode, + pub urls: Vec, + pub cache_dir: Option, + /// None means no request timeout. + pub timeout_secs: Option, + /// None means no explicit client-side response size cap. + pub max_size_bytes: Option, +} + +impl ResolvedDebuginfodConfig { + pub fn is_effectively_enabled(&self) -> bool { + self.mode == DebuginfodMode::On && !self.urls.is_empty() + } + + fn resolve(args: &ParsedArgs, config: &Config) -> Self { + let mode = args.debuginfod.unwrap_or(config.dwarf.debuginfod.enabled); + + if mode == DebuginfodMode::Off { + return Self { + mode, + urls: Vec::new(), + cache_dir: None, + timeout_secs: None, + max_size_bytes: None, + }; + } + + let urls = if !args.debuginfod_urls.is_empty() { + args.debuginfod_urls.clone() + } else if !config.dwarf.debuginfod.urls.is_empty() { + config.dwarf.debuginfod.urls.clone() + } else { + env_list("DEBUGINFOD_URLS") + }; + + let cache_dir = args + .debuginfod_cache_dir + .clone() + .filter(|path| !path.as_os_str().is_empty()) + .or_else(|| { + config + .dwarf + .debuginfod + .cache_dir + .clone() + .filter(|path| !path.as_os_str().is_empty()) + }) + .or_else(|| env_path("DEBUGINFOD_CACHE_PATH")) + .or_else(default_debuginfod_cache_dir); + + let timeout_secs = first_u64( + args.debuginfod_timeout_secs, + config.dwarf.debuginfod.timeout_secs, + env_u64("DEBUGINFOD_TIMEOUT"), + ) + .or(Some(5)) + .and_then(nonzero_u64); + + let max_size_bytes = first_u64( + args.debuginfod_max_size, + config.dwarf.debuginfod.max_size_bytes, + env_u64("DEBUGINFOD_MAXSIZE"), + ) + .and_then(nonzero_u64); + + if mode == DebuginfodMode::Ask { + warn!( + "debuginfod mode 'ask' is reserved for future TUI confirmation support; \ + debuginfod will not be used until ask-mode is implemented" + ); + } else if urls.is_empty() { + info!( + "debuginfod is enabled but no URLs were configured; skipping debuginfod fallback" + ); + } + + Self { + mode, + urls, + cache_dir, + timeout_secs, + max_size_bytes, + } + } +} + +impl Default for ResolvedDebuginfodConfig { + fn default() -> Self { + Self { + mode: DebuginfodMode::Off, + urls: Vec::new(), + cache_dir: None, + timeout_secs: None, + max_size_bytes: None, + } + } +} + +fn env_list(name: &str) -> Vec { + std::env::var(name) + .ok() + .map(|value| { + value + .split_whitespace() + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn env_path(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) +} + +fn env_u64(name: &str) -> Option { + match std::env::var(name) { + Ok(value) if !value.trim().is_empty() => match value.parse::() { + Ok(parsed) => Some(parsed), + Err(error) => { + warn!("Ignoring invalid {name} value '{value}': {error}"); + None + } + }, + _ => None, + } +} + +fn first_u64(cli: Option, config: Option, env: Option) -> Option { + cli.or(config).or(env) +} + +fn nonzero_u64(value: u64) -> Option { + (value != 0).then_some(value) +} + +fn default_debuginfod_cache_dir() -> Option { + std::env::var("XDG_CACHE_HOME") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(|value| PathBuf::from(value).join("debuginfod_client")) + .or_else(|| { + dirs::home_dir().map(|home| { + let deprecated = home.join(".debuginfod_client_cache"); + if deprecated.exists() { + deprecated + } else { + home.join(".cache").join("debuginfod_client") + } + }) + }) + .or_else(|| Some(std::env::temp_dir().join("debuginfod_client"))) +} + fn default_enable_logging_for_mode(is_script_mode: bool) -> bool { !is_script_mode } diff --git a/ghostscope/src/core/session.rs b/ghostscope/src/core/session.rs index e19e6f19..7a76e54a 100644 --- a/ghostscope/src/core/session.rs +++ b/ghostscope/src/core/session.rs @@ -2,10 +2,12 @@ use crate::config::{ParsedArgs, PidViews, ResolvedConfig}; use crate::source_path::SourcePathResolver; use crate::trace::TraceManager; use anyhow::Result; +use ghostscope_debuginfod::{DebuginfodClient, DebuginfodConfig}; use ghostscope_dwarf::{DwarfAnalyzer, ModuleStats}; use ghostscope_process::{ProcessManager, ProcessSysmon, SysmonConfig}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use std::time::Duration; use tracing::{info, warn}; #[derive(Debug, Clone)] @@ -85,6 +87,26 @@ impl GhostSession { "Session runtime environment: {}", cfg.runtime.runtime_env.compact_display() ); + let debuginfod = &cfg.dwarf_debuginfod; + info!( + "Session debuginfod config: mode={:?}, effective={}, urls={}, cache_dir={}, timeout_secs={}, max_size_bytes={}", + debuginfod.mode, + debuginfod.is_effectively_enabled(), + debuginfod.urls.len(), + debuginfod + .cache_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_string()), + debuginfod + .timeout_secs + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + debuginfod + .max_size_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); if let Some(filter) = cfg.runtime.pid_filter_spec { info!("Session PID filter spec: {:?}", filter); } @@ -186,20 +208,61 @@ impl GhostSession { .unwrap_or(false) } + fn build_debuginfod_client(&self) -> Result>> { + let Some(config) = self.config.as_ref() else { + return Ok(None); + }; + let debuginfod = &config.dwarf_debuginfod; + if !debuginfod.is_effectively_enabled() { + return Ok(None); + } + + let Some(cache_dir) = debuginfod.cache_dir.as_ref() else { + warn!("debuginfod is enabled but no cache directory was resolved; skipping"); + return Ok(None); + }; + + let mut client_config = + DebuginfodConfig::new(debuginfod.urls.iter().map(String::as_str), cache_dir)?; + client_config = match debuginfod.timeout_secs { + Some(timeout_secs) => client_config.with_timeout(Duration::from_secs(timeout_secs)), + None => client_config.without_timeout(), + }; + client_config = client_config.with_max_size(debuginfod.max_size_bytes); + + info!( + "debuginfod fallback enabled: urls={}, cache_dir={}, timeout_secs={}, max_size_bytes={}", + debuginfod.urls.len(), + cache_dir.display(), + debuginfod + .timeout_secs + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + debuginfod + .max_size_bytes + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + + Ok(Some(Arc::new(DebuginfodClient::new(client_config)?))) + } + /// Load binary and perform DWARF analysis using parallel loading (TUI mode) pub async fn load_binary_parallel(&mut self) -> Result<()> { info!("Loading binary and performing DWARF analysis (parallel mode)"); let debug_search_paths = self.get_debug_search_paths(); let allow_loose = self.get_allow_loose_debug_match(); + let debuginfod_client = self.build_debuginfod_client()?; let process_analyzer = if let Some(proc_pid) = self.proc_pid() { info!("Loading binary from PID: {} (parallel)", proc_pid); Some( - DwarfAnalyzer::from_pid_parallel_with_config( + DwarfAnalyzer::from_pid_parallel_with_config_and_debuginfod( proc_pid, &debug_search_paths, allow_loose, + debuginfod_client.clone(), |_| {}, ) .await?, @@ -207,10 +270,11 @@ impl GhostSession { } else if let Some(ref binary_path) = self.target_binary { info!("Loading binary from executable path: {}", binary_path); Some( - DwarfAnalyzer::from_exec_path_with_config( + DwarfAnalyzer::from_exec_path_with_config_and_debuginfod( binary_path, &debug_search_paths, allow_loose, + debuginfod_client.clone(), ) .await?, ) @@ -235,6 +299,7 @@ impl GhostSession { let debug_search_paths = self.get_debug_search_paths(); let allow_loose = self.get_allow_loose_debug_match(); + let debuginfod_client = self.build_debuginfod_client()?; let process_analyzer = if let Some(proc_pid) = self.proc_pid() { info!( @@ -242,10 +307,11 @@ impl GhostSession { proc_pid ); Some( - DwarfAnalyzer::from_pid_parallel_with_config( + DwarfAnalyzer::from_pid_parallel_with_config_and_debuginfod( proc_pid, &debug_search_paths, allow_loose, + debuginfod_client.clone(), progress_callback, ) .await?, @@ -253,10 +319,11 @@ impl GhostSession { } else if let Some(ref binary_path) = self.target_binary { info!("Loading binary from executable path: {}", binary_path); Some( - DwarfAnalyzer::from_exec_path_with_config_and_progress( + DwarfAnalyzer::from_exec_path_with_config_and_debuginfod_and_progress( binary_path, &debug_search_paths, allow_loose, + debuginfod_client.clone(), progress_callback, ) .await?, @@ -391,6 +458,11 @@ mod tests { force_perf_event_array: false, enable_sysmon_for_shared_lib: false, allow_loose_debug_match: false, + debuginfod: None, + debuginfod_urls: Vec::new(), + debuginfod_cache_dir: None, + debuginfod_timeout_secs: None, + debuginfod_max_size: None, source_panel: false, no_source_panel: false, };