Skip to content
13 changes: 13 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,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`. In a git repo the working tree must be clean (a warning is printed if the source isn't a git repo, since cleanliness can't be verified). 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). The build always computes and records source_sha256, so this needs only `--verifiable`

## `stellar contract build archive`

Generate (or inspect) the reproducible source archive for a contract.
Expand Down
192 changes: 192 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,125 @@ 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"));
}

// The source-archive stage (writing the archive and computing source_sha256) is
// exercised end-to-end by the Docker-gated `integration/verifiable.rs`; these
// engine-free tests deliberately stop at validation so they never reach a real
// container.

// `contract archive --out-file` writes the gzipped tarball and prints its
// source_sha256.
#[test]
Expand Down Expand Up @@ -1345,3 +1467,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")));
}
1 change: 1 addition & 0 deletions cmd/crates/soroban-test/tests/it/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ mod snapshot;
mod token;
mod tx;
mod util;
mod verifiable;
130 changes: 130 additions & 0 deletions cmd/crates/soroban-test/tests/it/integration/verifiable.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//! End-to-end coverage for `stellar contract build --verifiable`.
//!
//! Unlike the unit/validation tests, this drives a real container build, so it
//! requires a working docker/engine — hence it lives in the opt-in `integration`
//! suite (run with `--features it`). It asserts the happy path the cheaper tests
//! can't: a successful in-container build, the artifact copied back to the host
//! target dir, and the SEP-58 provenance meta stamped into the wasm.

use fs_extra::dir::CopyOptions;
use predicates::prelude::*;
use soroban_test::{AssertExt, TestEnv};
use std::path::{Path, PathBuf};
use std::process::Command;

/// A digest-pinned ref for the official CLI image, resolved dynamically (pulling
/// first) so it never goes stale. `--verifiable` requires a digest pin with an
/// explicit registry host.
fn pinned_cli_image() -> String {
let tag = "stellar/stellar-cli:latest";
let pulled = Command::new("docker")
.args(["pull", tag])
.status()
.expect("docker must be available for verifiable-build integration tests");
assert!(pulled.success(), "failed to pull {tag}");

let out = Command::new("docker")
.args(["inspect", "--format", "{{index .RepoDigests 0}}", tag])
.output()
.expect("docker inspect");
let repo_digest = String::from_utf8(out.stdout).unwrap().trim().to_string();
// e.g. `stellar/stellar-cli@sha256:…`; prepend the registry host the CLI wants.
format!("docker.io/{repo_digest}")
}

fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed");
}

#[test]
fn verifiable_build_stamps_sep58_metadata_and_copies_artifact() {
let sandbox = TestEnv::default();

// Copy the workspace fixture into the sandbox and make it a clean git repo —
// a verifiable build requires a committed tree.
let cargo_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture = cargo_dir.join("tests/fixtures/workspace");
fs_extra::dir::copy(&fixture, sandbox.dir(), &CopyOptions::new()).unwrap();
let workspace = sandbox.dir().join("workspace");
git(&workspace, &["init", "-q", "-b", "main"]);
// Never sign, regardless of the runner's global git config.
git(&workspace, &["config", "commit.gpgsign", "false"]);
git(&workspace, &["add", "-A"]);
git(&workspace, &["commit", "-q", "-m", "init"]);

let image = pinned_cli_image();

sandbox
.new_assert_cmd("contract")
.current_dir(&workspace)
.arg("build")
.arg("--verifiable")
.arg("--image")
.arg(&image)
.arg("--pull")
.arg("--package")
.arg("add")
// `--source-uri` needs only `--verifiable`; the hash is computed and stamped.
.arg("--source-uri")
.arg("https://example.com/src.tar.gz")
.assert()
.success()
// The archive is generated and its hash computed before the container
// build, so the "Wrote source archive" line is emitted on the happy path.
.stderr(
predicate::str::contains("Wrote source archive")
.and(predicate::str::contains("source_sha256")),
);

// The build runs in an extracted tempdir, so the artifact must be copied back
// to the host workspace target dir.
let wasm = workspace.join("target/wasm32v1-none/release/add.wasm");
assert!(
wasm.exists(),
"verifiable build should copy the wasm to the host target dir"
);

let meta = sandbox
.new_assert_cmd("contract")
.current_dir(&workspace)
.arg("info")
.arg("meta")
.arg("--wasm")
.arg(&wasm)
.assert()
.success()
.stdout_as_str();

assert!(
meta.contains("bldimg: docker.io/stellar/stellar-cli@sha256:"),
"expected bldimg in meta, got:\n{meta}"
);
assert!(
meta.contains("source_uri: https://example.com/src.tar.gz"),
"expected source_uri in meta, got:\n{meta}"
);
assert!(
meta.contains("source_sha256: "),
"expected computed source_sha256 in meta, got:\n{meta}"
);
// bldopts are recorded verbatim (SEP-58), including the implied `--locked`.
assert!(
meta.contains("bldopt: --locked"),
"expected implied --locked bldopt, got:\n{meta}"
);
assert!(
meta.contains("bldopt: --package=add"),
"expected --package bldopt, got:\n{meta}"
);
}
Loading
Loading