skillfile_sources/
http.rs1use std::process::Command;
2use std::sync::OnceLock;
3
4use skillfile_core::error::SkillfileError;
5
6static TOKEN_CACHE: OnceLock<Option<String>> = OnceLock::new();
11
12static CONFIG_TOKEN: OnceLock<Option<String>> = OnceLock::new();
17
18pub fn set_config_token(token: Option<String>) {
23 let _ = CONFIG_TOKEN.set(token);
24}
25
26pub struct GithubToken(Option<&'static str>);
33
34impl GithubToken {
35 #[must_use]
41 pub fn for_url(&self, url: &str) -> Option<&'static str> {
42 is_github_url(url).then_some(self.0).flatten()
43 }
44}
45
46#[must_use]
51pub fn github_token() -> GithubToken {
52 GithubToken(TOKEN_CACHE.get_or_init(discover_github_token).as_deref())
53}
54
55fn env_token(name: &str) -> Option<String> {
56 std::env::var(name).ok().filter(|t| !t.is_empty())
57}
58
59fn gh_cli_token() -> Option<String> {
60 let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
61 if !output.status.success() {
62 return None;
63 }
64 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
65 (!token.is_empty()).then_some(token)
66}
67
68fn discover_github_token() -> Option<String> {
69 if let Some(token) = env_token("GITHUB_TOKEN") {
70 return Some(token);
71 }
72 if let Some(token) = env_token("GH_TOKEN") {
73 return Some(token);
74 }
75 if let Some(Some(token)) = CONFIG_TOKEN.get() {
77 if !token.is_empty() {
78 return Some(token.clone());
79 }
80 }
81 gh_cli_token()
82}
83
84pub struct BearerPost<'a> {
89 pub url: &'a str,
90 pub body: &'a str,
91 pub token: &'a str,
92}
93
94pub trait HttpClient: Send + Sync {
106 fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError>;
108
109 fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError>;
115
116 fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError>;
120
121 fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
130 self.post_json(req.url, req.body)
131 }
132}
133
134fn is_github_url(url: &str) -> bool {
144 let host = url
148 .strip_prefix("https://")
149 .or_else(|| url.strip_prefix("http://"))
150 .and_then(|s| s.split('/').next())
151 .unwrap_or("");
152 matches!(host, "api.github.com" | "raw.githubusercontent.com")
153}
154
155fn read_response_text(body: &mut ureq::Body, url: &str) -> Result<String, SkillfileError> {
160 body.read_to_string()
161 .map_err(|e| SkillfileError::Network(format!("failed to read response from {url}: {e}")))
162}
163
164pub struct UreqClient {
170 agent: ureq::Agent,
171}
172
173impl UreqClient {
174 pub fn new() -> Self {
175 let config = ureq::config::Config::builder()
176 .redirect_auth_headers(ureq::config::RedirectAuthHeaders::SameHost)
180 .build();
181 Self {
182 agent: ureq::Agent::new_with_config(config),
183 }
184 }
185
186 fn build_get(&self, url: &str) -> ureq::RequestBuilder<ureq::typestate::WithoutBody> {
187 let mut req = self.agent.get(url).header("User-Agent", "skillfile/1.0");
188 if let Some(token) = github_token().for_url(url) {
189 req = req.header("Authorization", &format!("Bearer {token}"));
190 }
191 req
192 }
193
194 fn build_post(&self, url: &str) -> ureq::RequestBuilder<ureq::typestate::WithBody> {
195 let mut req = self.agent.post(url).header("User-Agent", "skillfile/1.0");
196 if let Some(token) = github_token().for_url(url) {
197 req = req.header("Authorization", &format!("Bearer {token}"));
198 }
199 req
200 }
201}
202
203impl Default for UreqClient {
204 fn default() -> Self {
205 Self::new()
206 }
207}
208
209impl HttpClient for UreqClient {
210 fn get_bytes(&self, url: &str) -> Result<Vec<u8>, SkillfileError> {
211 let mut response = self.build_get(url).call().map_err(|e| match &e {
212 ureq::Error::StatusCode(404) => SkillfileError::Network(format!(
213 "HTTP 404: {url} not found — check that the path exists in the upstream repo"
214 )),
215 ureq::Error::StatusCode(code) => {
216 SkillfileError::Network(format!("HTTP {code} fetching {url}"))
217 }
218 _ => SkillfileError::Network(format!("{e} fetching {url}")),
219 })?;
220 response.body_mut().read_to_vec().map_err(|e| {
221 SkillfileError::Network(format!("failed to read response from {url}: {e}"))
222 })
223 }
224
225 fn get_json(&self, url: &str) -> Result<Option<String>, SkillfileError> {
226 let result = self
227 .build_get(url)
228 .header("Accept", "application/vnd.github.v3+json")
229 .call();
230
231 match result {
232 Ok(mut response) => read_response_text(response.body_mut(), url).map(Some),
233 Err(ureq::Error::StatusCode(code)) if code == 404 || code == 422 => Ok(None),
236 Err(ureq::Error::StatusCode(403)) => Err(SkillfileError::Network(format!(
237 "HTTP 403 fetching {url} — you may be rate-limited. \
238 Set GITHUB_TOKEN or run `gh auth login` to authenticate."
239 ))),
240 Err(e) => Err(SkillfileError::Network(format!("{e} fetching {url}"))),
241 }
242 }
243
244 fn post_json(&self, url: &str, body: &str) -> Result<Vec<u8>, SkillfileError> {
245 let mut response = self
246 .build_post(url)
247 .header("Content-Type", "application/json")
248 .send(body.as_bytes())
249 .map_err(|e| match &e {
250 ureq::Error::StatusCode(code) => {
251 SkillfileError::Network(format!("HTTP {code} posting to {url}"))
252 }
253 _ => SkillfileError::Network(format!("{e} posting to {url}")),
254 })?;
255 response.body_mut().read_to_vec().map_err(|e| {
256 SkillfileError::Network(format!("failed to read response from {url}: {e}"))
257 })
258 }
259
260 fn post_json_with_bearer(&self, req: &BearerPost<'_>) -> Result<Vec<u8>, SkillfileError> {
261 let (url, token) = (req.url, req.token);
262 let mut response = self
263 .agent
264 .post(url)
265 .header("User-Agent", "skillfile/1.0")
266 .header("Content-Type", "application/json")
267 .header("Authorization", &format!("Bearer {token}"))
268 .send(req.body.as_bytes())
269 .map_err(|e| match &e {
270 ureq::Error::StatusCode(code) => {
271 SkillfileError::Network(format!("HTTP {code} posting to {url}"))
272 }
273 _ => SkillfileError::Network(format!("{e} posting to {url}")),
274 })?;
275 response.body_mut().read_to_vec().map_err(|e| {
276 SkillfileError::Network(format!("failed to read response from {url}: {e}"))
277 })
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn ureq_client_default_creates_successfully() {
287 let _client = UreqClient::default();
288 }
289
290 #[test]
295 fn set_config_token_populates_cache() {
296 set_config_token(Some("test-token-abc".to_string()));
297 assert!(CONFIG_TOKEN.get().is_some());
300 }
301
302 #[test]
308 fn github_token_type_for_url_rejects_registries() {
309 let token = GithubToken(Some("ghp_secret"));
310 assert!(token.for_url("https://agentskill.sh/api/search").is_none());
311 assert!(token.for_url("https://skills.sh/api/search").is_none());
312 assert!(token
313 .for_url("https://www.skillhub.club/api/v1/skills/search")
314 .is_none());
315 }
316
317 #[test]
318 fn github_token_type_for_url_allows_github() {
319 let token = GithubToken(Some("ghp_secret"));
320 assert_eq!(
321 token.for_url("https://api.github.com/repos/o/r"),
322 Some("ghp_secret")
323 );
324 assert_eq!(
325 token.for_url("https://raw.githubusercontent.com/o/r/HEAD/f"),
326 Some("ghp_secret")
327 );
328 }
329
330 #[test]
331 fn github_token_type_for_url_returns_none_without_token() {
332 let token = GithubToken(None);
333 assert!(token.for_url("https://api.github.com/repos/o/r").is_none());
334 }
335
336 #[test]
339 fn github_api_url_is_github() {
340 assert!(is_github_url("https://api.github.com/repos/owner/repo"));
341 }
342
343 #[test]
344 fn github_raw_url_is_github() {
345 assert!(is_github_url(
346 "https://raw.githubusercontent.com/owner/repo/main/file.md"
347 ));
348 }
349
350 #[test]
351 fn github_api_root_is_github() {
352 assert!(is_github_url("https://api.github.com/"));
353 }
354
355 #[test]
356 fn agentskill_url_is_not_github() {
357 assert!(!is_github_url(
358 "https://agentskill.sh/api/agent/search?q=test"
359 ));
360 }
361
362 #[test]
363 fn skillssh_url_is_not_github() {
364 assert!(!is_github_url("https://skills.sh/api/search?q=test"));
365 }
366
367 #[test]
368 fn skillhub_url_is_not_github() {
369 assert!(!is_github_url(
370 "https://www.skillhub.club/api/v1/skills/search"
371 ));
372 }
373
374 #[test]
375 fn spoofed_github_subdomain_is_not_github() {
376 assert!(!is_github_url("https://api.github.com.evil.com/repos"));
377 }
378
379 #[test]
380 fn spoofed_raw_subdomain_is_not_github() {
381 assert!(!is_github_url(
382 "https://raw.githubusercontent.com.evil.com/file"
383 ));
384 }
385
386 #[test]
387 fn empty_url_is_not_github() {
388 assert!(!is_github_url(""));
389 }
390
391 #[test]
392 fn bare_domain_is_not_github() {
393 assert!(!is_github_url("api.github.com/repos"));
394 }
395
396 #[test]
397 fn http_github_url_is_github() {
398 assert!(is_github_url("http://api.github.com/repos/owner/repo"));
399 }
400}