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 @@ -422,6 +422,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 archive`

Generate the reproducible source archive used by verifiable builds
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.
fn git_in(dir: &Path, args: &[&str]) {
std::process::Command::new("git")
Expand All @@ -1104,6 +1107,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 @@ -1285,3 +1428,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")));
}
60 changes: 59 additions & 1 deletion cmd/soroban-cli/src/commands/container/shared.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use core::fmt;
use std::process::Stdio;

use clap::ValueEnum;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;

use crate::print::Print;
Expand All @@ -20,6 +22,9 @@ pub enum Error {
program: String,
source: std::io::Error,
},

#[error("could not pull image {image}: {stderr}")]
PullImageFailed { image: String, stderr: String },
}

/// Container runtime to shell out to.
Expand Down Expand Up @@ -261,6 +266,59 @@ impl Args {
};
cmd
}

/// Pull `image`, streaming the engine's high-level status lines ("Pulling
/// from", "Digest", "Status") through `print`. Per-layer progress written to
/// stderr is captured rather than shown and surfaced only when the pull
/// fails, as `PullImageFailed` — callers that need to explain a failed pull
/// (e.g. the verifiable build's tag-listing hint) rely on that captured text.
/// A missing engine binary surfaces via `io_error` as `NotFound`.
pub(crate) async fn pull_image(&self, image: &str, print: &Print) -> Result<(), Error> {
let mut child = self
.pull_command(image)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| self.io_error(e))?;

let stdout = child.stdout.take();
let stderr = child.stderr.take();

let stream_stdout = async {
if let Some(stdout) = stdout {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.contains("Pulling from")
|| line.contains("Digest")
|| line.contains("Status")
{
print.infoln(line);
}
}
}
};

let capture_stderr = async {
let mut buf = String::new();
if let Some(mut stderr) = stderr {
let _ = stderr.read_to_string(&mut buf).await;
}
buf
};

// Drain both pipes concurrently so a full stderr buffer can't deadlock
// the child while we're reading stdout.
let ((), stderr) = tokio::join!(stream_stdout, capture_stderr);

if child.wait().await.map_err(|e| self.io_error(e))?.success() {
Ok(())
} else {
Err(Error::PullImageFailed {
image: image.to_string(),
stderr: stderr.trim().to_string(),
})
}
}
}

/// Resource limits for commands that *run* a container (e.g. `container start`).
Expand Down Expand Up @@ -498,7 +556,7 @@ mod test {
let not_found = std::io::Error::from(std::io::ErrorKind::NotFound);
match args(None, Some(Engine::AppleContainer)).io_error(not_found) {
Error::NotFound { program, .. } => assert_eq!(program, "container"),
Error::Command { .. } => panic!("expected NotFound, got Command"),
other => panic!("expected NotFound, got {other:?}"),
}
}

Expand Down
Loading
Loading