soroban_cli/commands/contract/info/
build.rs

1use 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}