Skip to content

Commit e473057

Browse files
authored
Add command contract info build to display GitHub attestation (experimental) (#1957)
1 parent f558f0c commit e473057

File tree

6 files changed

+311
-8
lines changed

6 files changed

+311
-8
lines changed

FULL_HELP_DOCS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ Access info about contracts
524524
* `interface` — Output the interface of a contract
525525
* `meta` — Output the metadata stored in a contract
526526
* `env-meta` — Output the env required metadata stored in a contract
527+
* `build` — Output the contract build information, if available
527528

528529

529530

@@ -647,6 +648,28 @@ Outputs no data when no data is present in the contract.
647648

648649

649650

651+
## `stellar contract info build`
652+
653+
Output the contract build information, if available.
654+
655+
If the contract has a meta entry like `source_repo=github:user/repo`, this command will try to fetch the attestation information for the WASM file.
656+
657+
**Usage:** `stellar contract info build [OPTIONS] <--wasm <WASM>|--wasm-hash <WASM_HASH>|--contract-id <CONTRACT_ID>>`
658+
659+
###### **Options:**
660+
661+
* `--wasm <WASM>` — Wasm file path on local filesystem. Provide this OR `--wasm-hash` OR `--contract-id`
662+
* `--wasm-hash <WASM_HASH>` — Hash of Wasm blob on a network. Provide this OR `--wasm` OR `--contract-id`
663+
* `--contract-id <CONTRACT_ID>` — Contract ID/alias on a network. Provide this OR `--wasm-hash` OR `--wasm`
664+
* `--rpc-url <RPC_URL>` — RPC server endpoint
665+
* `--rpc-header <RPC_HEADERS>` — RPC Header(s) to include in requests to the RPC provider
666+
* `--network-passphrase <NETWORK_PASSPHRASE>` — Network passphrase to sign the transaction sent to the rpc server
667+
* `-n`, `--network <NETWORK>` — Name of network to use from config
668+
* `--global` — Use global config
669+
* `--config-dir <CONFIG_DIR>` — Location of config directory, default is "."
670+
671+
672+
650673
## `stellar contract init`
651674

652675
Initialize a Soroban contract project.

cmd/soroban-cli/src/commands/contract/info.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::fmt::Debug;
22

33
use crate::commands::global;
44

5+
pub mod build;
56
pub mod env_meta;
67
pub mod interface;
78
pub mod meta;
@@ -49,26 +50,38 @@ pub enum Cmd {
4950
///
5051
/// Outputs no data when no data is present in the contract.
5152
EnvMeta(env_meta::Cmd),
53+
54+
/// Output the contract build information, if available.
55+
///
56+
/// If the contract has a meta entry like `source_repo=github:user/repo`, this command will try
57+
/// to fetch the attestation information for the WASM file.
58+
Build(build::Cmd),
5259
}
5360

5461
#[derive(thiserror::Error, Debug)]
5562
pub enum Error {
5663
#[error(transparent)]
5764
Interface(#[from] interface::Error),
65+
5866
#[error(transparent)]
5967
Meta(#[from] meta::Error),
68+
6069
#[error(transparent)]
6170
EnvMeta(#[from] env_meta::Error),
71+
72+
#[error(transparent)]
73+
Build(#[from] build::Error),
6274
}
6375

6476
impl Cmd {
6577
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
66-
let result = match &self {
78+
match &self {
6779
Cmd::Interface(interface) => interface.run(global_args).await?,
6880
Cmd::Meta(meta) => meta.run(global_args).await?,
6981
Cmd::EnvMeta(env_meta) => env_meta.run(global_args).await?,
82+
Cmd::Build(build) => build.run(global_args).await?,
7083
};
71-
println!("{result}");
84+
7285
Ok(())
7386
}
7487
}
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
use super::shared::{self, Fetched};
2+
use crate::commands::contract::info::shared::fetch;
3+
use crate::{commands::global, print::Print, utils::http};
4+
use base64::Engine as _;
5+
use clap::{command, Parser};
6+
use sha2::{Digest, Sha256};
7+
use soroban_spec_tools::contract;
8+
use soroban_spec_tools::contract::Spec;
9+
use std::fmt::Debug;
10+
use stellar_xdr::curr::{ScMetaEntry, ScMetaV0};
11+
12+
#[derive(Parser, Debug, Clone)]
13+
#[group(skip)]
14+
pub struct Cmd {
15+
#[command(flatten)]
16+
pub common: shared::Args,
17+
}
18+
19+
#[derive(thiserror::Error, Debug)]
20+
pub 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+
49+
impl 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+
154+
mod 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+
183+
mod 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+
}

cmd/soroban-cli/src/commands/contract/info/env_meta.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ pub enum Error {
4141
}
4242

4343
impl Cmd {
44-
pub async fn run(&self, global_args: &global::Args) -> Result<String, Error> {
44+
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
4545
let print = Print::new(global_args.quiet);
4646
let Fetched { contract, .. } = fetch(&self.common, &print).await?;
4747

@@ -79,6 +79,8 @@ impl Cmd {
7979
}
8080
};
8181

82-
Ok(res)
82+
println!("{res}");
83+
84+
Ok(())
8385
}
8486
}

cmd/soroban-cli/src/commands/contract/info/interface.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ pub enum Error {
4444
}
4545

4646
impl Cmd {
47-
pub async fn run(&self, global_args: &global::Args) -> Result<String, Error> {
47+
pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
4848
let print = Print::new(global_args.quiet);
4949
let Fetched { contract, .. } = fetch(&self.common, &print).await?;
5050

@@ -72,6 +72,8 @@ impl Cmd {
7272
.expect("Unexpected spec format error"),
7373
};
7474

75-
Ok(res)
75+
println!("{res}");
76+
77+
Ok(())
7678
}
7779
}

0 commit comments

Comments
 (0)