sherpack_repo/
http.rs

1//! HTTP repository implementation
2//!
3//! Supports traditional Helm-style HTTP repositories with index.yaml
4
5use std::path::Path;
6
7use crate::config::Repository;
8use crate::credentials::{
9    CachedResponse, ResolvedCredentials, ScopedCredentials, SecureHttpClient,
10};
11use crate::error::{RepoError, Result};
12use crate::index::{PackEntry, RepositoryIndex};
13
14/// HTTP repository client
15pub struct HttpRepository {
16    /// Repository configuration
17    repo: Repository,
18    /// HTTP client with secure credential handling
19    client: SecureHttpClient,
20    /// Cached index
21    cached_index: Option<RepositoryIndex>,
22}
23
24impl HttpRepository {
25    /// Create a new HTTP repository client
26    pub fn new(repo: Repository, credentials: Option<ResolvedCredentials>) -> Result<Self> {
27        let mut scoped = ScopedCredentials::default();
28        if let Some(creds) = credentials {
29            scoped.add(&repo.url, creds);
30        }
31
32        let client = SecureHttpClient::new(scoped)?;
33
34        Ok(Self {
35            repo,
36            client,
37            cached_index: None,
38        })
39    }
40
41    /// Create for a public repository (no auth)
42    pub fn public(repo: Repository) -> Result<Self> {
43        Self::new(repo, None)
44    }
45
46    /// Get the repository name
47    pub fn name(&self) -> &str {
48        &self.repo.name
49    }
50
51    /// Get the repository URL
52    pub fn url(&self) -> &str {
53        &self.repo.url
54    }
55
56    /// Fetch or refresh the repository index
57    pub async fn fetch_index(&mut self) -> Result<&RepositoryIndex> {
58        let index_url = self.repo.index_url();
59
60        // Use ETag for conditional request if we have a cached index
61        let response = self
62            .client
63            .get_cached(&index_url, self.repo.etag.as_deref())
64            .await?;
65
66        match response {
67            CachedResponse::NotModified => {
68                // Index hasn't changed, use cached version
69                if self.cached_index.is_none() {
70                    return Err(RepoError::CacheError {
71                        message: "Received 304 but no cached index".to_string(),
72                    });
73                }
74            }
75            CachedResponse::Fresh { data, etag } => {
76                // Parse new index
77                let index = RepositoryIndex::from_bytes(&data)?;
78                self.cached_index = Some(index);
79
80                // Store new ETag (would need to save to config)
81                if etag.is_some() {
82                    // Note: caller should save updated repo config
83                    // self.repo.etag = etag;
84                }
85            }
86        }
87
88        self.cached_index
89            .as_ref()
90            .ok_or(RepoError::IndexNotFound { url: index_url })
91    }
92
93    /// Get the cached index without fetching
94    pub fn index(&self) -> Option<&RepositoryIndex> {
95        self.cached_index.as_ref()
96    }
97
98    /// Search packs in the repository
99    pub async fn search(&mut self, query: &str) -> Result<Vec<&PackEntry>> {
100        let index = self.fetch_index().await?;
101        Ok(index.search(query))
102    }
103
104    /// Get the latest version of a pack
105    pub async fn get_latest(&mut self, name: &str) -> Result<PackEntry> {
106        let repo_name = self.repo.name.clone();
107        let index = self.fetch_index().await?;
108        index
109            .get_latest(name)
110            .cloned()
111            .ok_or_else(|| RepoError::PackNotFound {
112                name: name.to_string(),
113                repo: repo_name,
114            })
115    }
116
117    /// Get a specific version of a pack
118    pub async fn get_version(&mut self, name: &str, version: &str) -> Result<PackEntry> {
119        let repo_name = self.repo.name.clone();
120        let index = self.fetch_index().await?;
121        index
122            .get_version(name, version)
123            .cloned()
124            .ok_or_else(|| RepoError::VersionNotFound {
125                name: name.to_string(),
126                version: version.to_string(),
127                repo: repo_name,
128            })
129    }
130
131    /// Find best matching version for a constraint
132    pub async fn find_best_match(&mut self, name: &str, constraint: &str) -> Result<PackEntry> {
133        let index = self.fetch_index().await?;
134        index.find_best_match(name, constraint).cloned()
135    }
136
137    /// Download a pack archive
138    pub async fn download(&self, entry: &PackEntry) -> Result<Vec<u8>> {
139        let url = entry
140            .download_url()
141            .ok_or_else(|| RepoError::PackNotFound {
142                name: entry.name.clone(),
143                repo: self.repo.name.clone(),
144            })?;
145
146        // Resolve relative URLs
147        let full_url = if url.starts_with("http://") || url.starts_with("https://") {
148            url.to_string()
149        } else {
150            format!("{}/{}", self.repo.url.trim_end_matches('/'), url)
151        };
152
153        let data = self.client.get_bytes(&full_url).await?;
154
155        // Verify digest if present
156        if let Some(expected_digest) = &entry.digest {
157            let actual_digest = compute_digest(&data);
158            if !digest_matches(expected_digest, &actual_digest) {
159                return Err(RepoError::IntegrityCheckFailed {
160                    name: entry.name.clone(),
161                    expected: expected_digest.clone(),
162                    actual: actual_digest,
163                });
164            }
165        }
166
167        Ok(data)
168    }
169
170    /// Download and extract a pack to a directory
171    pub async fn download_to(&self, entry: &PackEntry, dest: &Path) -> Result<()> {
172        let data = self.download(entry).await?;
173
174        // Extract the archive
175        extract_pack_archive(&data, dest)?;
176
177        Ok(())
178    }
179
180    /// List all packs in the repository
181    pub async fn list(&mut self) -> Result<Vec<&PackEntry>> {
182        let index = self.fetch_index().await?;
183        Ok(index
184            .entries
185            .values()
186            .filter_map(|versions| {
187                versions.iter().max_by(|a, b| {
188                    let va = semver::Version::parse(&a.version).ok();
189                    let vb = semver::Version::parse(&b.version).ok();
190                    va.cmp(&vb)
191                })
192            })
193            .collect())
194    }
195}
196
197/// Compute SHA256 digest of data
198fn compute_digest(data: &[u8]) -> String {
199    use sha2::{Digest, Sha256};
200    let mut hasher = Sha256::new();
201    hasher.update(data);
202    let result = hasher.finalize();
203    format!("sha256:{}", hex::encode(result))
204}
205
206/// Check if two digests match (supports various formats)
207fn digest_matches(expected: &str, actual: &str) -> bool {
208    // Normalize both digests
209    let norm_expected = expected
210        .trim()
211        .to_lowercase()
212        .replace("sha256:", "")
213        .replace("sha256-", "");
214    let norm_actual = actual
215        .trim()
216        .to_lowercase()
217        .replace("sha256:", "")
218        .replace("sha256-", "");
219
220    norm_expected == norm_actual
221}
222
223/// Extract a pack archive (tar.gz) to a directory
224fn extract_pack_archive(data: &[u8], dest: &Path) -> Result<()> {
225    use flate2::read::GzDecoder;
226    use tar::Archive;
227
228    let gz = GzDecoder::new(std::io::Cursor::new(data));
229    let mut archive = Archive::new(gz);
230
231    std::fs::create_dir_all(dest)?;
232    archive.unpack(dest)?;
233
234    Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_compute_digest() {
243        let data = b"hello world";
244        let digest = compute_digest(data);
245        assert!(digest.starts_with("sha256:"));
246        assert_eq!(digest.len(), 7 + 64); // "sha256:" + 64 hex chars
247    }
248
249    #[test]
250    fn test_digest_matches() {
251        let d1 = "sha256:abc123";
252        let d2 = "sha256:ABC123";
253        let d3 = "abc123";
254        let d4 = "sha256-abc123";
255
256        assert!(digest_matches(d1, d2));
257        assert!(digest_matches(d1, d3));
258        assert!(digest_matches(d1, d4));
259        assert!(!digest_matches(d1, "sha256:xyz789"));
260    }
261}