//! GitHub channel: scans a user's public gists, then falls back to the //! `/` profile README. Reachable proof formats: Markdown, //! JSON, or compact, in any file whose name suggests a KEZ proof. use async_trait::async_trait; use kez_core::Identity; use reqwest::Client; use serde_json::Value; use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for}; const DEFAULT_API_BASE: &str = "https://api.github.com"; const DEFAULT_RAW_BASE: &str = "https://raw.githubusercontent.com"; const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)"; #[derive(Clone)] pub struct GithubChannel { client: Client, api_base: String, raw_base: String, } impl GithubChannel { pub fn new() -> anyhow::Result { let client = Client::builder().user_agent(USER_AGENT).build()?; Ok(Self::with_bases( client, DEFAULT_API_BASE.to_owned(), DEFAULT_RAW_BASE.to_owned(), )) } /// For tests / custom endpoints (enterprise GitHub, mock server). pub fn with_bases(client: Client, api_base: String, raw_base: String) -> Self { Self { client, api_base, raw_base, } } } #[async_trait] impl Channel for GithubChannel { fn system(&self) -> &'static str { "github" } async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult { let user = identity.value(); if user.is_empty() { return Err(ChannelError::Other(anyhow::anyhow!( "github identity has empty user" ))); } let mut last_error: Option = None; // 1. Try the user's public gists. match self.fetch_gist_candidates(user).await { Ok(candidates) => { for raw_url in candidates { match self.fetch_text(&raw_url).await { Ok(body) => match parse_and_verify_for(&body, identity) { Ok(hit) => return Ok(hit), Err(err) => last_error = Some(err), }, Err(err) => last_error = Some(err), } } } Err(err) => last_error = Some(err), } // 2. Fall back to the GitHub profile README convention. for url in profile_readme_urls(&self.raw_base, user) { match self.fetch_text(&url).await { Ok(body) => match parse_and_verify_for(&body, identity) { Ok(hit) => return Ok(hit), Err(err) => last_error = Some(err), }, Err(_) => continue, // 404s on profile READMEs are expected. } } Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone()))) } } impl GithubChannel { async fn fetch_text(&self, url: &str) -> ChannelResult { let resp = self .client .get(url) .send() .await .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))? .error_for_status() .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?; resp.text() .await .map_err(|e| ChannelError::Unreachable(format!("read body {url}: {e}"))) } async fn fetch_gist_candidates(&self, user: &str) -> ChannelResult> { let url = gists_url(&self.api_base, user); let resp = self .client .get(&url) .header("Accept", "application/vnd.github+json") .send() .await .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))? .error_for_status() .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?; let body: Value = resp .json() .await .map_err(|e| ChannelError::Other(anyhow::anyhow!("parse gist listing: {e}")))?; Ok(parse_gist_candidates(&body)) } } /// Pure: which file names look like they hold a KEZ proof? pub fn looks_like_kez_filename(name: &str) -> bool { let lower = name.to_lowercase(); lower.ends_with(".kez") || lower.ends_with(".kez.md") || lower.ends_with(".kez.json") || lower.contains("kez") } /// Pure: build the gist-listing URL for a user. pub fn gists_url(api_base: &str, user: &str) -> String { format!("{api_base}/users/{user}/gists?per_page=100") } /// Pure: profile README URLs to try, in order. pub fn profile_readme_urls(raw_base: &str, user: &str) -> Vec { vec![ format!("{raw_base}/{user}/{user}/main/README.md"), format!("{raw_base}/{user}/{user}/master/README.md"), ] } /// Pure: extract raw-URLs of KEZ-looking files from a gist listing payload. pub fn parse_gist_candidates(body: &Value) -> Vec { let Some(gists) = body.as_array() else { return Vec::new(); }; let mut out = Vec::new(); for gist in gists { let Some(files) = gist.get("files").and_then(|f| f.as_object()) else { continue; }; for (name, file) in files { if !looks_like_kez_filename(name) { continue; } if let Some(raw_url) = file.get("raw_url").and_then(|u| u.as_str()) { out.push(raw_url.to_owned()); } } } out } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn filename_filter_accepts_kez_files() { assert!(looks_like_kez_filename("github-jason.kez.md")); assert!(looks_like_kez_filename("proof.kez")); assert!(looks_like_kez_filename("kez.json")); assert!(looks_like_kez_filename("my.kez.json")); assert!(looks_like_kez_filename("KEZ-PROOF.txt")); // case-insensitive, contains "kez" } #[test] fn filename_filter_rejects_unrelated() { assert!(!looks_like_kez_filename("README.md")); assert!(!looks_like_kez_filename("notes.txt")); assert!(!looks_like_kez_filename(".gitignore")); } #[test] fn gists_url_includes_user_and_pagination() { let url = gists_url("https://api.github.com", "jason"); assert_eq!(url, "https://api.github.com/users/jason/gists?per_page=100"); } #[test] fn profile_readme_urls_tries_main_then_master() { let urls = profile_readme_urls("https://raw.githubusercontent.com", "jason"); assert_eq!(urls.len(), 2); assert!(urls[0].ends_with("/jason/jason/main/README.md")); assert!(urls[1].ends_with("/jason/jason/master/README.md")); } #[test] fn parse_gist_candidates_skips_non_kez_files() { let body = json!([ { "files": { "notes.txt": { "raw_url": "https://example/notes" }, "github-jason.kez.md": { "raw_url": "https://example/kez" } } } ]); let candidates = parse_gist_candidates(&body); assert_eq!(candidates, vec!["https://example/kez".to_owned()]); } #[test] fn parse_gist_candidates_handles_empty_and_malformed() { assert!(parse_gist_candidates(&json!([])).is_empty()); assert!(parse_gist_candidates(&json!({})).is_empty()); assert!(parse_gist_candidates(&json!([{ "no_files_field": true }])).is_empty()); } }