1use futures_util::StreamExt;
14use reqwest::{StatusCode, Url};
15use std::{
16 ffi::OsStr,
17 path::{Component, Path, PathBuf},
18 time::{Duration, SystemTime, UNIX_EPOCH},
19};
20use thiserror::Error;
21use tokio::{fs, io::AsyncWriteExt};
22
23pub const DEFAULT_TIMEOUT_SECS: u64 = 5;
24
25const USER_AGENT: &str = concat!("ghostscope/", env!("CARGO_PKG_VERSION"));
26
27pub type Result<T> = std::result::Result<T, DebuginfodError>;
28
29#[derive(Debug, Error)]
30pub enum DebuginfodError {
31 #[error("invalid debuginfod URL '{raw}': {source}")]
32 InvalidUrl {
33 raw: String,
34 source: url::ParseError,
35 },
36
37 #[error("unsupported debuginfod URL scheme '{scheme}' in '{raw}'")]
38 UnsupportedUrlScheme { raw: String, scheme: String },
39
40 #[error("invalid build-id: build-id must not be empty")]
41 EmptyBuildId,
42
43 #[error("invalid source path '{0}': debuginfod source queries require an absolute path")]
44 InvalidSourcePath(String),
45
46 #[error("debuginfod HTTP client error: {0}")]
47 Http(#[from] reqwest::Error),
48
49 #[error("I/O error for {path}: {source}")]
50 Io {
51 path: PathBuf,
52 source: std::io::Error,
53 },
54
55 #[error("debuginfod response exceeded configured maximum size ({max_size} bytes)")]
56 ResponseTooLarge { max_size: u64 },
57}
58
59#[derive(Debug, Clone)]
60pub struct DebuginfodConfig {
61 urls: Vec<Url>,
62 cache_dir: PathBuf,
63 timeout: Option<Duration>,
64 max_size: Option<u64>,
65 user_agent: String,
66}
67
68impl DebuginfodConfig {
69 pub fn new<I, S>(urls: I, cache_dir: impl Into<PathBuf>) -> Result<Self>
70 where
71 I: IntoIterator<Item = S>,
72 S: AsRef<str>,
73 {
74 let urls = parse_url_list(urls)?;
75 Ok(Self {
76 urls,
77 cache_dir: cache_dir.into(),
78 timeout: Some(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
79 max_size: None,
80 user_agent: USER_AGENT.to_string(),
81 })
82 }
83
84 pub fn with_timeout(mut self, timeout: Duration) -> Self {
85 self.timeout = Some(timeout);
86 self
87 }
88
89 pub fn without_timeout(mut self) -> Self {
90 self.timeout = None;
91 self
92 }
93
94 pub fn with_max_size(mut self, max_size: Option<u64>) -> Self {
95 self.max_size = max_size.filter(|size| *size > 0);
96 self
97 }
98
99 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
100 self.user_agent = user_agent.into();
101 self
102 }
103
104 pub fn urls(&self) -> &[Url] {
105 &self.urls
106 }
107
108 pub fn cache_dir(&self) -> &Path {
109 &self.cache_dir
110 }
111
112 pub fn timeout(&self) -> Option<Duration> {
113 self.timeout
114 }
115
116 pub fn max_size(&self) -> Option<u64> {
117 self.max_size
118 }
119}
120
121#[derive(Debug, Clone)]
122pub struct DebuginfodClient {
123 config: DebuginfodConfig,
124 http: reqwest::Client,
125}
126
127impl DebuginfodClient {
128 pub fn new(config: DebuginfodConfig) -> Result<Self> {
129 let mut builder = reqwest::Client::builder().user_agent(config.user_agent.clone());
130 if let Some(timeout) = config.timeout {
131 builder = builder.timeout(timeout);
132 }
133 let http = builder.build()?;
134 Ok(Self { config, http })
135 }
136
137 pub fn config(&self) -> &DebuginfodConfig {
138 &self.config
139 }
140
141 pub async fn fetch_debuginfo(&self, build_id: &[u8]) -> Result<Option<FetchedFile>> {
142 self.fetch_artifact(build_id, Artifact::Debuginfo).await
143 }
144
145 pub async fn fetch_executable(&self, build_id: &[u8]) -> Result<Option<FetchedFile>> {
146 self.fetch_artifact(build_id, Artifact::Executable).await
147 }
148
149 pub async fn fetch_source(
150 &self,
151 build_id: &[u8],
152 source_path: impl AsRef<str>,
153 ) -> Result<Option<FetchedFile>> {
154 self.fetch_artifact(
155 build_id,
156 Artifact::Source {
157 path: source_path.as_ref(),
158 },
159 )
160 .await
161 }
162
163 async fn fetch_artifact(
164 &self,
165 build_id: &[u8],
166 artifact: Artifact<'_>,
167 ) -> Result<Option<FetchedFile>> {
168 if build_id.is_empty() {
169 return Err(DebuginfodError::EmptyBuildId);
170 }
171
172 let build_id_hex = build_id_to_hex(build_id);
173 let cache_path = artifact.cache_path(self.config.cache_dir(), &build_id_hex)?;
174 if fs::metadata(&cache_path).await.is_ok() {
175 return Ok(Some(FetchedFile {
176 path: cache_path,
177 build_id: build_id_hex,
178 from_cache: true,
179 url: None,
180 }));
181 }
182
183 let endpoint = artifact.endpoint_path(&build_id_hex)?;
184 for base_url in self.config.urls() {
185 let url = build_url(base_url, &endpoint)?;
186 tracing::debug!(%url, "querying debuginfod");
187
188 let response = match self.http.get(url.clone()).send().await {
189 Ok(response) => response,
190 Err(err) => {
191 tracing::warn!(%url, error=%err, "debuginfod request failed");
192 continue;
193 }
194 };
195
196 match response.status() {
197 StatusCode::OK => {
198 match stream_response_to_cache(response, &cache_path, self.config.max_size())
199 .await
200 {
201 Ok(()) => {
202 return Ok(Some(FetchedFile {
203 path: cache_path,
204 build_id: build_id_hex,
205 from_cache: false,
206 url: Some(url.to_string()),
207 }));
208 }
209 Err(DebuginfodError::Http(err)) => {
210 tracing::warn!(%url, error=%err, "debuginfod response download failed");
211 continue;
212 }
213 Err(err) => return Err(err),
214 }
215 }
216 StatusCode::NOT_FOUND => {
217 tracing::debug!(%url, "debuginfod artifact not found");
218 }
219 status => {
220 tracing::warn!(%url, %status, "debuginfod returned non-success status");
221 }
222 }
223 }
224
225 Ok(None)
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct FetchedFile {
231 pub path: PathBuf,
232 pub build_id: String,
233 pub from_cache: bool,
234 pub url: Option<String>,
235}
236
237#[derive(Debug, Copy, Clone)]
238enum Artifact<'a> {
239 Debuginfo,
240 Executable,
241 Source { path: &'a str },
242}
243
244impl Artifact<'_> {
245 fn endpoint_path(&self, build_id_hex: &str) -> Result<String> {
246 match self {
252 Self::Debuginfo => Ok(format!("/buildid/{build_id_hex}/debuginfo")),
253 Self::Executable => Ok(format!("/buildid/{build_id_hex}/executable")),
254 Self::Source { path } => Ok(format!(
255 "/buildid/{}/source/{}",
256 build_id_hex,
257 encode_source_path_for_url(path)?
258 )),
259 }
260 }
261
262 fn cache_path(&self, cache_dir: &Path, build_id_hex: &str) -> Result<PathBuf> {
263 let build_id_dir = cache_dir.join(build_id_hex);
264 match self {
265 Self::Debuginfo => Ok(build_id_dir.join("debuginfo")),
266 Self::Executable => Ok(build_id_dir.join("executable")),
267 Self::Source { path } => source_cache_path(&build_id_dir, path),
268 }
269 }
270}
271
272pub fn build_id_to_hex(build_id: &[u8]) -> String {
273 let mut hex = String::with_capacity(build_id.len() * 2);
274 for byte in build_id {
275 use std::fmt::Write;
276 let _ = write!(&mut hex, "{byte:02x}");
277 }
278 hex
279}
280
281pub fn parse_url_list<I, S>(urls: I) -> Result<Vec<Url>>
282where
283 I: IntoIterator<Item = S>,
284 S: AsRef<str>,
285{
286 let mut parsed = Vec::new();
287 for raw in urls {
288 let raw = raw.as_ref().trim();
289 if raw.is_empty() || raw.starts_with("ima:") {
290 continue;
291 }
292
293 let mut url = Url::parse(raw).map_err(|source| DebuginfodError::InvalidUrl {
294 raw: raw.to_string(),
295 source,
296 })?;
297 match url.scheme() {
298 "http" | "https" => {}
299 scheme => {
300 return Err(DebuginfodError::UnsupportedUrlScheme {
301 raw: raw.to_string(),
302 scheme: scheme.to_string(),
303 });
304 }
305 }
306
307 url.set_query(None);
308 url.set_fragment(None);
309 parsed.push(url);
310 }
311 Ok(parsed)
312}
313
314fn build_url(base_url: &Url, endpoint_path: &str) -> Result<Url> {
315 let base = base_url.as_str().trim_end_matches('/');
316 let endpoint = endpoint_path.trim_start_matches('/');
317 let raw = format!("{base}/{endpoint}");
318 Url::parse(&raw).map_err(|source| DebuginfodError::InvalidUrl { raw, source })
319}
320
321fn encode_source_path_for_url(source_path: &str) -> Result<String> {
322 let rest = source_path
323 .strip_prefix('/')
324 .ok_or_else(|| DebuginfodError::InvalidSourcePath(source_path.to_string()))?;
325
326 if rest.is_empty() {
327 return Err(DebuginfodError::InvalidSourcePath(source_path.to_string()));
328 }
329
330 let mut encoded = String::with_capacity(rest.len());
334 for byte in rest.bytes() {
335 match byte {
336 b'/' => encoded.push('/'),
337 b if is_unreserved_uri_byte(b) => encoded.push(byte as char),
338 b => {
339 use std::fmt::Write;
340 let _ = write!(&mut encoded, "%{b:02X}");
341 }
342 }
343 }
344 Ok(encoded)
345}
346
347fn source_cache_path(build_id_dir: &Path, source_path: &str) -> Result<PathBuf> {
348 if !source_path.starts_with('/') {
349 return Err(DebuginfodError::InvalidSourcePath(source_path.to_string()));
350 }
351
352 let mut path = build_id_dir.join("source");
353 let mut saw_component = false;
354 for component in Path::new(source_path).components() {
355 match component {
356 Component::RootDir => {}
357 Component::Normal(part) => {
358 path.push(encode_path_component(part));
359 saw_component = true;
360 }
361 Component::CurDir => {
362 path.push("%2E");
363 saw_component = true;
364 }
365 Component::ParentDir => {
366 path.push("%2E%2E");
367 saw_component = true;
368 }
369 Component::Prefix(_) => {
370 return Err(DebuginfodError::InvalidSourcePath(source_path.to_string()));
371 }
372 }
373 }
374
375 if !saw_component {
376 return Err(DebuginfodError::InvalidSourcePath(source_path.to_string()));
377 }
378
379 Ok(path)
380}
381
382#[cfg(unix)]
383fn encode_path_component(component: &OsStr) -> String {
384 use std::os::unix::ffi::OsStrExt;
385 percent_encode_component(component.as_bytes())
386}
387
388#[cfg(not(unix))]
389fn encode_path_component(component: &OsStr) -> String {
390 percent_encode_component(component.to_string_lossy().as_bytes())
391}
392
393fn percent_encode_component(bytes: &[u8]) -> String {
394 let mut encoded = String::with_capacity(bytes.len());
395 for &byte in bytes {
396 if is_unreserved_uri_byte(byte) {
397 encoded.push(byte as char);
398 } else {
399 use std::fmt::Write;
400 let _ = write!(&mut encoded, "%{byte:02X}");
401 }
402 }
403 encoded
404}
405
406fn is_unreserved_uri_byte(byte: u8) -> bool {
407 matches!(
408 byte,
409 b'A'..=b'Z'
410 | b'a'..=b'z'
411 | b'0'..=b'9'
412 | b'-'
413 | b'.'
414 | b'_'
415 | b'~'
416 )
417}
418
419async fn stream_response_to_cache(
420 response: reqwest::Response,
421 cache_path: &Path,
422 max_size: Option<u64>,
423) -> Result<()> {
424 if let Some(max_size) = max_size {
425 if response
426 .content_length()
427 .is_some_and(|content_length| content_length > max_size)
428 {
429 return Err(DebuginfodError::ResponseTooLarge { max_size });
430 }
431 }
432
433 let parent = cache_path.parent().unwrap_or_else(|| Path::new("."));
434 fs::create_dir_all(parent)
435 .await
436 .map_err(|source| DebuginfodError::Io {
437 path: parent.to_path_buf(),
438 source,
439 })?;
440
441 let tmp_path = temporary_path(cache_path);
442 let result = write_stream_to_file(response, &tmp_path, max_size).await;
443 if let Err(error) = result {
444 let _ = fs::remove_file(&tmp_path).await;
445 return Err(error);
446 }
447
448 fs::rename(&tmp_path, cache_path)
449 .await
450 .map_err(|source| DebuginfodError::Io {
451 path: cache_path.to_path_buf(),
452 source,
453 })?;
454 Ok(())
455}
456
457async fn write_stream_to_file(
458 response: reqwest::Response,
459 tmp_path: &Path,
460 max_size: Option<u64>,
461) -> Result<()> {
462 let mut file = fs::File::create(tmp_path)
463 .await
464 .map_err(|source| DebuginfodError::Io {
465 path: tmp_path.to_path_buf(),
466 source,
467 })?;
468 let mut stream = response.bytes_stream();
469 let mut total = 0_u64;
470
471 while let Some(chunk) = stream.next().await {
472 let chunk = chunk?;
473 total = total.saturating_add(chunk.len() as u64);
474 if let Some(max_size) = max_size {
475 if total > max_size {
476 return Err(DebuginfodError::ResponseTooLarge { max_size });
477 }
478 }
479 file.write_all(&chunk)
480 .await
481 .map_err(|source| DebuginfodError::Io {
482 path: tmp_path.to_path_buf(),
483 source,
484 })?;
485 }
486
487 file.flush().await.map_err(|source| DebuginfodError::Io {
488 path: tmp_path.to_path_buf(),
489 source,
490 })?;
491 Ok(())
492}
493
494fn temporary_path(cache_path: &Path) -> PathBuf {
495 let suffix = SystemTime::now()
496 .duration_since(UNIX_EPOCH)
497 .map(|duration| duration.as_nanos())
498 .unwrap_or(0);
499 let filename = cache_path
500 .file_name()
501 .and_then(|name| name.to_str())
502 .unwrap_or("debuginfod");
503 cache_path.with_file_name(format!("{filename}.tmp-{}-{suffix}", std::process::id()))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use std::sync::{
510 atomic::{AtomicUsize, Ordering},
511 Arc,
512 };
513 use tempfile::TempDir;
514 use tokio::{
515 io::{AsyncReadExt, AsyncWriteExt},
516 net::TcpListener,
517 };
518
519 #[test]
520 fn build_id_hex_is_lowercase() {
521 assert_eq!(build_id_to_hex(&[0xab, 0xcd, 0x01, 0xef]), "abcd01ef");
522 }
523
524 #[test]
525 fn parse_urls_skips_ima_tags_and_trims_fragments() {
526 let urls = parse_url_list([
527 "https://debuginfod.example/",
528 "ima:enforcing",
529 "http://localhost:8002/path?ignored=yes#fragment",
530 ])
531 .unwrap();
532
533 assert_eq!(urls.len(), 2);
534 assert_eq!(urls[0].as_str(), "https://debuginfod.example/");
535 assert_eq!(urls[1].as_str(), "http://localhost:8002/path");
536 }
537
538 #[test]
539 fn source_endpoint_percent_encodes_non_unreserved_chars() {
540 let endpoint = Artifact::Source {
541 path: "/usr/src/foo bar+/main.c",
542 }
543 .endpoint_path("abc123")
544 .unwrap();
545
546 assert_eq!(
547 endpoint,
548 "/buildid/abc123/source/usr/src/foo%20bar%2B/main.c"
549 );
550 }
551
552 #[test]
553 fn build_url_preserves_already_escaped_source_path() {
554 let base = Url::parse("https://debuginfod.example/prefix/").unwrap();
555 let endpoint = Artifact::Source {
556 path: "/usr/src/foo bar+/main.c",
557 }
558 .endpoint_path("abc123")
559 .unwrap();
560 let url = build_url(&base, &endpoint).unwrap();
561
562 assert_eq!(
563 url.as_str(),
564 "https://debuginfod.example/prefix/buildid/abc123/source/usr/src/foo%20bar%2B/main.c"
565 );
566 }
567
568 #[test]
569 fn source_cache_path_never_uses_parent_dir_components() {
570 let path = source_cache_path(Path::new("/cache/abc123"), "/zoo//../bar/foo.c").unwrap();
571
572 assert_eq!(path, Path::new("/cache/abc123/source/zoo/%2E%2E/bar/foo.c"));
573 }
574
575 #[test]
576 fn source_path_must_be_absolute() {
577 let err = Artifact::Source { path: "relative.c" }
578 .endpoint_path("abc123")
579 .unwrap_err();
580
581 assert!(matches!(err, DebuginfodError::InvalidSourcePath(_)));
582 }
583
584 #[tokio::test]
585 async fn fetch_debuginfo_downloads_and_reuses_cache() {
586 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
587 let addr = listener.local_addr().unwrap();
588 let requests = Arc::new(AtomicUsize::new(0));
589 let request_count = Arc::clone(&requests);
590
591 tokio::spawn(async move {
592 let (mut stream, _) = listener.accept().await.unwrap();
593 let mut buffer = [0_u8; 1024];
594 let read = stream.read(&mut buffer).await.unwrap();
595 let request = String::from_utf8_lossy(&buffer[..read]);
596 assert!(request.starts_with("GET /buildid/abcd/debuginfo HTTP/1.1"));
597 request_count.fetch_add(1, Ordering::SeqCst);
598
599 let body = b"debug-data";
600 let response = format!(
601 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
602 body.len()
603 );
604 stream.write_all(response.as_bytes()).await.unwrap();
605 stream.write_all(body).await.unwrap();
606 });
607
608 let cache = TempDir::new().unwrap();
609 let config = DebuginfodConfig::new([format!("http://{addr}")], cache.path()).unwrap();
610 let client = DebuginfodClient::new(config).unwrap();
611
612 let first = client
613 .fetch_debuginfo(&[0xab, 0xcd])
614 .await
615 .unwrap()
616 .unwrap();
617 assert!(!first.from_cache);
618 assert_eq!(fs::read(&first.path).await.unwrap(), b"debug-data");
619
620 let second = client
621 .fetch_debuginfo(&[0xab, 0xcd])
622 .await
623 .unwrap()
624 .unwrap();
625 assert!(second.from_cache);
626 assert_eq!(first.path, second.path);
627 assert_eq!(requests.load(Ordering::SeqCst), 1);
628 }
629
630 #[tokio::test]
631 async fn max_size_rejects_large_response_before_cache_commit() {
632 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
633 let addr = listener.local_addr().unwrap();
634
635 tokio::spawn(async move {
636 let (mut stream, _) = listener.accept().await.unwrap();
637 let mut buffer = [0_u8; 1024];
638 let _ = stream.read(&mut buffer).await.unwrap();
639
640 let body = b"too-large";
641 let response = format!(
642 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
643 body.len()
644 );
645 stream.write_all(response.as_bytes()).await.unwrap();
646 stream.write_all(body).await.unwrap();
647 });
648
649 let cache = TempDir::new().unwrap();
650 let config = DebuginfodConfig::new([format!("http://{addr}")], cache.path())
651 .unwrap()
652 .with_max_size(Some(4));
653 let client = DebuginfodClient::new(config).unwrap();
654
655 let err = client.fetch_debuginfo(&[0xab, 0xcd]).await.unwrap_err();
656 assert!(matches!(
657 err,
658 DebuginfodError::ResponseTooLarge { max_size: 4 }
659 ));
660 assert!(!cache.path().join("abcd").join("debuginfo").exists());
661 }
662
663 #[tokio::test]
664 async fn request_timeout_returns_not_found_fallback() {
665 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
666 let addr = listener.local_addr().unwrap();
667
668 tokio::spawn(async move {
669 let (mut stream, _) = listener.accept().await.unwrap();
670 let mut buffer = [0_u8; 1024];
671 let _ = stream.read(&mut buffer).await.unwrap();
672 tokio::time::sleep(Duration::from_millis(200)).await;
673 let body = b"debug-data";
674 let response = format!(
675 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
676 body.len()
677 );
678 let _ = stream.write_all(response.as_bytes()).await;
679 let _ = stream.write_all(body).await;
680 });
681
682 let cache = TempDir::new().unwrap();
683 let config = DebuginfodConfig::new([format!("http://{addr}")], cache.path())
684 .unwrap()
685 .with_timeout(Duration::from_millis(50));
686 let client = DebuginfodClient::new(config).unwrap();
687
688 let result = client.fetch_debuginfo(&[0xab, 0xcd]).await.unwrap();
689 assert!(result.is_none());
690 assert!(!cache.path().join("abcd").join("debuginfo").exists());
691 }
692}