Skip to main content

rootle_github/client/
blobs.rs

1//! Immutable blobs, path-at-revision content and source archives.
2
3use super::transport::{classify_send, classify_status};
4use super::{API, GitHubClient, urlencoding};
5use rootle_provider::{ProviderError, ProviderResult};
6
7impl GitHubClient {
8    /// File at a ref via the contents API: raw bytes + the git blob
9    /// sha (the provider's content id).
10    pub fn blob_at(
11        &self,
12        owner: &str,
13        repo: &str,
14        path: &str,
15        ref_: Option<&str>,
16    ) -> ProviderResult<(Vec<u8>, String)> {
17        #[derive(serde::Deserialize)]
18        struct Contents {
19            content: String, // base64 with embedded newlines
20            sha: String,
21        }
22        let mut url = format!("{API}/repos/{owner}/{repo}/contents/{path}");
23        if let Some(r) = ref_ {
24            url.push_str(&format!("?ref={}", urlencoding(r)));
25        }
26        let item: Contents = self.get(&url)?;
27        use base64::Engine;
28        let clean: String = item
29            .content
30            .chars()
31            .filter(|c| !c.is_whitespace())
32            .collect();
33        let bytes = base64::engine::general_purpose::STANDARD
34            .decode(clean)
35            .map_err(|e| ProviderError::other(format!("contents base64: {e}")))?;
36        Ok((bytes, item.sha))
37    }
38
39    /// The default branch's source as a gzip tarball (api.github.com
40    /// 302s to codeload). Capped — a repo past this size is not a
41    /// fallback candidate.
42    pub fn source_tarball(&self, repo: &str) -> ProviderResult<Vec<u8>> {
43        const CAP: u64 = 64 * 1024 * 1024;
44        let url = format!("{API}/repos/{repo}/tarball");
45        let trace = super::transport::HttpTrace::begin(&url, self.auth_source, false);
46        let mut req = self.http.get(&url);
47        if let Some(token) = &self.token {
48            req = req.bearer_auth(token);
49        }
50        let mut resp = match req.send() {
51            Ok(resp) => resp,
52            Err(error) => {
53                if let Some(trace) = &trace {
54                    trace.fail(if error.is_timeout() {
55                        "timeout"
56                    } else {
57                        "network"
58                    });
59                }
60                return Err(classify_send(error));
61            }
62        };
63        if !resp.status().is_success() {
64            if let Some(trace) = &trace {
65                trace.finish(resp.status().as_u16(), 0);
66            }
67            return Err(classify_status(resp));
68        }
69        if let Some(len) = resp.content_length()
70            && len > CAP
71        {
72            if let Some(trace) = &trace {
73                trace.finish(resp.status().as_u16(), 0);
74                trace.rejected("content_length_over_cap");
75            }
76            return Err(ProviderError::other(format!(
77                "tarball too large for local grep ({len} bytes)"
78            )));
79        }
80        // Status is captured before the body borrow; the byte count is
81        // whatever actually arrived under the cap.
82        let status = resp.status().as_u16();
83        let mut bytes = Vec::new();
84        let mut capped = std::io::Read::take(&mut resp, CAP);
85        std::io::Read::read_to_end(&mut capped, &mut bytes).map_err(|error| {
86            if let Some(trace) = &trace {
87                trace.fail("body_read");
88            }
89            ProviderError::other(error.to_string())
90        })?;
91        if let Some(trace) = &trace {
92            trace.finish(status, bytes.len() as u64);
93        }
94        Ok(bytes)
95    }
96
97    /// Fetch a blob by git sha, cache-first (blobs are immutable).
98    /// Files over 1 MiB are rejected — too heavy for a preview pane.
99    pub fn fetch_blob(&self, owner: &str, repo: &str, sha: &str) -> ProviderResult<Vec<u8>> {
100        if let Some(bytes) = crate::cache::read_blob(sha) {
101            return Ok(bytes);
102        }
103        #[derive(serde::Deserialize)]
104        struct BlobResponse {
105            content: String,
106            size: u64,
107        }
108        let url = format!("{API}/repos/{owner}/{repo}/git/blobs/{sha}");
109        let blob: BlobResponse = self.get(&url)?;
110        if blob.size > 1024 * 1024 {
111            return Err(ProviderError::other(format!(
112                "file too large to preview ({} bytes)",
113                blob.size
114            )));
115        }
116        use base64::Engine;
117        let clean: String = blob
118            .content
119            .chars()
120            .filter(|c| !c.is_whitespace())
121            .collect();
122        let bytes = base64::engine::general_purpose::STANDARD
123            .decode(clean)
124            .map_err(|e| ProviderError::other(e.to_string()))?;
125        let _ = crate::cache::write_blob(sha, &bytes);
126        Ok(bytes)
127    }
128}