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::{command, 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 let Some(attestation) = resp.attestations.first() else {
88 return Err(Error::AttestationNotFound);
89 };
90 let Ok(payload) = base64::engine::general_purpose::STANDARD
91 .decode(&attestation.bundle.dsse_envelope.payload)
92 else {
93 return Err(Error::AttestationInvalid);
94 };
95 let payload: gh_payload::Root = serde_json::from_slice(&payload)?;
96 print.checkln("Attestation found linked to GitHub Actions Workflow Run:");
97 let workflow_repo = payload
98 .predicate
99 .build_definition
100 .external_parameters
101 .workflow
102 .repository;
103 let workflow_ref = payload
104 .predicate
105 .build_definition
106 .external_parameters
107 .workflow
108 .ref_field;
109 let workflow_path = payload
110 .predicate
111 .build_definition
112 .external_parameters
113 .workflow
114 .path;
115 let git_commit = &payload
116 .predicate
117 .build_definition
118 .resolved_dependencies
119 .first()
120 .unwrap()
121 .digest
122 .git_commit;
123 let runner_environment = payload
124 .predicate
125 .build_definition
126 .internal_parameters
127 .github
128 .runner_environment
129 .as_str();
130 print.blankln(format!(" \x1b[34mRepository:\x1b[0m {workflow_repo}"));
131 print.blankln(format!(" \x1b[34mRef:\x1b[0m {workflow_ref}"));
132 print.blankln(format!(" \x1b[34mPath:\x1b[0m {workflow_path}"));
133 print.blankln(format!(" \x1b[34mGit Commit:\x1b[0m {git_commit}"));
134 match runner_environment
135 {
136 runner @ "github-hosted" => print.blankln(format!(" \x1b[34mRunner:\x1b[0m {runner}")),
137 runner => print.warnln(format!(" \x1b[34mRunner:\x1b[0m {runner} (runners not hosted by GitHub could have any configuration or environmental changes)")),
138 }
139 print.blankln(format!(
140 " \x1b[34mRun:\x1b[0m {}",
141 payload.predicate.run_details.metadata.invocation_id
142 ));
143 print.globeln(format!(
144 "View the workflow at {workflow_repo}/blob/{git_commit}/{workflow_path}"
145 ));
146 print.globeln(format!(
147 "View the repo at {workflow_repo}/tree/{git_commit}"
148 ));
149
150 Ok(())
151 }
152}
153
154mod gh_attest_resp {
155 use serde::Deserialize;
156 use serde::Serialize;
157
158 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
159 #[serde(rename_all = "camelCase")]
160 pub struct Root {
161 pub attestations: Vec<Attestation>,
162 }
163
164 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
165 #[serde(rename_all = "camelCase")]
166 pub struct Attestation {
167 pub bundle: Bundle,
168 }
169
170 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
171 #[serde(rename_all = "camelCase")]
172 pub struct Bundle {
173 pub dsse_envelope: DsseEnvelope,
174 }
175
176 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
177 #[serde(rename_all = "camelCase")]
178 pub struct DsseEnvelope {
179 pub payload: String,
180 }
181}
182
183mod gh_payload {
184 use serde::Deserialize;
185 use serde::Serialize;
186
187 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
188 #[serde(rename_all = "camelCase")]
189 pub struct Root {
190 pub predicate_type: String,
191 pub predicate: Predicate,
192 }
193
194 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
195 #[serde(rename_all = "camelCase")]
196 pub struct Predicate {
197 pub build_definition: BuildDefinition,
198 pub run_details: RunDetails,
199 }
200
201 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
202 #[serde(rename_all = "camelCase")]
203 pub struct BuildDefinition {
204 pub external_parameters: ExternalParameters,
205 pub internal_parameters: InternalParameters,
206 pub resolved_dependencies: Vec<ResolvedDependency>,
207 }
208
209 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
210 #[serde(rename_all = "camelCase")]
211 pub struct ExternalParameters {
212 pub workflow: Workflow,
213 }
214
215 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
216 #[serde(rename_all = "camelCase")]
217 pub struct Workflow {
218 #[serde(rename = "ref")]
219 pub ref_field: String,
220 pub repository: String,
221 pub path: String,
222 }
223
224 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
225 #[serde(rename_all = "camelCase")]
226 pub struct InternalParameters {
227 pub github: Github,
228 }
229
230 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
231 #[serde(rename_all = "camelCase")]
232 pub struct Github {
233 #[serde(rename = "runner_environment")]
234 pub runner_environment: String,
235 }
236
237 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
238 #[serde(rename_all = "camelCase")]
239 pub struct ResolvedDependency {
240 pub uri: String,
241 pub digest: Digest,
242 }
243
244 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
245 #[serde(rename_all = "camelCase")]
246 pub struct Digest {
247 pub git_commit: String,
248 }
249
250 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
251 #[serde(rename_all = "camelCase")]
252 pub struct RunDetails {
253 pub metadata: Metadata,
254 }
255
256 #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
257 #[serde(rename_all = "camelCase")]
258 pub struct Metadata {
259 pub invocation_id: String,
260 }
261}