Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,19 @@ To view the commands that will be executed, without executing them, use the --pr

- `--print-commands-only` — Print commands to build without executing them

Incompatible with `--verifiable`, which compiles from a throwaway extracted-archive tempdir that only exists for the build, so a printed command bind-mounting it could never be replayed.

###### **Verifiable Options:**

- `--verifiable` — Produce a SEP-58 verifiable (reproducible) build.

Snapshots the working tree into a byte-reproducible source archive, builds it in a digest-pinned container image, and records provenance meta (bldimg, source_uri, source_sha256, bldopt) into the wasm so a third party can reproduce the exact bytes. Implies `--locked`. Requires a clean git tree. Requires `--image` pinned by digest (`<registry-host>/<repo>@sha256:<64-hex>`) so the recorded `bldimg` names the exact bytes.

Incompatible with `--print-commands-only`: a verifiable build compiles from an extracted-archive tempdir that only exists for the build, so a printed command bind-mounting it could never be replayed.

- `--source-sha256 <SOURCE_SHA256>` — Pin the SEP-58 source_sha256 of the generated archive (64-char lower-case hex). The build fails if the archive hashes to a different value
- `--source-uri <SOURCE_URI>` — Record a SEP-58 source_uri where the source archive can be fetched (a URI with a scheme, e.g. https://example.com/src.tar.gz)

## `stellar contract build archive`

Generate (or inspect) the reproducible source archive for a contract.
Expand Down
213 changes: 213 additions & 0 deletions cmd/crates/soroban-test/tests/it/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,9 @@ fn build_always_injects_cli_version() {
);
}

const ZERO_DIGEST: &str =
"docker.io/stellar/stellar-cli@sha256:0000000000000000000000000000000000000000000000000000000000000000";

// Convenience: drive a git command in a fixture directory, asserting it succeeds
// so a failed setup can't silently push tests down the non-git path.
fn git_in(dir: &Path, args: &[&str]) {
Expand All @@ -1106,6 +1109,146 @@ fn fresh_workspace() -> (TempDir, PathBuf) {
(temp, workspace)
}

// `--verifiable` cannot accept reserved `--meta` keys that the cli writes itself.
#[test]
fn verifiable_meta_conflict_errors() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--source-sha256")
.arg("a".repeat(64))
.arg("--meta")
.arg("bldimg=not-allowed")
.assert()
.failure()
.stderr(predicate::str::contains("reserved key: bldimg"));
}

// A verifiable build compiles from a throwaway extracted-archive tempdir, so a
// `--print-commands-only` command bind-mounting it could never be replayed;
// clap rejects the combination up front.
#[test]
fn verifiable_rejects_print_commands_only() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--print-commands-only")
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
}

// `--image` is validated against the SEP-58 bldimg regex; tag-only refs fail.
#[test]
fn verifiable_image_must_be_digest_pinned() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg("docker.io/stellar/stellar-cli:latest")
.arg("--source-sha256")
.arg("a".repeat(64))
.assert()
.failure()
.stderr(predicate::str::contains("bldimg format"));
}

// SEP-58 metadata must be ASCII; a non-ASCII `--image` is rejected before the
// bldimg format check.
#[test]
fn verifiable_image_must_be_ascii() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

let non_ascii = format!("localhost:5000/café@sha256:{}", "0".repeat(64));

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(non_ascii)
.arg("--source-sha256")
.arg("a".repeat(64))
.assert()
.failure()
.stderr(predicate::str::contains("must be ASCII"));
}

// SEP-58 bldimg requires an explicit registry host (e.g. `docker.io/...`).
// Implicit Docker-Hub-style short refs are rejected.
#[test]
fn verifiable_image_requires_explicit_registry_host() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

let short_ref = format!("stellar/stellar-cli@sha256:{}", "0".repeat(64));

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(short_ref)
.arg("--source-sha256")
.arg("a".repeat(64))
.assert()
.failure()
.stderr(predicate::str::contains("bldimg format"));
}

// `--verifiable` always generates the source archive (and computes
// source_sha256) before the docker stage, so the "Wrote source archive" line
// appears even though the build then fails to reach a real image.
#[test]
fn verifiable_always_writes_source_archive() {
let sandbox = TestEnv::default();
let (_temp, workspace) = fresh_workspace();
git_in(&workspace, &["init", "-q", "-b", "main"]);
git_in(&workspace, &["add", "-A"]);
git_in(&workspace, &["commit", "-q", "-m", "init"]);

sandbox
.new_assert_cmd("contract")
.current_dir(workspace.join("contracts").join("add"))
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.assert()
.failure()
.stderr(
predicate::str::contains("Wrote source archive")
.and(predicate::str::contains("source_sha256")),
);
}

// `contract archive --out-file` writes the gzipped tarball and prints its
// source_sha256.
#[test]
Expand Down Expand Up @@ -1345,3 +1488,73 @@ fn contract_archive_dirty_tree_errors() {
"no archive should be written for a dirty tree"
);
}

// `--source-sha256` value must match the 64-hex regex.
#[test]
fn verifiable_source_sha256_format_errors() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--source-sha256")
.arg("not-a-sha")
.assert()
.failure()
.stderr(predicate::str::contains("source_sha256 format"));
}

