Skip to main content

isb_apps/app/
forge.rs

1//! Commit statuses on the forge: a preview's state and URL, shown on the
2//! pull request.
3//!
4//! GitHub (`POST /repos/{owner}/{repo}/statuses/{sha}`, `Authorization:
5//! Bearer`) and Gitea/Forgejo (the same path under `/api/v1`, `Authorization:
6//! token`) take the same body: `{state, target_url, description, context}`.
7//! A status with the same `context` replaces the previous one, so each
8//! preview deploy updates one line on the pull request.
9//!
10//! The token (an org secret) goes only to the API of the host the app's git
11//! URL names, over HTTPS, unless `api_url` says otherwise.
12
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::error::{Error, Result};
19
20/// The status line's name on the pull request.
21pub const CONTEXT: &str = "isb/preview";
22
23/// Which API shape.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum ForgeKind {
27    #[serde(alias = "GitHub")]
28    Github,
29    /// Gitea and Forgejo.
30    #[serde(alias = "forgejo")]
31    Gitea,
32}
33
34/// Where a preview's status goes and with which token.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct StatusSettings {
38    /// An org secret holding an API token that may write commit statuses
39    /// (GitHub: `repo:status` / fine-grained "Commit statuses: write";
40    /// Gitea: `write:repository`).
41    pub token_secret: String,
42    /// `github` or `gitea` (also Forgejo). Default: the webhook's sender.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub kind: Option<ForgeKind>,
45    /// The API base: `https://api.github.com`, `https://git.example.com/api/v1`.
46    /// Default: derived from the git URL (github.com's API, or
47    /// `https://<host>/api/v1`).
48    #[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
69/// `owner/repo` and the host of a git URL (`https://h/o/r.git`,
70/// `git@h:o/r.git`, `ssh://git@h:22/o/r`).
71pub 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
94/// The API base for `git_url`: `api_url` when set; GitHub's API for
95/// github.com; else `https://<host>/api/v1` (Gitea, Forgejo). A plain-HTTP
96/// git URL needs an explicit `api_url`: a token is never sent in clear
97/// unless someone wrote that down.
98pub 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
118/// A commit status: `pending`, `success`, `failure` or `error`.
119pub struct Status<'a> {
120    pub state: &'a str,
121    pub target_url: Option<&'a str>,
122    pub description: &'a str,
123}
124
125/// Post one commit status for `sha` of `owner_repo`.
126pub 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    /// A one-request HTTP server: returns what it received.
251    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}