Skip to main content

ghostscope_debuginfod/
lib.rs

1//! Async debuginfod client.
2//!
3//! This crate implements the small HTTP client-side subset GhostScope needs:
4//! debuginfo, executable, and source lookups by build-id. It does not implement
5//! a debuginfod server.
6//!
7//! The HTTP paths implemented here come from the debuginfod web API documented
8//! by elfutils/debuginfod(8):
9//! <https://sourceware.org/elfutils/Debuginfod.html>
10//! and distro man pages such as:
11//! <https://manpages.ubuntu.com/manpages/noble/en/man8/debuginfod.8.html>.
12
13use 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        // debuginfod(8) defines:
247        //   /buildid/BUILDID/debuginfo
248        //   /buildid/BUILDID/executable
249        //   /buildid/BUILDID/source/SOURCE/FILE
250        // where BUILDID is hexadecimal and SOURCE/FILE should be absolute.
251        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    // debuginfod(8) says clients should %-escape source path bytes that are not
331    // RFC3986 section 2.3 "unreserved" characters. Slash stays unescaped here
332    // because the debuginfod endpoint treats SOURCE/FILE as path segments.
333    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}