soroban_cli/commands/contract/info/
build.rs1use super::shared::{self, Fetched};
2use crate::commands::contract::info::shared::fetch;
3use crate::{commands::global, print::Print, utils::http};
4use base64::Engine as _;
5use clap::Parser;
6use sha2::{Digest, Sha256};
7use soroban_spec_tools::contract;
8use soroban_spec_tools::contract::Spec;
9use std::fmt::Debug;
10use stellar_xdr::curr::{ScMetaEntry, ScMetaV0};
11
12#[derive(Parser, Debug, Clone)]
13#[group(skip)]
14pub struct Cmd {
15 #[command(flatten)]
16 pub common: shared::Args,
17}
18
19#[derive(thiserror::Error, Debug)]
20pub enum Error {
21 #[error(transparent)]
22 Wasm(#[from] shared::Error),
23
24 #[error(transparent)]
25 Spec(#[from] contract::Error),
26
27 #[error("'source_repo' meta entry is not stored in the contract")]
28 SourceRepoNotSpecified,
29
30 #[error("'source_repo' meta entry '{0}' has prefix unsupported, only 'github:' supported")]
31 SourceRepoUnsupported(String),
32
33 #[error(transparent)]
34 Json(#[from] serde_json::Error),
35
36 #[error(transparent)]
37 Reqwest(#[from] reqwest::Error),
38
39 #[error("GitHub attestation not found")]
40 AttestationNotFound,
41
42 #[error("GitHub attestation invalid")]
43 AttestationInvalid,
44
45 #[error("Stellar asset contract doesn't contain meta information")]
46 NoSACMeta(),
47}
48
49impl Cmd {
50 pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
51 let print = Print::new(global_args.quiet);
52 print.warnln("\x1b[31mThis command displays information about the GitHub Actions run that attested to have built the wasm, and does not verify the source code. Please review the run, its workflow, and source code.\x1b[0m".to_string());
53
54 let Fetched { contract, .. } = fetch(&self.common, &print).await?;
55
56 let bytes = match contract {
57 shared::Contract::Wasm { wasm_bytes } => wasm_bytes,
58 shared::Contract::StellarAssetContract => return Err(Error::NoSACMeta()),
59 };
60
61 let wasm_hash = Sha256::digest(&bytes);
62 let wasm_hash_hex = hex::encode(wasm_hash);
63 print.infoln(format!("Wasm Hash: {wasm_hash_hex}"));
64
65 let spec = Spec::new(&bytes)?;
66 let Some(source_repo) = spec.meta.iter().find_map(|meta_entry| {
67 let ScMetaEntry::ScMetaV0(ScMetaV0 { key, val }) = meta_entry;
68 if key.to_string() == "source_repo" {
69 Some(val.to_string())
70 } else {
71 None
72 }
73 }) else {
74 return Err(Error::SourceRepoNotSpecified);
75 };
76 print.infoln(format!("Source Repo: {source_repo}"));
77 let Some(github_source_repo) = source_repo.strip_prefix("github:") else {
78 return Err(Error::SourceRepoUnsupported(source_repo));
79 };
80
81 let url = format!(
82 "https://api.github.com/repos/{github_source_repo}/attestations/sha256:{wasm_hash_hex}"
83 );
84 print.infoln(format!("Collecting GitHub attestation from {url}"));
85 let resp = http::client().get(url).send().await?;
86 let resp: gh_attest_resp::Root = resp.json().await?;
87
88 let payload = resp
92 .attestations
93 .iter()
94 .find_map(|attestation| {
95 let payload = base64::engine::general_purpose::STANDARD
96 .decode(&attestation.bundle.dsse_envelope.payload)
97 .ok()?;
98 let payload: gh_payload::Root = serde_json::from_slice(&payload).ok()?;
99
100 (payload.predicate_type == "https://slsa.dev/provenance/v1").then_some(payload)
101 })
102 .ok_or(Error::AttestationNotFound)?;
103
104 print.checkln("Attestation found linked to GitHub Actions Workflow Run:");
105
106 let workflow_repo = payload
107 .predicate
108 .build_definition
109 .external_parameters
110 .workflow
111 .repository;
112 let workflow_ref = payload
113 .predicate
114 .build_definition
115 .external_parameters
116 .workflow
117 .ref_field;
118 let workflow_path = payload
119 .predicate
120 .build_definition
121 .external_parameters
122 .workflow
123 .path;
124 let git_commit = &payload
125 .predicate
126 .build_definition
127 .resolved_dependencies
128 .first()
129 .ok_or(Error::AttestationInvalid)?
130 .digest
131 .git_commit;
132 let runner_environment = payload
133 .predicate
134 .build_definition
135 .internal_parameters
136 .github
137 .runner_environment
138 .as_str();
139 print.blankln(format!(" \x1b[34mRepository:\x1b[0m {workflow_repo}"));
140 print.blankln(format!(" \x1b[34mRef:\x1b[0m {workflow_ref}"));
141 print.blankln(format!(" \x1b[34mPath:\x1b[0m {workflow_path}"));
142 print.blankln(format!(" \x1b[34mGit Commit:\x1b[0m {git_commit}"));
143 match runner_environment
144 {
145 runner @ "github-hosted" => print.blankln(format!(" \x1b[34mRunner:\x1b[0m {runner}")),
146 runner => print.warnln(format!(" \x1b[34mRunner:\x1b[0m {runner} (runners not hosted by GitHub could have any configuration or environmental changes)")),
147 }
148 print.blankln(format!(
149 " \x1b[34mRun:\x1b[0m {}",
150 payload.predicate.run_details.metadata.invocation_id
151 ));
152 print.globeln(format!(
153 "View the workflow at {workflow_repo}/blob/{git_commit}/{workflow_path}"
154 ));
155 print.globeln(format!(
156 "View the repo at {workflow_repo}/tree/{git_commit}"
157 ));
158
159 Ok(())
160 }
161}
162
163mod gh_attest_resp {
164 use serde::Deserialize;
165 use serde::Serialize;
166
167 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
168 #[serde(rename_all = "camelCase")]
169 pub struct Root {
170 pub attestations: Vec<Attestation>,
171 }
172
173 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
174 #[serde(rename_all = "camelCase")]
175 pub struct Attestation {
176 pub bundle: Bundle,
177 }
178
179 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
180 #[serde(rename_all = "camelCase")]
181 pub struct Bundle {
182 pub dsse_envelope: DsseEnvelope,
183 }
184
185 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
186 #[serde(rename_all = "camelCase")]
187 pub struct DsseEnvelope {
188 pub payload: String,
189 }
190}
191
192mod gh_payload {
193 use serde::Deserialize;
194 use serde::Serialize;
195
196 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
197 #[serde(rename_all = "camelCase")]
198 pub struct Root {
199 pub predicate_type: String,
200 pub predicate: Predicate,
201 }
202
203 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
204 #[serde(rename_all = "camelCase")]
205 pub struct Predicate {
206 pub build_definition: BuildDefinition,
207 pub run_details: RunDetails,
208 }
209
210 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
211 #[serde(rename_all = "camelCase")]
212 pub struct BuildDefinition {
213 pub external_parameters: ExternalParameters,
214 pub internal_parameters: InternalParameters,
215 pub resolved_dependencies: Vec<ResolvedDependency>,
216 }
217
218 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
219 #[serde(rename_all = "camelCase")]
220 pub struct ExternalParameters {
221 pub workflow: Workflow,
222 }
223
224 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
225 #[serde(rename_all = "camelCase")]
226 pub struct Workflow {
227 #[serde(rename = "ref")]
228 pub ref_field: String,
229 pub repository: String,
230 pub path: String,
231 }
232
233 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
234 #[serde(rename_all = "camelCase")]
235 pub struct InternalParameters {
236 pub github: Github,
237 }
238
239 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
240 #[serde(rename_all = "camelCase")]
241 pub struct Github {
242 #[serde(rename = "runner_environment")]
243 pub runner_environment: String,
244 }
245
246 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
247 #[serde(rename_all = "camelCase")]
248 pub struct ResolvedDependency {
249 pub uri: String,
250 pub digest: Digest,
251 }
252
253 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
254 #[serde(rename_all = "camelCase")]
255 pub struct Digest {
256 pub git_commit: String,
257 }
258
259 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
260 #[serde(rename_all = "camelCase")]
261 pub struct RunDetails {
262 pub metadata: Metadata,
263 }
264
265 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
266 #[serde(rename_all = "camelCase")]
267 pub struct Metadata {
268 pub invocation_id: String,
269 }
270}