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::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        // Find the SLSA provenance attestation (not the Release attestation)
89        // GitHub may attach multiple attestations, and we need the one with predicate_type
90        // matching "https://slsa.dev/provenance/v1"
91        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}