| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | | use super::json; |
| 5 | | use super::open; |
| 6 | | use super::ui; |
| 7 | | use anyhow::{bail, Result}; |
| 8 | | use oxy_core::receipt::SignedReceipt; |
| 9 | | use oxy_core::repo::ReceiptInfo; |
| 10 | |
|
| 11 | 1× | fn status(problem: Option<&str>, revoked: bool) -> String { |
| 12 | 1× | match (problem, revoked) { |
| 13 | 1× | (Some(p), _) => ui::err(format!("{} {p}", ui::glyph("✘", "x"))), |
| 14 | 0× | (None, true) => ui::warn(format!("{} runner key revoked", ui::glyph("!", "!"))), |
| 15 | 0× | (None, false) => ui::ok(format!("{} signed", ui::glyph("✔", "v"))), |
| 16 | | } |
| 17 | 1× | } |
| 18 | |
|
| 19 | 1× | fn line(sr: &SignedReceipt, problem: Option<&str>, revoked: bool) -> String { |
| 20 | 1× | let r = &sr.receipt; |
| 21 | 1× | let result = if r.passed() { |
| 22 | 0× | ui::ok(format!("{} passed", ui::glyph("●", "*"))) |
| 23 | | } else { |
| 24 | 1× | ui::err(format!("{} exit {}", ui::glyph("●", "*"), r.exit_code)) |
| 25 | | }; |
| 26 | 1× | format!( |
| 27 | | "{} {}{}{}{}{}", |
| 28 | | result, |
| 29 | 1× | ui::subject(&r.job_name), |
| 30 | 1× | ui::sep() + &ui::dim(format!("{:.1} s", r.duration_ms as f64 / 1000.0)), |
| 31 | 1× | ui::sep() + &ui::author(&sr.runner.name), |
| 32 | 1× | ui::sep() + &status(problem, revoked), |
| 33 | 1× | ui::sep() + &ui::dim(ui::ago(sr.attest.hlc.wall_ms)), |
| 34 | | ) |
| 35 | 1× | } |
| 36 | |
|
| 37 | 3× | fn to_json( |
| 38 | 3× | attest: Option<String>, |
| 39 | 3× | sr: &SignedReceipt, |
| 40 | 3× | problem: Option<&str>, |
| 41 | 3× | revoked: bool, |
| 42 | 3× | ) -> String { |
| 43 | 3× | let r = &sr.receipt; |
| 44 | 6× | let refs = |v: &[oxy_core::hash::ObjectRef]| json::arr(v.iter().map(|h| json::s(&h.to_hex()))); |
| 45 | 3× | json::obj([ |
| 46 | 3× | ("attest", json::opt(attest.as_ref(), |a| json::s(a))), |
| 47 | 3× | ("receipt", json::s(&r.hash().to_hex())), |
| 48 | 3× | ("state", json::s(&sr.state().to_hex())), |
| 49 | 3× | ("run", json::s(sr.run())), |
| 50 | 3× | ("job", json::s(&r.job_name)), |
| 51 | 3× | ("exit_code", json::n(r.exit_code)), |
| 52 | 3× | ("duration_ms", json::n(r.duration_ms)), |
| 53 | 3× | ("pipeline", json::s(&r.pipeline_hash.to_hex())), |
| 54 | 3× | ("scope", json::s(&r.scope_tree_hash.to_hex())), |
| 55 | 3× | ("environment", json::s(&r.environment.to_hex())), |
| 56 | 3× | ("inputs", refs(&r.inputs)), |
| 57 | 3× | ("outputs", refs(&r.outputs)), |
| 58 | 3× | ("runner", json::s(&sr.runner.hash().to_hex())), |
| 59 | 3× | ("runner_name", json::s(&sr.runner.name)), |
| 60 | 3× | ("made_ms", json::n(sr.attest.hlc.wall_ms)), |
| 61 | 3× | ("valid", json::b(problem.is_none())), |
| 62 | 3× | ("problem", json::opt(problem, json::s)), |
| 63 | 3× | ("revoked", json::b(revoked)), |
| 64 | | ]) |
| 65 | 3× | } |
| 66 | |
|
| 67 | 1× | fn detail(sr: &SignedReceipt, problem: Option<&str>, revoked: bool) { |
| 68 | 1× | let r = &sr.receipt; |
| 69 | 1× | println!("{}", line(sr, problem, revoked)); |
| 70 | 1× | println!( |
| 71 | | "{}", |
| 72 | 1× | ui::kv("run", if sr.run().is_empty() { "-" } else { sr.run() }) |
| 73 | | ); |
| 74 | 1× | println!( |
| 75 | | "{}", |
| 76 | 1× | ui::kv("state", ui::hash(format!("#{}", sr.state().short(12)))) |
| 77 | | ); |
| 78 | 1× | println!( |
| 79 | | "{}", |
| 80 | 1× | ui::kv("receipt", ui::hash(format!("#{}", r.hash().short(12)))) |
| 81 | | ); |
| 82 | 1× | println!( |
| 83 | | "{}", |
| 84 | 1× | ui::kv("pipeline", ui::hash(r.pipeline_hash.short(12))) |
| 85 | | ); |
| 86 | 1× | println!("{}", ui::kv("scope", ui::hash(r.scope_tree_hash.short(12)))); |
| 87 | 1× | println!( |
| 88 | | "{}", |
| 89 | 1× | ui::kv("environment", ui::hash(r.environment.short(12))) |
| 90 | | ); |
| 91 | 1× | println!( |
| 92 | | "{}", |
| 93 | 1× | ui::kv( |
| 94 | 1× | "runner", |
| 95 | 1× | format!("{} {}", sr.runner.name, ui::dim(sr.runner.hash().short(12))) |
| 96 | | ) |
| 97 | | ); |
| 98 | 2× | for (what, v) in [("input", &r.inputs), ("output", &r.outputs)] { |
| 99 | 2× | for h in v.iter() { |
| 100 | 0× | println!("{}", ui::kv(what, ui::hash(h.short(12)))); |
| 101 | 0× | } |
| 102 | | } |
| 103 | 1× | } |
| 104 | |
|
| 105 | |
|
| 106 | 2× | pub fn list(rev: Option<&str>, as_json: bool) -> Result<i32> { |
| 107 | 2× | let repo = open()?; |
| 108 | 2× | let heads = match rev { |
| 109 | 0× | Some(r) => repo.resolve_heads(r)?, |
| 110 | 2× | None => repo.bookmark_heads()?, |
| 111 | | }; |
| 112 | 2× | let found = repo.receipts(&heads)?; |
| 113 | 2× | if as_json { |
| 114 | 2× | println!( |
| 115 | | "{}", |
| 116 | 2× | json::arr(found.iter().map(|i| to_json( |
| 117 | 1× | Some(i.attest.to_hex()), |
| 118 | 1× | &i.signed, |
| 119 | 1× | i.problem.as_deref(), |
| 120 | 1× | i.revoked |
| 121 | | ))) |
| 122 | | ); |
| 123 | 2× | return Ok(0); |
| 124 | 0× | } |
| 125 | 0× | if found.is_empty() { |
| 126 | 0× | println!("{}", ui::dim("No receipts for this state.")); |
| 127 | 0× | } |
| 128 | 0× | for i in &found { |
| 129 | 0× | println!( |
| 130 | 0× | "{} {}", |
| 131 | 0× | ui::hash(format!("#{}", i.attest.short(12))), |
| 132 | 0× | line(&i.signed, i.problem.as_deref(), i.revoked) |
| 133 | 0× | ); |
| 134 | 0× | } |
| 135 | 0× | Ok(0) |
| 136 | 2× | } |
| 137 | |
|
| 138 | 4× | fn find(repo: &oxy_core::repo::Repo, prefix: &str, rev: Option<&str>) -> Result<ReceiptInfo> { |
| 139 | 4× | let heads = match rev { |
| 140 | 0× | Some(r) => repo.resolve_heads(r)?, |
| 141 | 4× | None => repo.bookmark_heads()?, |
| 142 | | }; |
| 143 | 4× | let p = prefix.trim_start_matches('#').to_ascii_lowercase(); |
| 144 | 4× | let mut hits: Vec<ReceiptInfo> = repo |
| 145 | 4× | .receipts(&heads)? |
| 146 | 4× | .into_iter() |
| 147 | 4× | .filter(|i| i.attest.to_hex().starts_with(&p)) |
| 148 | 4× | .collect(); |
| 149 | 4× | match hits.len() { |
| 150 | 4× | 1 => Ok(hits.remove(0)), |
| 151 | 0× | 0 => bail!("no receipt #{p} for this state; `oxy receipt list` shows them"), |
| 152 | 0× | n => bail!("#{p} matches {n} receipts; give more of the hash"), |
| 153 | | } |
| 154 | 4× | } |
| 155 | |
|
| 156 | |
|
| 157 | 1× | pub fn show(prefix: &str, rev: Option<&str>, as_json: bool) -> Result<i32> { |
| 158 | 1× | let repo = open()?; |
| 159 | 1× | let i = find(&repo, prefix, rev)?; |
| 160 | 1× | if as_json { |
| 161 | 1× | println!( |
| 162 | 1× | "{}", |
| 163 | 1× | to_json( |
| 164 | 1× | Some(i.attest.to_hex()), |
| 165 | 1× | &i.signed, |
| 166 | 1× | i.problem.as_deref(), |
| 167 | 1× | i.revoked |
| 168 | 1× | ) |
| 169 | 1× | ); |
| 170 | 1× | } else { |
| 171 | 0× | detail(&i.signed, i.problem.as_deref(), i.revoked); |
| 172 | 0× | } |
| 173 | 1× | Ok(if i.problem.is_some() { 1 } else { 0 }) |
| 174 | 1× | } |
| 175 | |
|
| 176 | |
|
| 177 | |
|
| 178 | 3× | pub fn export(prefix: &str, file: &str, rev: Option<&str>, format: &str) -> Result<i32> { |
| 179 | 3× | let repo = open()?; |
| 180 | 3× | let i = find(&repo, prefix, rev)?; |
| 181 | 3× | if format == "slsa" { |
| 182 | 2× | return export_slsa(&repo, &i, file); |
| 183 | 1× | } |
| 184 | 1× | std::fs::write(file, i.signed.encode())?; |
| 185 | 1× | println!( |
| 186 | | "{} {} {}", |
| 187 | 1× | ui::done("Exported receipt"), |
| 188 | 1× | ui::hash(format!("#{}", i.attest.short(12))), |
| 189 | 1× | ui::path(file) |
| 190 | | ); |
| 191 | 1× | Ok(0) |
| 192 | 3× | } |
| 193 | |
|
| 194 | |
|
| 195 | | pub const BUILD_TYPE: &str = "https://oxygenhub.io/oxygenflow/job/v1"; |
| 196 | |
|
| 197 | |
|
| 198 | |
|
| 199 | |
|
| 200 | |
|
| 201 | |
|
| 202 | 1× | pub fn statement(sr: &SignedReceipt) -> serde_json::Value { |
| 203 | | use serde_json::json; |
| 204 | 1× | let r = &sr.receipt; |
| 205 | 1× | let subject: Vec<_> = if r.outputs.is_empty() { |
| 206 | 1× | vec![json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } })] |
| 207 | | } else { |
| 208 | 0× | r.outputs |
| 209 | 0× | .iter() |
| 210 | 0× | .map(|o| json!({ "name": o.to_hex(), "digest": { "oxygen-blob": o.to_hex() } })) |
| 211 | 0× | .collect() |
| 212 | | }; |
| 213 | 2× | let rfc3339 = |ms: u64| super::format::when(ms).replacen(' ', "T", 1); |
| 214 | 1× | let made = sr.attest.hlc.wall_ms; |
| 215 | 1× | let mut deps = vec![ |
| 216 | 1× | json!({ "name": "state", "digest": { "oxygen-state": sr.state().to_hex() } }), |
| 217 | 1× | json!({ "name": "scope", "digest": { "oxygen-scope": r.scope_tree_hash.to_hex() } }), |
| 218 | | ]; |
| 219 | 1× | deps.extend( |
| 220 | 1× | r.inputs |
| 221 | 1× | .iter() |
| 222 | 1× | .map(|h| json!({ "name": h.to_hex(), "digest": { "oxygen-blob": h.to_hex() } })), |
| 223 | | ); |
| 224 | 1× | json!({ |
| 225 | 1× | "_type": "https://in-toto.io/Statement/v1", |
| 226 | 1× | "subject": subject, |
| 227 | 1× | "predicateType": "https://slsa.dev/provenance/v1", |
| 228 | 1× | "predicate": { |
| 229 | 1× | "buildDefinition": { |
| 230 | 1× | "buildType": BUILD_TYPE, |
| 231 | 1× | "externalParameters": { |
| 232 | 1× | "job": r.job_name, |
| 233 | 1× | "pipeline": r.pipeline_hash.to_hex(), |
| 234 | | }, |
| 235 | 1× | "internalParameters": { |
| 236 | 1× | "environment": r.environment.to_hex(), |
| 237 | | }, |
| 238 | 1× | "resolvedDependencies": deps, |
| 239 | | }, |
| 240 | 1× | "runDetails": { |
| 241 | 1× | "builder": { |
| 242 | 1× | "id": format!("urn:oxygen:runner:{}", sr.runner.hash().to_hex()), |
| 243 | 1× | "version": { "name": sr.runner.name }, |
| 244 | | }, |
| 245 | 1× | "metadata": { |
| 246 | 1× | "invocationId": sr.run(), |
| 247 | 1× | "startedOn": rfc3339(made.saturating_sub(r.duration_ms)), |
| 248 | 1× | "finishedOn": rfc3339(made), |
| 249 | | }, |
| 250 | 1× | "byproducts": [{ |
| 251 | 1× | "name": "oxygen-receipt", |
| 252 | 1× | "mediaType": "application/vnd.oxygen.receipt+cbor", |
| 253 | 1× | "content": oxy_core::dsse::base64(&sr.encode()), |
| 254 | 1× | "annotations": { |
| 255 | 1× | "exitCode": r.exit_code, |
| 256 | 1× | "durationMs": r.duration_ms, |
| 257 | 1× | "runner": sr.runner.name, |
| 258 | | }, |
| 259 | | }], |
| 260 | | }, |
| 261 | | }, |
| 262 | | }) |
| 263 | 1× | } |
| 264 | |
|
| 265 | 2× | fn export_slsa(repo: &oxy_core::repo::Repo, i: &ReceiptInfo, file: &str) -> Result<i32> { |
| 266 | 2× | if let Some(p) = &i.problem { |
| 267 | 0× | bail!( |
| 268 | | "receipt #{} does not verify ({p}); provenance is only made of a valid one", |
| 269 | 0× | i.attest.short(12) |
| 270 | | ); |
| 271 | 2× | } |
| 272 | 2× | let Some((key, sk)) = repo.secret_key()? else { |
| 273 | 1× | bail!("SLSA provenance is signed with your key, and none is set up here; run `oxy identity create`"); |
| 274 | | }; |
| 275 | 1× | let payload = serde_json::to_vec(&statement(&i.signed))?; |
| 276 | 1× | std::fs::write( |
| 277 | 1× | file, |
| 278 | 1× | oxy_core::dsse::envelope(oxy_core::dsse::IN_TOTO, &payload, &key.to_hex(), &sk), |
| 279 | 0× | )?; |
| 280 | 1× | println!( |
| 281 | | "{} {} {} {}", |
| 282 | 1× | ui::done("Exported SLSA provenance of receipt"), |
| 283 | 1× | ui::hash(format!("#{}", i.attest.short(12))), |
| 284 | 1× | ui::path(file), |
| 285 | 1× | ui::dim(format!("signed by {}", key.short(12))) |
| 286 | | ); |
| 287 | 1× | println!( |
| 288 | | "{}", |
| 289 | 1× | ui::dim(format!( |
| 290 | | " check it: oxy identity export --pem key.pem, then cosign verify-blob-attestation --key key.pem --type slsaprovenance1 --insecure-ignore-tlog=true --check-claims=false --signature {file}" |
| 291 | | )) |
| 292 | | ); |
| 293 | 1× | Ok(0) |
| 294 | 2× | } |
| 295 | |
|
| 296 | 4× | fn read(file: &str) -> Result<SignedReceipt> { |
| 297 | 4× | let bytes = std::fs::read(file).map_err(|e| anyhow::anyhow!("{file}: {e}"))?; |
| 298 | 4× | SignedReceipt::decode(&bytes).map_err(|e| anyhow::anyhow!("{file}: not a signed receipt: {e}")) |
| 299 | 4× | } |
| 300 | |
|
| 301 | |
|
| 302 | 2× | pub fn verify(file: &str, as_json: bool) -> Result<i32> { |
| 303 | 2× | let sr = read(file)?; |
| 304 | 2× | let problem = sr.verify().err().map(|e| e.to_string()); |
| 305 | 2× | if as_json { |
| 306 | 1× | println!("{}", to_json(None, &sr, problem.as_deref(), false)); |
| 307 | 1× | } else { |
| 308 | 1× | detail(&sr, problem.as_deref(), false); |
| 309 | 1× | } |
| 310 | 2× | Ok(if problem.is_some() { 1 } else { 0 }) |
| 311 | 2× | } |
| 312 | |
|
| 313 | |
|
| 314 | 2× | pub fn import(file: &str) -> Result<i32> { |
| 315 | 2× | let repo = open()?; |
| 316 | 2× | let sr = read(file)?; |
| 317 | 2× | let r = repo.add_receipt(&sr)?; |
| 318 | 1× | println!( |
| 319 | | "{} {} {} {} {}", |
| 320 | 1× | ui::done("Kept receipt"), |
| 321 | 1× | ui::hash(format!("#{}", r.short(12))), |
| 322 | 1× | ui::subject(&sr.receipt.job_name), |
| 323 | 1× | ui::dim("by"), |
| 324 | 1× | ui::author(&sr.runner.name) |
| 325 | | ); |
| 326 | 1× | Ok(0) |
| 327 | 2× | } |