//! `oxy seal --flow RUN_URL --job NAME [--reject]` (oxygenhub#235): sign an //! approval of one job of one run with your key, so the hub can show, and //! anyone can check, who released it. The hub says what the job is (its //! hash with its sandbox, the state, the plan it takes, the environment); //! the signature covers exactly that and nothing else. use super::{open, ui}; use anyhow::{anyhow, bail, Context, Result}; use oxy_core::approval::{SignedApproval, Statement}; use oxy_core::hash::ObjectRef; use serde_json::Value; /// `https://hub/OWNER/NAME/flow/N` to the hub's base, `OWNER/NAME`, N and /// the repository's URL. fn run_url(url: &str) -> Result<(String, String, i64, String)> { let url = url.trim_end_matches('/'); let (repo_url, n) = url .rsplit_once("/flow/") .ok_or_else(|| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; let number: i64 = n .parse() .map_err(|_| anyhow!("{url} is not a run URL (…/OWNER/NAME/flow/N)"))?; let (rest, name) = repo_url .rsplit_once('/') .ok_or_else(|| anyhow!("{url} is not a run URL"))?; let (base, owner) = rest .rsplit_once('/') .ok_or_else(|| anyhow!("{url} is not a run URL"))?; Ok(( base.to_string(), format!("{owner}/{name}"), number, repo_url.to_string(), )) } fn to_hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } fn oref(v: &Value, key: &str) -> Result { v.get(key) .and_then(Value::as_str) .and_then(ObjectRef::from_hex) .ok_or_else(|| anyhow!("the hub's statement has no valid {key}")) } pub fn seal(url: &str, job: &str, reject: bool, as_json: bool) -> Result { let repo = open()?; let (base, slug, number, repo_url) = run_url(url)?; let options = repo.options_for_url(&repo_url); let api = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/approval"); let body = oxy_core::protocol::http_get(&api, &options).map_err(|e| anyhow!("{e}"))?; let v: Value = serde_json::from_slice(&body).context("the hub did not answer with JSON")?; let repo_hex = v.get("repo").and_then(Value::as_str).unwrap_or(""); let mine = to_hex(&repo.id()?); if repo_hex != mine { bail!("run #{number} belongs to repository {repo_hex}, not this checkout's ({mine}): run oxy seal --flow from a checkout of {slug}"); } let mut repo_id = [0u8; 16]; for (i, b) in repo_id.iter_mut().enumerate() { *b = u8::from_str_radix(&repo_hex[i * 2..i * 2 + 2], 16)?; } let inputs = v .get("inputs") .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(|x| x.as_str().and_then(ObjectRef::from_hex)) .collect::>() }) .unwrap_or_default(); let statement = Statement { repo: repo_id, run: v .get("run") .and_then(Value::as_str) .unwrap_or(url) .to_string(), job: job.to_string(), job_hash: oref(&v, "job_hash")?, state: oref(&v, "state")?, inputs, environment: v .get("environment") .and_then(Value::as_str) .unwrap_or("") .to_string(), approve: !reject, }; let (key, sk) = repo .secret_key()? .ok_or_else(|| anyhow!("no signing key: create one with `oxy identity new`"))?; let me = repo.identity(&key)?; let signed = SignedApproval::sign(&statement, &me, &sk); let post = format!("{base}/api/v1/repos/{slug}/flow/runs/{number}/jobs/{job}/seal"); let payload = serde_json::json!({ "signed": to_hex(&signed.encode()) }).to_string(); let (code, answer) = oxy_core::protocol::http_json("POST", &post, Some(&payload), &options) .map_err(|e| anyhow!("{e}"))?; if !(200..300).contains(&code) { let why = serde_json::from_str::(&answer) .ok() .and_then(|v| v.get("error").and_then(Value::as_str).map(String::from)); bail!( "the hub refused the seal (HTTP {code}): {}", why.unwrap_or(answer) ); } if as_json { println!("{answer}"); } else { let verb = if reject { "Rejected" } else { "Approved" }; println!( "{} {job} of run #{number} with key {}", ui::done(verb), &key.to_hex()[..12] ); println!( " state {} · job {}", &statement.state.to_hex()[..12], &statement.job_hash.to_hex()[..12] ); for i in &statement.inputs { println!(" takes {}", &i.to_hex()[..12]); } if !statement.environment.is_empty() { println!(" for environment {}", statement.environment); } } Ok(0) } #[cfg(test)] mod tests { #[test] fn run_urls_are_read() { let (base, slug, n, repo) = super::run_url("https://oxygenhub.io/andymccall/oxygenhub-gcp/flow/8/").unwrap(); assert_eq!( (base.as_str(), slug.as_str(), n, repo.as_str()), ( "https://oxygenhub.io", "andymccall/oxygenhub-gcp", 8, "https://oxygenhub.io/andymccall/oxygenhub-gcp" ) ); assert!(super::run_url("https://oxygenhub.io/andymccall/x/proposal/3").is_err()); assert!(super::run_url("https://oxygenhub.io/andymccall/x/flow/abc").is_err()); } }