1use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::error::{Error, Result};
19
20pub const CONTEXT: &str = "isb/preview";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ForgeKind {
27 #[serde(alias = "GitHub")]
28 Github,
29 #[serde(alias = "forgejo")]
31 Gitea,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct StatusSettings {
38 pub token_secret: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub kind: Option<ForgeKind>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub api_url: Option<String>,
50}
51
52impl StatusSettings {
53 pub fn validate(&self) -> Result<()> {
54 crate::secrets::validate_name(&self.token_secret)?;
55 if let Some(u) = &self.api_url {
56 if !(u.starts_with("https://") || u.starts_with("http://"))
57 || u.chars().any(|c| c.is_whitespace() || c.is_control())
58 || u["https://".len().min(u.len())..].contains('@')
59 {
60 return Err(Error::invalid(format!(
61 "previews.status.api_url {u:?}: an http(s) URL without credentials"
62 )));
63 }
64 }
65 Ok(())
66 }
67}
68
69pub fn repo_of(git_url: &str) -> Option<(String, String, String)> {
72 let (scheme, host, path) = match git_url.split_once("://") {
73 Some((scheme, rest)) => {
74 let (auth, path) = rest.split_once('/')?;
75 let host = auth.rsplit('@').next()?.to_string();
76 (scheme.to_string(), host, path.to_string())
77 }
78 None => {
79 let (h, path) = git_url.split_once(':')?;
80 let host = h.rsplit('@').next()?.to_string();
81 ("ssh".to_string(), host, path.to_string())
82 }
83 };
84 let path = path.trim_matches('/').trim_end_matches(".git");
85 let mut parts = path.rsplitn(2, '/');
86 let repo = parts.next()?.to_string();
87 let owner = parts.next()?.rsplit('/').next()?.to_string();
88 if owner.is_empty() || repo.is_empty() || host.is_empty() {
89 return None;
90 }
91 Some((scheme, host, format!("{owner}/{repo}")))
92}
93
94pub fn api_base(kind: ForgeKind, git_url: &str, api_url: Option<&str>) -> Result<String> {
99 if let Some(u) = api_url {
100 return Ok(u.trim_end_matches('/').to_string());
101 }
102 let (scheme, host, _) = repo_of(git_url)
103 .ok_or_else(|| Error::invalid(format!("cannot tell the repository of {git_url}")))?;
104 let bare = host.split(':').next().unwrap_or(&host).to_ascii_lowercase();
105 match kind {
106 ForgeKind::Github if bare == "github.com" => Ok("https://api.github.com".into()),
107 ForgeKind::Github => Ok(format!("https://{bare}/api/v3")),
108 ForgeKind::Gitea if scheme == "https" => Ok(format!("https://{host}/api/v1")),
109 ForgeKind::Gitea if matches!(scheme.as_str(), "ssh" | "git+ssh" | "ssh+git") => {
110 Ok(format!("https://{bare}/api/v1"))
111 }
112 ForgeKind::Gitea => Err(Error::invalid(format!(
113 "{git_url} is not HTTPS: set previews.status.api_url to send the token anyway"
114 ))),
115 }
116}
117
118pub struct Status<'a> {
120 pub state: &'a str,
121 pub target_url: Option<&'a str>,
122 pub description: &'a str,
123}
124
125pub fn post_status(
127 kind: ForgeKind,
128 api: &str,
129 owner_repo: &str,
130 token: &str,
131 sha: &str,
132 st: &Status,
133) -> Result<()> {
134 if !crate::app::git::is_sha(sha) {
135 return Err(Error::invalid(format!("not a commit SHA: {sha}")));
136 }
137 let (owner, repo) = owner_repo
138 .split_once('/')
139 .ok_or_else(|| Error::invalid(format!("not owner/repo: {owner_repo}")))?;
140 let url = format!(
141 "{api}/repos/{}/{}/statuses/{sha}",
142 crate::client::encode_segment(owner),
143 crate::client::encode_segment(repo)
144 );
145 let step = format!("post a commit status to {api}");
146 let fail = |m: String| Error::OperationFailed {
147 step: step.clone(),
148 message: m,
149 };
150 let auth = match kind {
151 ForgeKind::Github => format!("Bearer {}", token.trim()),
152 ForgeKind::Gitea => format!("token {}", token.trim()),
153 };
154 if auth.contains(['\n', '\r']) {
155 return Err(Error::invalid("the forge token holds a line break"));
156 }
157 let mut desc: String = st.description.chars().take(139).collect();
158 if desc.is_empty() {
159 desc = st.state.into();
160 }
161 let mut body = json!({"state": st.state, "description": desc, "context": CONTEXT});
162 if let Some(u) = st.target_url {
163 body["target_url"] = json!(u);
164 }
165 let agent: ureq::Agent = ureq::Agent::config_builder()
166 .timeout_global(Some(Duration::from_secs(20)))
167 .http_status_as_error(false)
168 .user_agent(concat!("isb/", env!("CARGO_PKG_VERSION")))
169 .build()
170 .into();
171 let payload = serde_json::to_vec(&body)?;
172 let mut resp = agent
173 .post(&url)
174 .header("Authorization", &auth)
175 .header("Accept", "application/json")
176 .header("Content-Type", "application/json")
177 .send(&payload[..])
178 .map_err(|e| fail(e.to_string()))?;
179 let code = resp.status().as_u16();
180 if (200..300).contains(&code) {
181 return Ok(());
182 }
183 let text = resp
184 .body_mut()
185 .with_config()
186 .limit(64 << 10)
187 .read_to_string()
188 .unwrap_or_default();
189 let first: String = text
190 .lines()
191 .next()
192 .unwrap_or("")
193 .chars()
194 .take(200)
195 .collect();
196 Err(fail(format!("HTTP {code}: {first}")))
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::io::{BufRead, BufReader, Read, Write};
203
204 #[test]
205 fn repos_and_api_bases() {
206 assert_eq!(
207 repo_of("https://github.com/acme/web.git").unwrap(),
208 ("https".into(), "github.com".into(), "acme/web".into())
209 );
210 assert_eq!(
211 repo_of("git@github.com:acme/web.git").unwrap().2,
212 "acme/web"
213 );
214 assert_eq!(
215 repo_of("ssh://git@git.example.com:2222/team/sub/app")
216 .unwrap()
217 .2,
218 "sub/app"
219 );
220 assert!(repo_of("https://h/onlyone").is_none());
221 let gh = |u| api_base(ForgeKind::Github, u, None).unwrap();
222 assert_eq!(gh("https://github.com/a/b"), "https://api.github.com");
223 assert_eq!(gh("git@github.com:a/b"), "https://api.github.com");
224 assert_eq!(gh("https://ghe.corp/a/b"), "https://ghe.corp/api/v3");
225 let gt = |u| api_base(ForgeKind::Gitea, u, None);
226 assert_eq!(
227 gt("https://git.example.com:3000/a/b").unwrap(),
228 "https://git.example.com:3000/api/v1"
229 );
230 assert!(gt("http://10.0.0.2:3000/a/b").is_err(), "no token in clear");
231 assert_eq!(
232 api_base(
233 ForgeKind::Gitea,
234 "http://10.0.0.2:3000/a/b",
235 Some("http://10.0.0.2:3000/api/v1/")
236 )
237 .unwrap(),
238 "http://10.0.0.2:3000/api/v1"
239 );
240 let s = |u: &str| StatusSettings {
241 token_secret: "t".into(),
242 kind: None,
243 api_url: Some(u.into()),
244 };
245 assert!(s("https://api.github.com").validate().is_ok());
246 assert!(s("https://u:p@h/api").validate().is_err());
247 assert!(s("file:///x").validate().is_err());
248 }
249
250 fn fake(
252 status: u16,
253 ) -> (
254 String,
255 std::thread::JoinHandle<(String, Vec<String>, String)>,
256 ) {
257 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
258 let addr = format!("http://{}", l.local_addr().unwrap());
259 let h = std::thread::spawn(move || {
260 let (s, _) = l.accept().unwrap();
261 let mut r = BufReader::new(s.try_clone().unwrap());
262 let mut line = String::new();
263 r.read_line(&mut line).unwrap();
264 let mut headers = Vec::new();
265 let mut len = 0;
266 loop {
267 let mut h = String::new();
268 r.read_line(&mut h).unwrap();
269 let h = h.trim_end().to_string();
270 if h.is_empty() {
271 break;
272 }
273 if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") {
274 len = v.trim().parse().unwrap();
275 }
276 headers.push(h);
277 }
278 let mut body = vec![0u8; len];
279 r.read_exact(&mut body).unwrap();
280 let mut s = s;
281 write!(
282 s,
283 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"
284 )
285 .unwrap();
286 (line, headers, String::from_utf8(body).unwrap())
287 });
288 (addr, h)
289 }
290
291 #[test]
292 fn posts_github_and_gitea_shapes() {
293 let sha = "a".repeat(40);
294 let st = Status {
295 state: "success",
296 target_url: Some("https://web-x.sslip.io/"),
297 description: "preview ready",
298 };
299 let (api, h) = fake(201);
300 post_status(ForgeKind::Github, &api, "acme/web", "tok\n", &sha, &st).unwrap();
301 let (line, headers, body) = h.join().unwrap();
302 assert_eq!(
303 line.trim_end(),
304 format!("POST /repos/acme/web/statuses/{sha} HTTP/1.1")
305 );
306 assert!(
307 headers
308 .iter()
309 .any(|h| h == "authorization: Bearer tok" || h == "Authorization: Bearer tok"),
310 "{headers:?}"
311 );
312 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
313 assert_eq!(v["state"], "success");
314 assert_eq!(v["context"], CONTEXT);
315 assert_eq!(v["target_url"], "https://web-x.sslip.io/");
316
317 let (api, h) = fake(201);
318 post_status(
319 ForgeKind::Gitea,
320 &format!("{api}/api/v1"),
321 "o/r",
322 "t2",
323 &sha,
324 &st,
325 )
326 .unwrap();
327 let (line, headers, _) = h.join().unwrap();
328 assert!(line.starts_with(&format!("POST /api/v1/repos/o/r/statuses/{sha} ")));
329 assert!(
330 headers
331 .iter()
332 .any(|h| h.eq_ignore_ascii_case("authorization: token t2"))
333 );
334
335 let (api, h) = fake(403);
336 let e = post_status(ForgeKind::Github, &api, "o/r", "t", &sha, &st).unwrap_err();
337 assert!(e.to_string().contains("403"), "{e}");
338 h.join().unwrap();
339 assert!(post_status(ForgeKind::Github, "http://x", "o/r", "t", "main", &st).is_err());
340 }
341}