// `--source-uri` value must be a URI with a scheme.
#[test]
fn verifiable_source_uri_format_errors() {
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/contracts/add");

sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--source-sha256")
.arg("a".repeat(64))
.arg("--source-uri")
.arg("not a uri")
.assert()
.failure()
.stderr(predicate::str::contains("source_uri format"));
}

// A dirty git tree is a hard fail under `--verifiable` (the recorded
// source_sha256 would not describe the bytes built).
#[test]
fn verifiable_dirty_tree_errors() {
let sandbox = TestEnv::default();
let (_temp, workspace) = fresh_workspace();
git_in(&workspace, &["init", "-q", "-b", "main"]);
git_in(&workspace, &["add", "-A"]);
git_in(&workspace, &["commit", "-q", "-m", "init"]);
// Dirty the tree after committing so status is non-empty.
std::fs::write(workspace.join("dirty.txt"), b"uncommitted").unwrap();

sandbox
.new_assert_cmd("contract")
.current_dir(workspace.join("contracts").join("add"))
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(ZERO_DIGEST)
.arg("--source-sha256")
.arg("a".repeat(64))
.assert()
.failure()
.stderr(predicate::str::contains("dirty").or(predicate::str::contains("clean tree")));
}
55 changes: 52 additions & 3 deletions cmd/soroban-cli/src/commands/contract/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::utils::XDR_DEPTH_LIMIT;
use crate::{
commands::{
container::shared::{Args as ContainerArgs, RunArgs as ContainerRunArgs},
global, version, HEADING_CONTAINER,
global, version, HEADING_CONTAINER, HEADING_VERIFIABLE,
},
print::Print,
wasm,
Expand All @@ -32,6 +32,7 @@ use crate::{
pub mod archive;
pub mod container;
pub(crate) mod source_archive;
pub mod verifiable;

/// A built WASM artifact with its package name and file path.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -104,7 +105,11 @@ pub struct Cmd {
pub locked: bool,

/// Print commands to build without executing them
#[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
///
/// Incompatible with `--verifiable`, which compiles from a throwaway
/// extracted-archive tempdir that only exists for the build, so a printed
/// command bind-mounting it could never be replayed.
#[arg(long, conflicts_with_all = ["out_dir", "verifiable"], help_heading = "Other")]
pub print_commands_only: bool,

/// Build inside this container image (e.g.
Expand All @@ -126,6 +131,37 @@ pub struct Cmd {
#[arg(long, requires = "image", help_heading = HEADING_CONTAINER)]
pub pull: bool,

/// Produce a SEP-58 verifiable (reproducible) build.
///
/// Snapshots the working tree into a byte-reproducible source archive,
/// builds it in a digest-pinned container image, and records provenance meta
/// (bldimg, source_uri, source_sha256, bldopt) into the wasm so a third
/// party can reproduce the exact bytes. Implies `--locked`. Requires a clean
/// git tree. Requires `--image` pinned by digest
Comment on lines +139 to +140
/// (`<registry-host>/<repo>@sha256:<64-hex>`) so the recorded `bldimg` names
/// the exact bytes.
///
/// Incompatible with `--print-commands-only`: a verifiable build compiles
/// from an extracted-archive tempdir that only exists for the build, so a
/// printed command bind-mounting it could never be replayed.
#[arg(long, requires = "image", help_heading = HEADING_VERIFIABLE)]
pub verifiable: bool,

/// Pin the SEP-58 source_sha256 of the generated archive (64-char lower-case
/// hex). The build fails if the archive hashes to a different value.
#[arg(long, requires = "verifiable", help_heading = HEADING_VERIFIABLE)]
pub source_sha256: Option<String>,

/// Record a SEP-58 source_uri where the source archive can be fetched (a URI
/// with a scheme, e.g. https://example.com/src.tar.gz).
#[arg(
long,
requires = "verifiable",
requires = "source_sha256",
help_heading = HEADING_VERIFIABLE
)]
Comment on lines +157 to +162
pub source_uri: Option<String>,

#[command(flatten)]
pub build_args: BuildArgs,

Expand Down Expand Up @@ -260,6 +296,9 @@ pub enum Error {

#[error(transparent)]
Archive(#[from] archive::Error),

#[error(transparent)]
Verifiable(#[from] verifiable::Error),
}

pub(crate) const WASM_TARGET: &str = "wasm32v1-none";
Expand All @@ -280,6 +319,9 @@ impl Default for Cmd {
print_commands_only: false,
image: None,
pull: false,
verifiable: false,
source_sha256: None,
source_uri: None,
build_args: BuildArgs::default(),
container_args: ContainerArgs::default(),
run_args: ContainerRunArgs::default(),
Expand All @@ -301,7 +343,14 @@ impl Cmd {

let print = Print::new(global_args.quiet);

// When an image is given, build inside that container instead of locally.
// A verifiable build archives the source and builds it in a
// digest-pinned container, recording SEP-58 provenance meta.
if self.verifiable {
return verifiable::run(self, global_args, &print).await;
}

// When an image is given (without --verifiable), build inside that
// container instead of locally.
if self.image.is_some() {
return container::run(self, global_args, &print).await;
}
Expand Down
Loading
Loading