OXYGENHUB

Coverage / src/cli/approve.rs 24.8%

31 of 125 lines run, 4 of 33 functions · run #307 on main · run missed

1
//! `oxy seal --flow RUN_URL --job NAME [--reject]` (oxygenhub#235): sign an
2
//! approval of one job of one run with your key, so the hub can show, and
3
//! anyone can check, who released it. The hub says what the job is (its
4
//! hash with its sandbox, the state, the plan it takes, the environment);
5
//! the signature covers exactly that and nothing else.
6
7
use super::{open, ui};
8
use anyhow::{anyhow, bail, Context, Result};
9
use oxy_core::approval::{SignedApproval, Statement};
10
use oxy_core::hash::ObjectRef;
11
use serde_json::Value;
12
13
/// `https://hub/OWNER/NAME/flow/N` to the hub's base, `OWNER/NAME`, N and
14
/// the repository's URL.
153×
fn run_url(url: &str) -> Result<(String, String, i64, String)> {
163×
    let url = url.trim_end_matches('/');
173×
    let (repo_url, n) = url
183×
        .rsplit_once("/flow/")
193×
        .ok_or_else(|| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?;
202×
    let number: i64 = n
212×
        .parse()
222×
        .map_err(|_| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?;
231×
    let (rest, name) = repo_url
241×
        .rsplit_once('/')
251×
        .ok_or_else(|| anyhow!("{url} is not a run URL"))?;
261×
    let (base, owner) = rest
271×
        .rsplit_once('/')
281×
        .ok_or_else(|| anyhow!("{url} is not a run URL"))?;
291×
    Ok((
301×
        base.to_string(),
311×
        format!("{owner}/{name}"),
321×
        number,
331×
        repo_url.to_string(),
341×
    ))
353×
}
36
370×
fn to_hex(b: &[u8]) -> String {
380×
    b.iter().map(|x| format!("{x:02x}")).collect()
390×
}
40
410×
fn oref(v: &Value, key: &str) -> Result<ObjectRef> {
420×
    v.get(key)
430×
        .and_then(Value::as_str)
440×
        .and_then(ObjectRef::from_hex)
450×
        .ok_or_else(|| anyhow!("the hub's statement has no valid {key}"))
460×
}
47
480×
pub fn seal(url: &str, job: &str, reject: bool, as_json: bool) -> Result<i32> {
490×
    let repo = open()?;
500×
    let (base, slug, number, repo_url) = run_url(url)?;
510×
    let options = repo.options_for_url(&repo_url);
520×
    let api = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/approval");
530×
    let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?;
540×
    let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?;
550×
    let repo_hex = v.get("repo").and_then(Value::as_str).unwrap_or("");
560×
    let mine = to_hex(&repo.id()?);
570×
    if repo_hex != mine {
580×
        bail!("run #{number} belongs to repository {repo_hex}, not this checkout's ({mine}): run oxy seal --flow from a checkout of {slug}");
590×
    }
600×
    let mut repo_id = [0u8; 16];
610×
    for (i, b) in repo_id.iter_mut().enumerate() {
620×
        *b = u8::from_str_radix(&repo_hex[i * 2..i * 2 + 2], 16)?;
63
    }
640×
    let inputs = v
650×
        .get("inputs")
660×
        .and_then(Value::as_array)
670×
        .map(|a| {
680×
            a.iter()
690×
                .filter_map(|x| x.as_str().and_then(ObjectRef::from_hex))
700×
                .collect::<Vec<_>>()
710×
        })
720×
        .unwrap_or_default();
730×
    let statement = Statement {
740×
        repo: repo_id,
750×
        run: v
760×
            .get("run")
770×
            .and_then(Value::as_str)
780×
            .unwrap_or(url)
790×
            .to_string(),
800×
        job: job.to_string(),
810×
        job_hash: oref(&v, "job_hash")?,
820×
        state: oref(&v, "state")?,
830×
        inputs,
840×
        environment: v
850×
            .get("environment")
860×
            .and_then(Value::as_str)
870×
            .unwrap_or("")
880×
            .to_string(),
890×
        approve: !reject,
90
    };
910×
    let (key, sk) = repo
920×
        .secret_key()?
930×
        .ok_or_else(|| anyhow!("no signing key: create one with `oxy identity new`"))?;
940×
    let me = repo.identity(&key)?;
950×
    let signed = SignedApproval::sign(&statement, &me, &sk);
960×
    let post = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/seal");
970×
    let payload = serde_json::json!({ "signed": to_hex(&signed.encode()) }).to_string();
980×
    let (code, answer) = oxy_core::protocol::http_json("POST", &post, Some(&payload), &options)
990×
        .map_err(|e| anyhow!("{e}"))?;
1000×
    if !(200..300).contains(&code) {
1010×
        let why = serde_json::from_str::<Value>(&answer)
1020×
            .ok()
1030×
            .and_then(|v| v.get("error").and_then(Value::as_str).map(String::from));
1040×
        bail!(
105
            "the hub refused the seal (HTTP {code}): {}",
1060×
            why.unwrap_or(answer)
107
        );
1080×
    }
1090×
    if as_json {
1100×
        println!("{answer}");
1110×
    } else {
1120×
        let verb = if reject { "Rejected" } else { "Approved" };
1130×
        println!(
114
            "{} {job} of run #{number} with key {}",
1150×
            ui::done(verb),
1160×
            &key.to_hex()[..12]
117
        );
1180×
        println!(
119
            "  state {} · job {}",
1200×
            &statement.state.to_hex()[..12],
1210×
            &statement.job_hash.to_hex()[..12]
122
        );
1230×
        for i in &statement.inputs {
1240×
            println!("  takes {}", &i.to_hex()[..12]);
1250×
        }
1260×
        if !statement.environment.is_empty() {
1270×
            println!("  for environment {}", statement.environment);
1280×
        }
129
    }
1300×
    Ok(0)
1310×
}
132
133
#[cfg(test)]
134
mod tests {
135
    #[test]
1361×
    fn run_urls_are_read() {
1371×
        let (base, slug, n, repo) =
1381×
            super::run_url("https://oxygenhub.io/andymccall/oxygenhub-gcp/flow/8/").unwrap();
1391×
        assert_eq!(
1401×
            (base.as_str(), slug.as_str(), n, repo.as_str()),
141
            (
142
                "https://oxygenhub.io",
143
                "andymccall/oxygenhub-gcp",
144
                8,
145
                "https://oxygenhub.io/andymccall/oxygenhub-gcp"
146
            )
147
        );
1481×
        assert!(super::run_url("https://oxygenhub.io/andymccall/x/proposal/3").is_err());
1491×
        assert!(super::run_url("https://oxygenhub.io/andymccall/x/flow/abc").is_err());
1501×
    }
151
}