diff --git a/Cargo.lock b/Cargo.lock index 1f3dc66cf..628bcbef5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2382,9 +2382,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -2959,9 +2959,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -4505,9 +4505,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5410,6 +5410,7 @@ dependencies = [ "hex", "home", "humantime", + "ignore", "indexmap 2.11.0", "itertools 0.10.5", "jsonrpsee-types", @@ -5451,6 +5452,7 @@ dependencies = [ "strsim", "strum 0.17.1", "strum_macros 0.17.1", + "tar", "tempfile", "termcolor", "termcolor_output", @@ -6089,6 +6091,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "temp-dir" version = "0.1.16" diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index fe7257053..6c7ea2ae0 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -85,6 +85,7 @@ Tools for smart contract developers - `alias` — Utilities to manage contract aliases - `bindings` — Generate code client bindings for a contract - `build` — Build a contract from source +- `archive` — Generate the reproducible source archive used by verifiable builds - `extend` — Extend the time to live ledger of a contract-data ledger entry - `deploy` — Deploy a wasm contract - `fetch` — Fetch a contract's Wasm binary @@ -414,6 +415,26 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them +###### **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. When `--image` is omitted, a `docker.io/stellar/stellar-cli:-rust` image is derived and pinned to its digest. + +- `--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 ` — 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 + +**Usage:** `stellar contract archive [OPTIONS]` + +###### **Options:** + +- `-o`, `--out-file ` — Where to write the gzipped tarball. Required unless `--dry-run` is used +- `--dry-run` — List the entries that would be archived and the computed source_sha256, without writing any file + ## `stellar contract extend` Extend the time to live ledger of a contract-data ledger entry. diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index dddf87fd8..6ceb82c8b 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1080,3 +1080,322 @@ fn build_always_injects_cli_version() { "CLI version should not be empty" ); } + +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") + .args(args) + .current_dir(dir) + .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(); +} + +// Init a tempdir copy of the workspace fixture and return the workspace path. +fn fresh_workspace() -> (TempDir, PathBuf) { + let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture_path = cargo_dir.join("tests/fixtures/workspace"); + let temp = TempDir::new().unwrap(); + fs_extra::dir::copy(&fixture_path, temp.path(), &CopyOptions::new()).unwrap(); + let workspace = temp.path().join("workspace"); + (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")); +} + +// `--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 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] +fn contract_archive_writes_out() { + 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"]); + + let out = temp.path().join("src.tar.gz"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) + .assert() + .success() + .stderr( + predicate::str::contains("Wrote source archive") + .and(predicate::str::contains("source_sha256")), + ); + + assert!(out.exists(), "the archive should be written to --out-file"); + assert!( + std::fs::metadata(&out).unwrap().len() > 0, + "the archive should not be empty" + ); +} + +// `contract archive --dry-run` lists the archived entries and the +// source_sha256 without writing any file. +#[test] +fn contract_archive_dry_run_lists_entries() { + 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"]); + + let out = temp.path().join("should-not-exist.tar.gz"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--dry-run") + .assert() + .success() + .stdout(predicate::str::contains("source/Cargo.toml")) + .stderr(predicate::str::contains("source_sha256")); + + assert!(!out.exists(), "--dry-run must not write an archive"); +} + +// `--out-file` must name a gzipped tarball (.tar.gz / .tgz). +#[test] +fn contract_archive_rejects_bad_out_file_extension() { + 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"]); + + let out = temp.path().join("src.zip"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) + .assert() + .failure() + .stderr(predicate::str::contains(".tar.gz or .tgz")); + + assert!( + !out.exists(), + "no archive should be written on a bad extension" + ); +} + +// `--out-file` is required unless `--dry-run` is passed. +#[test] +fn contract_archive_requires_out_file_without_dry_run() { + let sandbox = TestEnv::default(); + let (_temp, workspace) = fresh_workspace(); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .assert() + .failure() + .stderr(predicate::str::contains("--out-file")); +} + +// A dirty git tree is a hard fail for `contract archive` too, matching +// `--verifiable`: the source_sha256 must describe a committed state. +#[test] +fn contract_archive_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(); + + let out = temp.path().join("src.tar.gz"); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) + .assert() + .failure() + .stderr(predicate::str::contains("dirty")); + + assert!( + !out.exists(), + "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"))); +} diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 1e1d26225..f0d53fb67 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -128,6 +128,8 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "sync-s whoami = "1.5.2" serde_with = "3.11.0" rustc_version = "0.4.1" +tar = "0.4.40" +ignore = "0.4.26" # Used to read the current uid/gid so container builds don't leave root-owned # artifacts on Linux bind mounts. diff --git a/cmd/soroban-cli/src/commands/container/shared.rs b/cmd/soroban-cli/src/commands/container/shared.rs index 3bbd07391..830a5eafc 100644 --- a/cmd/soroban-cli/src/commands/container/shared.rs +++ b/cmd/soroban-cli/src/commands/container/shared.rs @@ -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; @@ -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. @@ -94,6 +99,44 @@ impl Engine { Engine::AppleContainer => stderr.contains("not found"), } } + + /// The `inspect`-family argv (after the engine binary and any host flag) + /// that prints an image's digest metadata: docker's `RepoDigests` Go + /// template vs Apple's `image inspect` JSON (Apple groups image operations + /// under the `image` subcommand and has no `--format` templates). + fn image_inspect_args(self, image: &str) -> Vec<&str> { + match self { + Engine::Docker => vec!["inspect", "--format", "{{index .RepoDigests 0}}", image], + Engine::AppleContainer => vec!["image", "inspect", image], + } + } + + /// Parse the stdout of [`image_inspect_args`] into a content-addressed + /// `@sha256:` reference, or `None` when the engine reports no + /// digest (e.g. a locally-built image never pushed or pulled). + fn parse_repo_digest(self, stdout: &[u8], image: &str) -> Option { + match self { + Engine::Docker => { + let digest = String::from_utf8_lossy(stdout).trim().to_string(); + (!digest.is_empty() && digest != "").then_some(digest) + } + // Apple emits a JSON array whose first entry carries the manifest-list + // descriptor at `configuration.descriptor.digest` — the equivalent of + // docker's `RepoDigests`. The per-platform `variants[].digest` is + // deliberately not used. `None` if the output doesn't have that shape. + Engine::AppleContainer => { + let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; + let digest = value + .as_array()? + .first()? + .get("configuration")? + .get("descriptor")? + .get("digest")? + .as_str()?; + Some(format!("{}@{digest}", repo_of(image))) + } + } + } } impl fmt::Display for Engine { @@ -261,6 +304,93 @@ 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(), + }) + } + } + + /// Resolve a locally-present image to its content-addressed repo digest + /// (`@sha256:`), so a caller can pin the exact bytes rather than a + /// mutable tag. Returns `Ok(None)` when the engine reports no digest (e.g. a + /// locally-built image that was never pushed or pulled). The per-engine + /// `inspect` argv and output parsing live on [`Engine`]; this owns only the + /// command execution (the engine binary and `--docker-host`). + pub(crate) async fn image_repo_digest(&self, image: &str) -> Result, Error> { + let engine = self.engine(); + let output = self + .base_command() + .args(engine.image_inspect_args(image)) + .output() + .await + .map_err(|e| self.io_error(e))?; + if !output.status.success() { + return Ok(None); + } + Ok(engine.parse_repo_digest(&output.stdout, image)) + } +} + +/// The repo portion of an image reference: everything before the `:tag` (or +/// `@digest`). A `:` only separates a tag when it appears after the last `/`, so +/// a registry host's `:port` (e.g. `localhost:5000/foo`) is preserved. +fn repo_of(image: &str) -> &str { + if let Some((repo, _)) = image.split_once('@') { + return repo; + } + let last_slash = image.rfind('/').map_or(0, |i| i + 1); + match image[last_slash..].find(':') { + Some(colon) => &image[..last_slash + colon], + None => image, + } } /// Resource limits for commands that *run* a container (e.g. `container start`). @@ -498,10 +628,70 @@ 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:?}"), } } + #[test] + fn repo_of_strips_tag_but_keeps_registry_port() { + assert_eq!( + repo_of("docker.io/stellar/stellar-cli:26.1.0-rust1.90.0"), + "docker.io/stellar/stellar-cli" + ); + assert_eq!(repo_of("localhost:5000/foo:bar"), "localhost:5000/foo"); + assert_eq!(repo_of("localhost:5000/foo"), "localhost:5000/foo"); + // An already digest-pinned ref keeps its repo. + assert_eq!( + repo_of(&format!( + "docker.io/stellar/stellar-cli@sha256:{}", + "a".repeat(64) + )), + "docker.io/stellar/stellar-cli" + ); + } + + #[test] + fn apple_repo_digest_reads_manifest_list_descriptor() { + // Shape mirrors real `container image inspect` output: the top-level + // manifest-list digest lives at [0].configuration.descriptor.digest, + // while the per-platform digest under variants[] must be ignored. + let list = format!("sha256:{}", "8d".repeat(32)); + let variant = format!("sha256:{}", "85".repeat(32)); + let json = format!( + r#"[{{"configuration":{{"descriptor":{{"digest":"{list}","mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","size":743}},"name":"docker.io/stellar/quickstart:latest"}},"id":"8ddf","variants":[{{"digest":"{variant}","platform":{{"architecture":"arm64","os":"linux"}}}}]}}]"# + ); + assert_eq!( + Engine::AppleContainer + .parse_repo_digest(json.as_bytes(), "docker.io/stellar/quickstart:latest"), + Some(format!("docker.io/stellar/quickstart@{list}")) + ); + } + + #[test] + fn docker_parse_repo_digest_trims_and_rejects_no_value() { + assert_eq!( + Engine::Docker.parse_repo_digest(b" docker.io/stellar/cli@sha256:abc\n", "ignored"), + Some("docker.io/stellar/cli@sha256:abc".to_string()) + ); + assert_eq!( + Engine::Docker.parse_repo_digest(b"\n", "ignored"), + None + ); + assert_eq!(Engine::Docker.parse_repo_digest(b" \n", "ignored"), None); + } + + #[test] + fn apple_repo_digest_none_when_shape_unexpected() { + let apple = Engine::AppleContainer; + assert_eq!(apple.parse_repo_digest(b"[]", "foo:bar"), None); + assert_eq!(apple.parse_repo_digest(b"not json", "foo:bar"), None); + // Missing the configuration.descriptor.digest path. + assert_eq!( + apple.parse_repo_digest(br#"[{"id":"8ddf"}]"#, "foo:bar"), + None + ); + } + #[test] fn docker_stderr_classifiers_match_expected_strings() { let docker = args(None, None); diff --git a/cmd/soroban-cli/src/commands/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/archive.rs new file mode 100644 index 000000000..34248fb5f --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/archive.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use clap::Parser; +use sha2::{Digest, Sha256}; + +use crate::{commands::global, print::Print}; + +use super::build::source_archive; + +/// Accepted `--out-file` suffixes (lower-case). The archive is always a gzipped +/// tarball, so the filename must say so. +const ARCHIVE_EXTENSIONS: &[&str] = &[".tar.gz", ".tgz"]; + +/// Generate (or inspect) the reproducible source archive for a contract. +/// +/// Produces the same gzipped tarball that `stellar contract build --verifiable` +/// builds from, and prints its SHA-256 (the SEP-58 `source_sha256`). Use +/// `--dry-run` to list exactly what would be archived without writing anything — +/// handy for confirming the contents before a verifiable build, or for +/// producing the archive to host at a `--source-uri`. +/// +/// The archive is the current working directory, honoring the project's +/// `.gitignore` and `.ignore` files (the `.git` directory itself is always +/// skipped). Run this from the project (or workspace) root you want archived. +#[derive(Parser, Debug, Clone)] +#[group(skip)] +pub struct Cmd { + /// Where to write the gzipped tarball. Required unless `--dry-run` is used. + #[arg(long, short = 'o', required_unless_present = "dry_run")] + pub out_file: Option, + + /// List the entries that would be archived and the computed source_sha256, + /// without writing any file. + #[arg(long)] + pub dry_run: bool, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + SourceArchive(#[from] source_archive::Error), + + #[error( + "--out-file {0} must end in .tar.gz or .tgz (the archive is always a gzipped tarball)" + )] + OutFileExtension(String), +} + +impl Cmd { + pub fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = Print::new(global_args.quiet); + + let source_root = source_archive::resolve_source_root(); + + // The archive is the working tree, so a dirty repo would bake uncommitted + // changes into the bytes and the printed source_sha256 — refuse it, so the + // hash always corresponds to a committed state (matching --verifiable). + source_archive::ensure_clean_tree(&source_root, &print)?; + + // The dry-run listing itself reveals the contents, so skip the + // "not a git repository" warning there. + let bytes = source_archive::build_source_archive(&source_root, &print, !self.dry_run)?; + let sha = hex::encode(Sha256::digest(&bytes)); + + if self.dry_run { + let names = source_archive::entry_names(&bytes)?; + let prefix = print.compute_emoji("📄"); + + for name in &names { + println!("{prefix} {name}"); + } + print.infoln(format!("{} files", names.len())); + print.infoln(format!("source_sha256 {sha}")); + return Ok(()); + } + + // `--out-file` is required when not `--dry-run`, so this is always set here. + let out = self + .out_file + .as_ref() + .expect("--out-file is required without --dry-run"); + + // The output is always a gzipped tarball, so require a matching + // extension to keep the filename honest. + let name = out + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + if !ARCHIVE_EXTENSIONS.iter().any(|ext| name.ends_with(ext)) { + return Err(Error::OutFileExtension(out.display().to_string())); + } + + if let Some(parent) = out.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|source| { + source_archive::Error::ArchiveWrite { + path: out.clone(), + source, + } + })?; + } + } + std::fs::write(out, &bytes).map_err(|source| source_archive::Error::ArchiveWrite { + path: out.clone(), + source, + })?; + print.checkln(format!( + "Wrote source archive {} (source_sha256 {sha})", + out.display() + )); + + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 5a00d1894..c17e6764f 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -23,13 +23,15 @@ 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, }; 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)] @@ -121,6 +123,33 @@ 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. When `--image` is omitted, a + /// `docker.io/stellar/stellar-cli:-rust` image is derived and + /// pinned to its digest. + #[arg(long, 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, + + /// 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 + )] + pub source_uri: Option, + #[command(flatten)] pub build_args: BuildArgs, @@ -244,6 +273,9 @@ pub enum Error { #[error(transparent)] Container(#[from] container::Error), + + #[error(transparent)] + Verifiable(#[from] verifiable::Error), } pub(crate) const WASM_TARGET: &str = "wasm32v1-none"; @@ -264,6 +296,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(), @@ -277,7 +312,14 @@ impl Cmd { pub async fn run(&self, global_args: &global::Args) -> Result, Error> { 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; } diff --git a/cmd/soroban-cli/src/commands/contract/build/container.rs b/cmd/soroban-cli/src/commands/contract/build/container.rs index d90773827..cc2f23ebc 100644 --- a/cmd/soroban-cli/src/commands/contract/build/container.rs +++ b/cmd/soroban-cli/src/commands/contract/build/container.rs @@ -26,18 +26,18 @@ use super::{get_wasm_target, BuiltContract, Cmd, WASM_TARGET, WASM_TARGET_OLD}; /// First CLI release whose `contract build` accepts `--locked` (added in cli /// v25.2.0). Older images reject it, so it's dropped (with a warning) on anything /// older, matching the version detected from the image's own `version` output. -const LOCKED_MIN: &str = "25.2.0"; +pub(super) const LOCKED_MIN: &str = "25.2.0"; /// First CLI release whose `contract build` has the `--optimize` flag at all. /// Older images reject it, so — since optimization is on by default — this is the /// effective minimum supported image. We probe the image's `version` and skip the /// flag (with a warning) on anything older. -const OPTIMIZE_FLAG_MIN: &str = "23.2.0"; +pub(super) const OPTIMIZE_FLAG_MIN: &str = "23.2.0"; /// First CLI release whose `contract build` accepts `--optimize=false` as an /// explicit value. Images between [`OPTIMIZE_FLAG_MIN`] and this default to *not* /// optimizing, so for them we forward nothing to get an unoptimized build. -const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0"; +pub(super) const OPTIMIZE_NEW_SYNTAX_MIN: &str = "26.1.0"; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -155,14 +155,18 @@ pub async fn run( let container_cmds: Vec> = targets .iter() .map(|target| { + // Plain container builds forward the user's `--locked` (when the + // image accepts it) and don't record bldopts. forwarded_build_args( cmd, &workspace_root, *target, - supports_locked, + cmd.locked && supports_locked, supports_optimize_flag, supports_optimize_false, + false, ) + .0 }) .collect(); @@ -200,6 +204,7 @@ pub async fn run( &docker, &cmd.run_args, &bin, + "stellar-contract-build", print, print_only, ) @@ -210,10 +215,10 @@ pub async fn run( return Ok(Vec::new()); } - collect_built_contracts(cmd, &md, &workspace_root) + collect_built_contracts(cmd, &md, &workspace_root, None) } -fn metadata(cmd: &Cmd) -> Result { +pub(super) fn metadata(cmd: &Cmd) -> Result { let mut mc = MetadataCommand::new(); mc.no_deps(); if let Some(p) = &cmd.manifest_path { @@ -226,7 +231,7 @@ fn metadata(cmd: &Cmd) -> Result Vec { +pub(super) fn resolve_packages(cmd: &Cmd, md: &cargo_metadata::Metadata) -> Vec { if let Some(pkg) = &cmd.package { return vec![pkg.clone()]; } @@ -247,13 +252,16 @@ fn resolve_packages(cmd: &Cmd, md: &cargo_metadata::Metadata) -> Vec { } /// The `contract build …` argv forwarded to the container, mirroring the local -/// build's flags. `--manifest-path` is relativized against the workspace root so -/// it's valid inside `/source`. `--out-dir` is deliberately omitted — artifacts -/// are collected on the host from the mounted `target/`. +/// build's flags, plus (when `record_bldopts`) the shell-escaped `bldopt` +/// strings recorded into SEP-58 metadata by verifiable builds. `--manifest-path` +/// is relativized against the workspace/source root so it's valid inside +/// `/source`. `--out-dir` is deliberately omitted — artifacts are collected on +/// the host from the mounted `target/`. /// -/// `supports_locked`: whether the container's `contract build` accepts `--locked` -/// (added in cli 25.2.0). When false, the user's `--locked` is dropped rather -/// than forwarded to an image that would reject it. +/// `include_locked`: whether to add `--locked`. A plain container build passes +/// `cmd.locked && supports_locked` (the user's flag, when the image accepts it); +/// a verifiable build implies it (`supports_locked`). Either way it's dropped on +/// images too old to accept the flag (added in cli 25.2.0). /// /// `supports_optimize_flag`: whether the container's cli has the `--optimize` /// flag at all (added in cli 23.2.0). When false, nothing about optimize is @@ -263,18 +271,47 @@ fn resolve_packages(cmd: &Cmd, md: &cargo_metadata::Metadata) -> Vec { /// `--optimize=false` (added in cli 26.1.0). When false and the user disabled /// optimization, nothing is forwarded — the older cli defaults to not /// optimizing, and passing `--optimize=false` there would fail. -fn forwarded_build_args( +/// +/// `record_bldopts`: when true, every forwarded build-affecting flag is also +/// captured as a `bldopt` (its value shell-escaped once, at the source, so each +/// recorded option is valid shell on its own) for the verifiable build's SEP-58 +/// metadata. A plain container build passes false and ignores the second tuple +/// element. +#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] +pub(super) fn forwarded_build_args( cmd: &Cmd, workspace_root: &Path, package: Option<&str>, - supports_locked: bool, + include_locked: bool, supports_optimize_flag: bool, supports_optimize_false: bool, -) -> Vec { + record_bldopts: bool, +) -> (Vec, Vec) { let mut args = vec!["contract".to_string(), "build".to_string()]; + let mut bldopts: Vec = Vec::new(); + + // Record a build option. `None` means a bare flag (`--locked`); `Some(v)` + // means `--flag=v`. The forwarded copy keeps the value raw (the container + // gets it as argv, and `compose_shell_command` re-escapes it for the + // multi-package `sh -c`); the bldopt copy shell-escapes only the value side, + // once, so every recorded option is valid shell on its own — e.g. + // `--meta=note='added on build'`, never `'--meta=note=added on build'`. + let mut record = |key: &str, value: Option<&str>| { + if let Some(v) = value { + args.push(format!("{key}={v}")); + if record_bldopts { + bldopts.push(format!("{key}={}", shell_escape::escape(v.into()))); + } + } else { + args.push(key.to_string()); + if record_bldopts { + bldopts.push(key.to_string()); + } + } + }; - if cmd.locked && supports_locked { - args.push("--locked".to_string()); + if include_locked { + record("--locked", None); } if let Some(path) = &cmd.manifest_path { let abs = std::path::absolute(path).unwrap_or_else(|_| path.clone()); @@ -282,25 +319,25 @@ fn forwarded_build_args( .strip_prefix(workspace_root) .map(Path::to_path_buf) .unwrap_or(abs); - args.push(format!("--manifest-path={}", rel.display())); + record("--manifest-path", Some(rel.display().to_string().as_str())); } if cmd.profile != "release" { - args.push(format!("--profile={}", cmd.profile)); + record("--profile", Some(cmd.profile.as_str())); } if let Some(features) = &cmd.features { - args.push(format!("--features={features}")); + record("--features", Some(features.as_str())); } if cmd.all_features { - args.push("--all-features".to_string()); + record("--all-features", None); } if cmd.no_default_features { - args.push("--no-default-features".to_string()); + record("--no-default-features", None); } if let Some(pkg) = package { - args.push(format!("--package={pkg}")); + record("--package", Some(pkg)); } for (k, v) in &cmd.build_args.meta { - args.push(format!("--meta={k}={v}")); + record(&format!("--meta={k}"), Some(v.as_str())); } // Optimization is forwarded per the image's cli version. To enable it, bare // `--optimize` on images >= v23.2.0 (older images lack the flag entirely, so @@ -308,13 +345,13 @@ fn forwarded_build_args( // older ones default to not optimizing, so forwarding nothing matches. if cmd.build_args.optimize { if supports_optimize_flag { - args.push("--optimize".to_string()); + record("--optimize", None); } } else if supports_optimize_false { - args.push("--optimize=false".to_string()); + record("--optimize", Some("false")); } - args + (args, bldopts) } async fn pull_image(docker: &shared::Args, image: &str, print: &Print) -> Result<(), Error> { @@ -363,18 +400,18 @@ async fn run_probe( /// Facts probed from the image before building, gathered in one throwaway /// container to avoid a round-trip per fact. -struct ImageProbe { +pub(super) struct ImageProbe { /// CLI binary on the image's PATH — `stellar` (v21.0.0+) or `soroban` /// (older). Used when invoking the CLI by name in the chained multi-build /// command; the single-build path uses the image's entrypoint instead. - bin: String, + pub(super) bin: String, /// Parsed CLI version, or `None` when the image reported no parseable version /// (treated as a current image by the caller). - version: Option, + pub(super) version: Option, /// The image's default rustup toolchain (e.g. /// `1.97.1-aarch64-unknown-linux-gnu`), pinned into `RUSTUP_TOOLCHAIN`. /// Guaranteed non-empty — the probe hard-fails when it can't be determined. - toolchain: String, + pub(super) toolchain: String, } /// Probe the image once for everything the build needs: the CLI binary name, its @@ -383,7 +420,7 @@ struct ImageProbe { /// already require) that detects the binary, then reports each fact on its own /// tagged line so the combined stdout can be split apart. Hard-fails when no /// default toolchain can be determined, rather than building unpinned. -async fn probe_image(image: &str, docker: &shared::Args) -> Result { +pub(super) async fn probe_image(image: &str, docker: &shared::Args) -> Result { // Detect the binary first, then run `$bin version` (version on its first // line) and `rustup default` (the toolchain name). Tag each line so we can // pick the values back out regardless of any extra output. @@ -445,7 +482,7 @@ fn parse_default_toolchain(stdout: &str) -> Option { } #[allow(clippy::too_many_arguments)] -async fn run_in_container( +pub(super) async fn run_in_container( image: &str, workspace_root: &Path, container_cmds: &[Vec], @@ -453,6 +490,7 @@ async fn run_in_container( docker: &shared::Args, run_args: &shared::RunArgs, bin: &str, + container_name_prefix: &str, print: &Print, print_only: bool, ) -> Result<(), Error> { @@ -512,7 +550,7 @@ async fn run_in_container( // per invocation so concurrent builds don't collide, and kept out of the // reproduce line where a fixed name would clash on re-run. let container_name = format!( - "stellar-contract-build-{}-{:08x}", + "{container_name_prefix}-{}-{:08x}", std::process::id(), rand::random::() ); @@ -697,17 +735,35 @@ fn newest_existing_artifact(candidates: &[PathBuf]) -> Option { .cloned() } -/// Collect the built wasm from the mounted `target/`. Because the working tree -/// was bind-mounted, the container writes artifacts straight to the host under -/// `/target///`. The container's rust toolchain -/// decides the target triple, so both known triples are probed. Copies to -/// `--out-dir` when set. -fn collect_built_contracts( +/// Collect the built wasm artifacts. Package names and the host target dir come +/// from host `cargo metadata`. +/// +/// `extracted_root` is `None` for a plain container build: the working tree was +/// bind-mounted, so the container wrote artifacts straight to the host target +/// dir and they're read (and optionally copied to `--out-dir`) in place. It's +/// `Some(er)` for a verifiable build, where the container built from an +/// extracted-archive tempdir; the artifacts then live under that tree's target +/// dir and must be copied back to the host target dir (or `--out-dir`) before +/// the tempdir drops. `source_root` is the host source root the extracted tree +/// mirrors, so the target dir's position relative to it carries over. +/// +/// The container's rust toolchain decides the target triple, so both known +/// triples are probed and the *freshest* artifact wins (an earlier build into +/// the other triple can leave a stale wasm behind). +pub(super) fn collect_built_contracts( cmd: &Cmd, md: &cargo_metadata::Metadata, - workspace_root: &Path, + source_root: &Path, + extracted_root: Option<&Path>, ) -> Result, super::Error> { - let target_root = workspace_root.join("target"); + let host_target = md.target_directory.as_std_path(); + + // Where the build actually wrote artifacts: under the extracted tree's + // target dir for a verifiable build, else the host target dir directly. + let src_target = match extracted_root { + Some(er) => er.join(host_target.strip_prefix(source_root).unwrap_or(host_target)), + None => host_target.to_path_buf(), + }; let mut out = Vec::new(); for p in &md.packages { @@ -727,30 +783,48 @@ fn collect_built_contracts( } let file = format!("{}.wasm", p.name.replace('-', "_")); - // The container may build for either wasm target depending on its rust - // version, so probe both triple dirs. Pick the *freshest* rather than the - // first that exists: an earlier build into the other triple can leave a - // stale wasm behind, and selecting by existence alone would return it. - // Fall back to the current host default for the reported path when the - // build produced nothing. - let candidates: Vec = [WASM_TARGET, WASM_TARGET_OLD] + // Probe both triple dirs (the container's rust version decides which), + // picking the freshest by mtime rather than the first that exists. The + // chosen triple's relative path is reused to mirror the layout when + // copying a verifiable build's artifact back to the host target dir. + let rel_candidates: Vec = [WASM_TARGET, WASM_TARGET_OLD] .iter() - .map(|triple| target_root.join(triple).join(&cmd.profile).join(&file)) + .map(|triple| Path::new(triple).join(&cmd.profile).join(&file)) .collect(); - let src = newest_existing_artifact(&candidates).unwrap_or_else(|| { - let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string()); - target_root.join(triple).join(&cmd.profile).join(&file) - }); + let abs_candidates: Vec = rel_candidates + .iter() + .map(|rel| src_target.join(rel)) + .collect(); + let chosen_rel = newest_existing_artifact(&abs_candidates) + .and_then(|src| src.strip_prefix(&src_target).ok().map(Path::to_path_buf)) + .unwrap_or_else(|| { + let triple = get_wasm_target().unwrap_or_else(|_| WASM_TARGET.to_string()); + Path::new(&triple).join(&cmd.profile).join(&file) + }); + let src = src_target.join(&chosen_rel); + + // Destination: `--out-dir` wins; else if the build ran in an extracted + // tempdir, copy into the host target dir so the artifact survives the + // tempdir drop; else leave it in place (already on the host). + let dest = if let Some(out_dir) = &cmd.out_dir { + Some(out_dir.join(&file)) + } else if extracted_root.is_some() { + Some(host_target.join(&chosen_rel)) + } else { + None + }; - let path = if let Some(out_dir) = &cmd.out_dir { - std::fs::create_dir_all(out_dir).map_err(super::Error::CreatingOutDir)?; - let dest = out_dir.join(&file); - if src.exists() { + let path = match dest { + Some(dest) if src.exists() => { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(super::Error::CreatingOutDir)?; + } std::fs::copy(&src, &dest).map_err(super::Error::CopyingWasmFile)?; + dest } - dest - } else { - src + // Source missing: report the intended dest (matches prior leniency). + Some(dest) => dest, + None => src, }; out.push(BuiltContract { @@ -774,12 +848,14 @@ mod tests { #[test] fn forwarded_build_args_defaults() { let cmd = Cmd::default(); - let args = forwarded_build_args(&cmd, ws(), None, true, true, true); + let (args, bldopts) = forwarded_build_args(&cmd, ws(), None, false, true, true, false); assert_eq!(args[..2], ["contract".to_string(), "build".to_string()]); // Default optimize=true → bare `--optimize`; no `--locked` unless asked. assert!(args.contains(&"--optimize".to_string())); assert!(!args.iter().any(|a| a == "--locked")); assert!(!args.iter().any(|a| a.starts_with("--package"))); + // Plain container builds don't record bldopts. + assert!(bldopts.is_empty()); } #[test] @@ -788,19 +864,21 @@ mod tests { locked: true, ..Cmd::default() }; - let args = forwarded_build_args(&cmd, ws(), Some("contract-a"), true, true, true); + let (args, _) = + forwarded_build_args(&cmd, ws(), Some("contract-a"), true, true, true, false); assert!(args.contains(&"--locked".to_string())); assert!(args.contains(&"--package=contract-a".to_string())); } #[test] fn forwarded_build_args_drops_locked_when_unsupported() { - // User asked for --locked but the image's cli doesn't accept it. + // User asked for --locked but the image's cli doesn't accept it, so the + // caller passes include_locked=false (cmd.locked && supports_locked). let cmd = Cmd { locked: true, ..Cmd::default() }; - let args = forwarded_build_args(&cmd, ws(), None, false, true, true); + let (args, _) = forwarded_build_args(&cmd, ws(), None, false, true, true, false); assert!(!args.iter().any(|a| a == "--locked")); } @@ -810,7 +888,7 @@ mod tests { // though optimize defaults to true. let cmd = Cmd::default(); assert!(cmd.build_args.optimize); - let args = forwarded_build_args(&cmd, ws(), None, true, false, false); + let (args, _) = forwarded_build_args(&cmd, ws(), None, false, false, false, false); assert!(!args.iter().any(|a| a.starts_with("--optimize"))); } @@ -830,7 +908,7 @@ mod tests { }, ..Cmd::default() }; - let args = forwarded_build_args(&cmd, ws(), None, true, true, true); + let (args, _) = forwarded_build_args(&cmd, ws(), None, false, true, true, false); assert!(args.contains(&"--profile=dev".to_string())); assert!(args.contains(&"--features=a,b".to_string())); assert!(args.contains(&"--all-features".to_string())); @@ -840,6 +918,32 @@ mod tests { assert!(args.contains(&"--optimize=false".to_string())); } + #[test] + fn forwarded_build_args_records_bldopts_when_requested() { + // Verifiable builds pass record_bldopts=true and include_locked=true, + // capturing each forwarded flag as a shell-escaped bldopt. + let cmd = Cmd { + features: Some("a,b".to_string()), + build_args: BuildArgs { + meta: vec![("note".to_string(), "added on build".to_string())], + optimize: true, + }, + ..Cmd::default() + }; + let (forwarded, bldopts) = + forwarded_build_args(&cmd, ws(), Some("contract-a"), true, true, true, true); + assert!(forwarded.contains(&"--locked".to_string())); + assert!(forwarded.contains(&"--meta=note=added on build".to_string())); + assert!(bldopts.contains(&"--locked".to_string())); + assert!(bldopts.contains(&"--features=a,b".to_string())); + assert!(bldopts.contains(&"--package=contract-a".to_string())); + // Only the value side is shell-escaped, and each bldopt is one token. + assert!(bldopts.contains(&"--meta=note='added on build'".to_string())); + for o in &bldopts { + assert_eq!(shlex::split(o).expect("valid shell").len(), 1, "{o}"); + } + } + #[test] fn forwarded_build_args_optimize_false_old_image_forwards_nothing() { // Old image defaults to not optimizing and rejects `--optimize=false`, @@ -851,7 +955,7 @@ mod tests { }, ..Cmd::default() }; - let args = forwarded_build_args(&cmd, ws(), None, true, true, false); + let (args, _) = forwarded_build_args(&cmd, ws(), None, false, true, false, false); assert!(!args.iter().any(|a| a.starts_with("--optimize"))); } @@ -861,7 +965,7 @@ mod tests { manifest_path: Some(PathBuf::from("/tmp/ws/contracts/add/Cargo.toml")), ..Cmd::default() }; - let args = forwarded_build_args(&cmd, ws(), None, true, true, true); + let (args, _) = forwarded_build_args(&cmd, ws(), None, false, true, true, false); assert!(args.contains(&"--manifest-path=contracts/add/Cargo.toml".to_string())); } diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs new file mode 100644 index 000000000..b266cc81c --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -0,0 +1,473 @@ +//! Reproducible source-archive generation for verifiable builds. +//! +//! Produces a gzipped tarball of a contract's source tree, rooted under a +//! top-level `source/` prefix (so it extracts to a `source/` dir, mirroring the +//! container's `/source` mount). The working directory is walked and tarred, +//! honoring the project's own `.gitignore`/`.ignore` files (the `.git` directory +//! itself is always skipped). The output is byte-reproducible, so the same tree +//! always hashes to the same `source_sha256`. +//! +//! Shared by `contract build --verifiable` (which builds from the extracted +//! archive) and the standalone `contract archive` command (which generates and +//! inspects it). + +use std::{ + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use ignore::WalkBuilder; + +use crate::print::Print; + +/// Names that usually shouldn't end up in a source archive — VCS metadata of +/// other systems, secrets/local env, build/cache/transient dirs, and editor/OS/ +/// AI-assistant junk. These don't *exclude* anything (selection is driven +/// entirely by `.gitignore`/`.ignore`); instead, if any of them slip into the +/// archive because the project didn't ignore them, we warn the user so they can +/// add an ignore rule. Matched against each path component. +pub(crate) const ARCHIVE_WARN_LIST: &[&str] = &[ + // version control (other systems) + ".svn", + ".hg", + // secrets / local environment + ".env", + // build output / dependencies + "target", + "node_modules", + // transient + "log", + "logs", + "tmp", + "temp", + // OS / editor junk + ".DS_Store", + "Thumbs.db", + ".idea", + ".vscode", + // AI assistant dirs + ".claude", + ".cursor", + ".windsurf", + ".aider", +]; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("could not read git state at {path}: {source}")] + GitInvoke { + path: PathBuf, + source: std::io::Error, + }, + + #[error( + "refusing to archive a dirty git working tree at {path}; commit or stash your changes and try again." + )] + GitDirty { path: PathBuf }, + + #[error("could not write source archive to {path}: {source}")] + ArchiveWrite { + path: PathBuf, + source: std::io::Error, + }, + + #[error("could not extract source archive: {0}")] + ArchiveExtract(std::io::Error), +} + +/// The source tree's root: always the current working directory. The archive is +/// rooted there as-is — we do NOT search upward for a git repository or anchor on +/// `--manifest-path`'s directory, since for a workspace member the build needs +/// the whole workspace (its root `Cargo.toml`/`Cargo.lock`), which lives at the +/// cwd, not the member's directory. So run `contract archive`/`build +/// --verifiable` from the project (or workspace) root you want archived; +/// `--manifest-path`, when given, is interpreted relative to it. +pub(crate) fn resolve_source_root() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Warn about and reject a dirty git working tree. Both `contract archive` and +/// `build --verifiable` archive the working tree as-is, so uncommitted changes +/// would be baked into the recorded `source_sha256`; refuse them (after +/// explaining why) so an archive always corresponds to a committed state. A +/// no-op when `source_root` isn't a git repo (we can't check, e.g. archive +/// sources) — the user owns the bytes they produce there. +pub(crate) fn ensure_clean_tree(source_root: &Path, print: &Print) -> Result<(), Error> { + if tree_is_dirty(source_root)? { + print.warnln(format!( + "git working tree at {} is dirty; the archive would include uncommitted changes.", + source_root.display(), + )); + return Err(Error::GitDirty { + path: source_root.to_path_buf(), + }); + } + Ok(()) +} + +/// Whether `source_root` is a git work tree with uncommitted changes. Returns +/// `Ok(false)` when it isn't a git repo (git ran but refused) — callers can't +/// verify cleanliness there, so they proceed. Errors only when git can't be +/// invoked at all. +fn tree_is_dirty(source_root: &Path) -> Result { + let status = Command::new("git") + .arg("-C") + .arg(source_root) + .arg("status") + .arg("--porcelain") + .output() + .map_err(|source| Error::GitInvoke { + path: source_root.to_path_buf(), + source, + })?; + + // Not a git repo (or git refused): can't verify cleanliness, proceed. + if !status.status.success() { + return Ok(false); + } + + Ok(!status.stdout.is_empty()) +} + +/// Produce the gzipped source tarball bytes. The working directory under +/// `source_root` is walked and tarred, honoring the project's `.gitignore`/ +/// `.ignore` files; entries are rooted under a top-level `source/` prefix. +/// +/// `warn` controls whether to warn about archived paths that usually shouldn't +/// be shipped (see `ARCHIVE_WARN_LIST`). Callers that only inspect the result +/// (e.g. `contract archive --dry-run`) pass `false`, since the listing itself +/// reveals the contents. +pub(crate) fn build_source_archive( + source_root: &Path, + print: &Print, + warn: bool, +) -> Result, Error> { + let tar = walk_tar(source_root, print, warn)?; + gzip(&tar) +} + +/// Tar entry paths inside the gzipped archive bytes, in archive order. Used by +/// `contract archive --dry-run` to list exactly what the bytes that hash to +/// `source_sha256` contain. +pub(crate) fn entry_names(bytes: &[u8]) -> Result, Error> { + let dec = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(dec); + let mut names = Vec::new(); + for entry in archive.entries().map_err(Error::ArchiveExtract)? { + let entry = entry.map_err(Error::ArchiveExtract)?; + let path = entry.path().map_err(Error::ArchiveExtract)?; + names.push(path.to_string_lossy().into_owned()); + } + Ok(names) +} + +/// Tar the working tree under `source_root`, honoring the project's `.gitignore`/ +/// `.ignore` files and always skipping the `.git` directory. Each entry is +/// prefixed with `source/`. When `warn` is set, archived paths matching +/// `ARCHIVE_WARN_LIST` (e.g. `.env`, `target/`) trigger a warning so the user can +/// add an ignore rule. +/// +/// Selection depends only on the in-tree files plus the `.gitignore`/`.ignore` +/// files inside the archived tree — never on machine-specific state (the global +/// gitignore, `.git/info/exclude`, or ignore files in parent directories are not +/// consulted) — so the archive stays byte-reproducible across machines. +/// +/// The output is reproducible, following GNU tar's reproducibility guidance +/// () +/// with the portable equivalents available via the `tar` crate (the system +/// `tar` can't be relied on — macOS ships bsdtar, which lacks `--sort`, +/// `--mtime`, `--pax-option`, …): entries are sorted by name (`--sort=name`) +/// using locale-independent path ordering (`LC_ALL=C`), and `HeaderMode::Deterministic` +/// zeroes mtime (`--mtime`/`--clamp-mtime`), sets uid/gid to 0 with empty owner +/// names (`--owner=0 --group=0 --numeric-owner`), and normalizes mode +/// (`--mode=go+u,go-w`). ustar headers carry no atime/ctime or tar PID. The gzip +/// wrapper (see `gzip`) is likewise deterministic. +fn walk_tar(source_root: &Path, print: &Print, warn: bool) -> Result, Error> { + let walk = WalkBuilder::new(source_root) + .hidden(false) // include dotfiles; let .gitignore decide + .git_ignore(true) // honor in-tree .gitignore + .ignore(true) // honor .ignore + .git_global(false) // not the machine's global gitignore (not reproducible) + .git_exclude(false) // not .git/info/exclude (not in the archive) + .require_git(false) // apply .gitignore/.ignore even without a .git dir + .parents(false) // only ignore files inside the archived tree + .filter_entry(|e| e.file_name() != ".git") // never archive VCS internals + .build(); + + let mut files: Vec = Vec::new(); + for entry in walk { + let entry = entry.map_err(|source| Error::ArchiveWrite { + path: source_root.to_path_buf(), + source: std::io::Error::other(source), + })?; + if entry.file_type().is_some_and(|t| t.is_file()) { + files.push(entry.path().to_path_buf()); + } + } + files.sort(); + + if warn { + warn_unexpected_paths(&files, source_root, print); + } + + let mut builder = tar::Builder::new(Vec::new()); + builder.mode(tar::HeaderMode::Deterministic); + for path in &files { + let rel = path.strip_prefix(source_root).unwrap_or(path); + let name = Path::new("source").join(rel); + let mut f = std::fs::File::open(path).map_err(|source| Error::ArchiveWrite { + path: path.clone(), + source, + })?; + builder + .append_file(&name, &mut f) + .map_err(|source| Error::ArchiveWrite { + path: path.clone(), + source, + })?; + } + builder.into_inner().map_err(|source| Error::ArchiveWrite { + path: source_root.to_path_buf(), + source, + }) +} + +/// Whether a path component matches the warn list: it equals an entry, or — for +/// dotted entries, which double as extension filters (e.g. `.swp`, `.log`) — it +/// ends with that entry. Plain names (`target`, `node_modules`) match exactly +/// only, so `mytarget` is not flagged. +fn is_warned(name: &std::ffi::OsStr) -> bool { + let name = name.to_string_lossy(); + ARCHIVE_WARN_LIST + .iter() + .any(|d| name == *d || (d.starts_with('.') && name.ends_with(d))) +} + +/// Warn about archived paths that usually shouldn't be shipped (secrets, build +/// output, editor/OS junk; see `ARCHIVE_WARN_LIST`). Selection is driven by +/// `.gitignore`/`.ignore`, so these slipped in only because the project didn't +/// ignore them — point that out so the user can add a rule. Reports the path up +/// to each matched component once (so a flagged directory is named once, not per +/// file under it), each on its own line since paths can be long. +fn warn_unexpected_paths(files: &[PathBuf], source_root: &Path, print: &Print) { + let mut hits: Vec = Vec::new(); + for path in files { + let rel = path.strip_prefix(source_root).unwrap_or(path); + let mut prefix = PathBuf::new(); + for comp in rel.components() { + prefix.push(comp); + if is_warned(comp.as_os_str()) { + let hit = prefix.to_string_lossy().into_owned(); + if !hits.contains(&hit) { + hits.push(hit); + } + break; + } + } + } + if hits.is_empty() { + return; + } + hits.sort(); + print.warnln( + "archive includes paths usually excluded; add them to .gitignore or .ignore if unintended:", + ); + for hit in &hits { + print.blankln(hit); + } +} + +/// Gzip with a default (mtime-zeroed) header so the same tar bytes always hash +/// the same. +fn gzip(bytes: &[u8]) -> Result, Error> { + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(bytes).map_err(|source| Error::ArchiveWrite { + path: PathBuf::new(), + source, + })?; + enc.finish().map_err(|source| Error::ArchiveWrite { + path: PathBuf::new(), + source, + }) +} + +/// Decompress gzip and unpack the tar into `dest`. Entries are `source/…`, so +/// they land at `/source/…`. +pub(crate) fn unpack_targz(bytes: &[u8], dest: &Path) -> Result<(), Error> { + let dec = flate2::read::GzDecoder::new(bytes); + tar::Archive::new(dec) + .unpack(dest) + .map_err(Error::ArchiveExtract) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::locator::enforce_hardened_tree; + use sha2::{Digest, Sha256}; + + #[test] + fn is_warned_matches_names_and_dotted_suffixes() { + use std::ffi::OsStr; + // exact name matches + assert!(is_warned(OsStr::new("target"))); + assert!(is_warned(OsStr::new(".env"))); + assert!(is_warned(OsStr::new(".DS_Store"))); + // plain names match exactly only + assert!(!is_warned(OsStr::new("mytarget"))); + assert!(!is_warned(OsStr::new("targets"))); + // dotted entries also match as suffix (extension-style) + assert!(is_warned(OsStr::new("backup.svn"))); + // `.git`/`.gitignore` are not warned: `.git` is skipped structurally and + // `.gitignore` is legitimately archived like any other tracked file. + assert!(!is_warned(OsStr::new(".git"))); + assert!(!is_warned(OsStr::new(".gitignore"))); + // unrelated files pass through + assert!(!is_warned(OsStr::new("Cargo.toml"))); + assert!(!is_warned(OsStr::new("lib.rs"))); + } + + // Initialize a git repo at `root` with one commit of everything present. + #[cfg(unix)] + fn git_init_commit(root: &Path) { + for args in [ + &["init", "-q", "-b", "main"][..], + &["add", "-A"][..], + &["commit", "-q", "-m", "init"][..], + ] { + let ok = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .env("GIT_AUTHOR_NAME", "T") + .env("GIT_AUTHOR_EMAIL", "t@e.x") + .env("GIT_COMMITTER_NAME", "T") + .env("GIT_COMMITTER_EMAIL", "t@e.x") + .status() + .unwrap() + .success(); + assert!(ok); + } + } + + #[test] + #[cfg(unix)] + fn build_source_archive_git_is_prefixed_and_deterministic() { + use std::os::unix::fs::PermissionsExt; + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + git_init_commit(root); + + let a = build_source_archive(root, &print, true).unwrap(); + let b = build_source_archive(root, &print, true).unwrap(); + assert!(!a.is_empty()); + assert_eq!(a, b, "same tree should produce identical bytes"); + + // The `.git` dir git_init_commit created is never archived. + assert!(entry_names(&a) + .unwrap() + .iter() + .all(|n| !n.starts_with("source/.git/"))); + + let sha = hex::encode(Sha256::digest(&a)); + assert_eq!(sha.len(), 64); + + // The listing reflects exactly the archived entries. + let names = entry_names(&a).unwrap(); + assert!(names.iter().any(|n| n == "source/Cargo.toml")); + assert!(names.iter().any(|n| n == "source/src/lib.rs")); + + // Unpack and confirm the `source/` prefix + hardened perms. + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&a, dest.path()).unwrap(); + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + + enforce_hardened_tree(dest.path()).unwrap(); + let file_mode = std::fs::metadata(dest.path().join("source/Cargo.toml")) + .unwrap() + .permissions() + .mode() + & 0o777; + let dir_mode = std::fs::metadata(dest.path().join("source")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(file_mode, 0o600); + assert_eq!(dir_mode, 0o700); + } + + #[test] + fn build_source_archive_skips_git_dir_and_is_reproducible() { + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + // A `.git` dir is always skipped, even without a real repo. + std::fs::create_dir_all(root.join(".git")).unwrap(); + std::fs::write(root.join(".git/config"), b"junk").unwrap(); + // No `.gitignore`, so `target/` is NOT excluded — selection is driven by + // ignore files only. + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); + + let bytes = build_source_archive(root, &print, true).unwrap(); + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&bytes, dest.path()).unwrap(); + + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + assert!(!dest.path().join("source/.git").exists()); + // Un-ignored `target/` is included (and would have triggered a warning). + assert!(dest.path().join("source/target/debug/x").exists()); + assert_eq!(hex::encode(Sha256::digest(&bytes)).len(), 64); + + // Reproducible: a second run over the same tree yields identical bytes + // (sorted entries + zeroed header fields + deterministic gzip). + let again = build_source_archive(root, &print, true).unwrap(); + assert_eq!(bytes, again); + } + + #[test] + fn build_source_archive_respects_gitignore_and_dot_ignore() { + let print = Print::new(true); + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); + // `.gitignore` and `.ignore` are honored even without a git repo. + std::fs::write(root.join(".gitignore"), b"target/\n").unwrap(); + std::fs::write(root.join(".ignore"), b"secret.txt\n").unwrap(); + std::fs::create_dir_all(root.join("target/debug")).unwrap(); + std::fs::write(root.join("target/debug/x"), b"junk").unwrap(); + std::fs::write(root.join("secret.txt"), b"shh").unwrap(); + + let bytes = build_source_archive(root, &print, true).unwrap(); + let dest = tempfile::TempDir::new().unwrap(); + unpack_targz(&bytes, dest.path()).unwrap(); + + assert!(dest.path().join("source/Cargo.toml").exists()); + assert!(dest.path().join("source/src/lib.rs").exists()); + // Excluded by the in-tree ignore files. + assert!(!dest.path().join("source/target").exists()); + assert!(!dest.path().join("source/secret.txt").exists()); + // The ignore files themselves are archived like any other tracked file. + assert!(dest.path().join("source/.gitignore").exists()); + } + + #[test] + fn resolve_source_root_is_cwd() { + // The root is always the current working directory — no upward search, + // no manifest anchoring. + assert_eq!(resolve_source_root(), std::env::current_dir().unwrap()); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/build/verifiable.rs b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs new file mode 100644 index 000000000..57876c734 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build/verifiable.rs @@ -0,0 +1,665 @@ +//! Verifiable (SEP-58 reproducible) contract builds. +//! +//! Triggered by `stellar contract build --verifiable`. Unlike the plain +//! `--image` container build, this snapshots the working tree into a +//! byte-reproducible source archive, hashes it (`source_sha256`), extracts it +//! into a permission-hardened tempdir, and builds *that* in a digest-pinned +//! image — recording SEP-58 provenance meta (`bldimg`, `source_uri`, +//! `source_sha256`, `bldopt`) into the wasm so a third party can reproduce the +//! exact bytes. +//! +//! The container execution machinery (image probe, `run_in_container`, +//! reproduce lines, artifact collection) is shared with +//! [`super::container`]; this module adds the archive, the digest-pinned image +//! resolution, and the SEP-58 metadata on top. + +use std::path::{Path, PathBuf}; + +use regex::Regex; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::{ + commands::{ + container::shared::{self, Error as ConnectionError}, + global, + }, + config::{data, locator::enforce_hardened_tree}, + print::Print, +}; + +use super::{container, source_archive, BuiltContract, Cmd}; + +const REGISTRY: &str = "docker.io/stellar/stellar-cli"; +const HUB_TAGS_URL: &str = + "https://hub.docker.com/v2/repositories/stellar/stellar-cli/tags/?page_size=100"; +const RESERVED_META_KEYS: &[&str] = &["bldimg", "source_uri", "source_sha256", "bldopt"]; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + DockerConnection(#[from] ConnectionError), + + #[error("--image value {value:?} does not match the SEP-58 bldimg format `/@sha256:<64-hex>`. Examples: docker.io/stellar/stellar-cli@sha256:<64-hex>, localhost:5000/foo@sha256:<64-hex>. Tag-only refs and implicit Docker-Hub short refs are not accepted.")] + BldimgFormat { value: String }, + + #[error("could not determine the running rustc version: {0}")] + RustcVersion(String), + + #[error("could not pull image {tag}: {detail}\n\nAvailable tags for this CLI version: {available_for_cli}\nAll published cli/rust pairs: {all_grouped}\n\nFix: install a matching rustc, or pass --image docker.io/stellar/stellar-cli@sha256: with one of the listed tags resolved to a digest.")] + ImageNotFound { + tag: String, + available_for_cli: String, + all_grouped: String, + detail: String, + }, + + #[error("could not list published images on docker hub: {0}")] + TagListUnavailable(String), + + #[error("image {tag} has no repo digest after pull; cannot record a content-addressed bldimg")] + NoRepoDigest { tag: String }, + + #[error(transparent)] + SourceArchive(#[from] source_archive::Error), + + #[error( + "the cli sets bldimg, source_uri, source_sha256, and bldopt automatically when --verifiable is used; remove them from --meta. Got reserved key: {key}" + )] + ReservedMetaKey { key: String }, + + #[error("--source-sha256 value {value:?} does not match the SEP-58 source_sha256 format `^[0-9a-f]{{64}}$` (64-char lower-case hex).")] + SourceSha256Format { value: String }, + + #[error("--source-uri value {value:?} does not match the SEP-58 source_uri format `^[a-zA-Z][a-zA-Z0-9+.-]*:\\S+$` (a URI with a scheme, e.g. https://example.com/src.tar.gz).")] + SourceUriFormat { value: String }, + + #[error("--source-sha256 {provided} does not match the SHA-256 of the generated archive {computed}. Omit --source-sha256 to record the computed value, or fix the value.")] + SourceSha256Mismatch { provided: String, computed: String }, + + #[error(transparent)] + Data(#[from] data::Error), +} + +pub async fn run( + cmd: &Cmd, + global_args: &global::Args, + print: &Print, +) -> Result, super::Error> { + let _ = global_args; + + // Stage 1: pure validation, no I/O. + for (k, _) in &cmd.build_args.meta { + if RESERVED_META_KEYS.iter().any(|r| r == k) { + return Err(Error::ReservedMetaKey { key: k.clone() }.into()); + } + } + if let Some(img) = &cmd.image { + if !bldimg_regex().is_match(img) { + return Err(Error::BldimgFormat { value: img.clone() }.into()); + } + } + + // Stage 2: local filesystem + git, no network. + validate_source_formats(cmd)?; + + // The source root is the current working directory: it's archived, + // bind-mounted into the container, and the `--manifest-path` bldopt is + // relativized against it. Run from the project/workspace root you want built. + let source_root = source_archive::resolve_source_root(); + + // The archive is the working tree, so refuse a dirty repo: a verifiable build + // should be deliberate, off a committed state, not whatever happens to be on + // disk. Skipped when the source root isn't a git repo. + source_archive::ensure_clean_tree(&source_root, print).map_err(Error::from)?; + + // Build the source archive, record its hash, and build from the *extracted* + // archive (in a hardened tempdir) so the wasm is produced from exactly the + // bytes that were hashed. + let resolved = { + let a = resolve_archive(cmd, &source_root, print)?; + // The extracted `source/` dir mirrors `source_root` exactly and is both + // the container mount and the tree the build writes `target/` into. + let mount_root = a.extracted_root.join("source"); + ResolvedSource { + source_sha256: a.source_sha256, + extracted_root: Some(mount_root.clone()), + mount_root, + _tmp: Some(a.tmp), + } + }; + + let source_ids = SourceIds { + source_uri: cmd.source_uri.clone(), + source_sha256: Some(resolved.source_sha256.clone()), + }; + + // Stage 3: the container engine. `resolve_image` pulls the (derived or + // user-supplied) image and pins it to a content-addressed digest. + let docker = cmd.container_args.clone(); + docker.warn_if_host_ignored(print); + let image_ref = resolve_image(cmd, &docker, print).await?; + + // Probe the pinned image once for its cli binary, version, and default + // toolchain (shared with the plain container build), then gate flags on the + // reported version. + let probe = container::probe_image(&image_ref, &docker).await?; + let cli_version = probe.version.clone(); + let at_least = |min: &str| { + cli_version + .as_ref() + .is_none_or(|v| *v >= Version::parse(min).unwrap()) + }; + let supports_locked = at_least(container::LOCKED_MIN); + let supports_optimize_flag = at_least(container::OPTIMIZE_FLAG_MIN); + let supports_optimize_false = at_least(container::OPTIMIZE_NEW_SYNTAX_MIN); + + // `--locked` is implied by `--verifiable` (a reproducible build should pin + // the lockfile), but it was only added to `contract build` in cli 25.2.0. + if supports_locked { + if !cmd.locked { + print.infoln("Implying --locked because --verifiable was passed"); + } + } else { + print.warnln( + "The build image's `contract build` does not support --locked; \ + building without it. Dependency drift may affect reproducibility.", + ); + } + + // Resolve host `cargo metadata` once and reuse it for package selection and + // artifact collection, mirroring the plain container build. + let md = container::metadata(cmd).map_err(container::Error::Metadata)?; + + // Build once per package, each with its own `--package` forwarded and + // recorded as a `bldopt`, so every wasm is independently reproducible. + let packages = container::resolve_packages(cmd, &md); + if cmd.package.is_none() && !packages.is_empty() { + print.infoln(format!("Inferred packages: {}", packages.join(", "))); + } + let targets: Vec> = if packages.is_empty() { + vec![None] + } else { + packages.iter().map(|p| Some(p.as_str())).collect() + }; + let container_cmds: Vec> = targets + .iter() + .map(|target| { + // Verifiable implies `--locked` (when supported) and records every + // build-affecting flag as a `bldopt`. + let (mut args, bldopts) = container::forwarded_build_args( + cmd, + &source_root, + *target, + supports_locked, + supports_optimize_flag, + supports_optimize_false, + true, + ); + args.extend(build_metadata_args(&image_ref, &source_ids, &bldopts)); + args + }) + .collect(); + + // Pin the target dir to a known location under the mount, and the image's + // own default toolchain so a `rust-toolchain.toml` in the source can't + // redirect the build to a toolchain rustup would then try to install. + let mut env = vec!["CARGO_TARGET_DIR=/source/target".to_string()]; + print.infoln(format!("Using Rust toolchain {}", probe.toolchain)); + env.push(format!("RUSTUP_TOOLCHAIN={}", probe.toolchain)); + + container::run_in_container( + &image_ref, + &resolved.mount_root, + &container_cmds, + &env, + &docker, + &cmd.run_args, + &probe.bin, + "stellar-verifiable-build", + print, + cmd.print_commands_only, + ) + .await?; + + // Nothing was built when only printing the command. + if cmd.print_commands_only { + return Ok(Vec::new()); + } + + container::collect_built_contracts(cmd, &md, &source_root, resolved.extracted_root.as_deref()) +} + +/// The recorded `source_sha256`, the directory bind-mounted at `/source`, the +/// extracted-archive root, and its tempdir guard — held so the temp dir +/// outlives the container build and artifact collection. +struct ResolvedSource { + source_sha256: String, + mount_root: PathBuf, + extracted_root: Option, + _tmp: Option, +} + +/// Source-identification fields recorded as SEP-58 meta. `source_sha256` is +/// always `Some` by the time these are built in `run()` (computed from the +/// generated archive). `source_uri` is `Some` only when the user passed +/// `--source-uri`. +#[derive(Debug, Default, Clone)] +struct SourceIds { + source_uri: Option, + source_sha256: Option, +} + +/// Format-validate the user-supplied source flags. Both are optional under +/// `--verifiable`; `--source-sha256`, when present, is validated as a pin in +/// `resolve_archive`. +fn validate_source_formats(cmd: &Cmd) -> Result<(), Error> { + if let Some(sha) = &cmd.source_sha256 { + if !source_sha256_regex().is_match(sha) { + return Err(Error::SourceSha256Format { value: sha.clone() }); + } + } + if let Some(uri) = &cmd.source_uri { + if !source_uri_regex().is_match(uri) { + return Err(Error::SourceUriFormat { value: uri.clone() }); + } + } + Ok(()) +} + +/// Outcome of archiving: the generated archive's SHA-256 and the directory it +/// was extracted into (held alive by `tmp`). +struct ArchiveResult { + source_sha256: String, + extracted_root: PathBuf, + tmp: tempfile::TempDir, +} + +/// Build the source archive, record its hash, write it to the managed archives +/// dir (content-addressed, so the bytes are available to upload for +/// `--source-uri`), and extract it into a permission-hardened tempdir that the +/// container then builds from. +fn resolve_archive(cmd: &Cmd, source_root: &Path, print: &Print) -> Result { + let bytes = source_archive::build_source_archive(source_root, print, true)?; + let computed = hex::encode(Sha256::digest(&bytes)); + + // If the user pinned a hash, it must match what we produced. + if let Some(provided) = &cmd.source_sha256 { + if provided != &computed { + return Err(Error::SourceSha256Mismatch { + provided: provided.clone(), + computed, + }); + } + } + + // Content-addressed name under the managed archives dir. + let out_path = data::archives_dir()?.join(format!("{computed}.tar.gz")); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).map_err(|source| source_archive::Error::ArchiveWrite { + path: out_path.clone(), + source, + })?; + } + std::fs::write(&out_path, &bytes).map_err(|source| source_archive::Error::ArchiveWrite { + path: out_path.clone(), + source, + })?; + print.infoln(format!( + "Wrote source archive {} (source_sha256 {computed})", + out_path.display() + )); + + // Extract and harden, then build from the extracted copy so the wasm is + // produced from exactly the archived bytes. + // + // Extract under the data dir, NOT the OS temp dir: on macOS `$TMPDIR` lives + // under /var/folders, which container VMs (Docker Desktop, Colima, …) don't + // share by default, so a bind mount of it would be empty inside the + // container. The data dir lives under the user's home, which is shared. + let base = data::data_local_dir()?; + std::fs::create_dir_all(&base).map_err(|source| source_archive::Error::ArchiveWrite { + path: base.clone(), + source, + })?; + let tmp = tempfile::Builder::new() + .prefix("verifiable-src-") + .tempdir_in(&base) + .map_err(source_archive::Error::ArchiveExtract)?; + source_archive::unpack_targz(&bytes, tmp.path())?; + enforce_hardened_tree(tmp.path()).map_err(source_archive::Error::ArchiveExtract)?; + + let extracted_root = tmp.path().to_path_buf(); + Ok(ArchiveResult { + source_sha256: computed, + extracted_root, + tmp, + }) +} + +fn bldimg_regex() -> Regex { + Regex::new(r"^(?:localhost(?::\d+)?|[^\s@/]*[.:][^\s@/]*)/[^\s@]+@sha256:[0-9a-f]{64}$") + .unwrap() +} + +fn source_sha256_regex() -> Regex { + Regex::new(r"^[0-9a-f]{64}$").unwrap() +} + +fn source_uri_regex() -> Regex { + Regex::new(r"^[a-zA-Z][a-zA-Z0-9+.-]*:\S+$").unwrap() +} + +/// Emit the SEP-58 `--meta` pairs recorded into the wasm: `bldimg` (the pinned +/// image digest) first, then `source_uri`/`source_sha256` when present, then one +/// `bldopt` per recorded build option. The bldopts already arrive as valid shell +/// (escaped at the source in `forwarded_build_args`), so a verifier reconstructs +/// the build by joining the recorded values and running them through a shell. +fn build_metadata_args(image_ref: &str, ids: &SourceIds, bldopts: &[String]) -> Vec { + let mut out = Vec::new(); + + let push = |out: &mut Vec, key: &str, val: &str| { + out.push("--meta".to_string()); + out.push(format!("{key}={val}")); + }; + + push(&mut out, "bldimg", image_ref); + + if let Some(v) = &ids.source_uri { + push(&mut out, "source_uri", v); + } + if let Some(v) = &ids.source_sha256 { + push(&mut out, "source_sha256", v); + } + + for o in bldopts { + push(&mut out, "bldopt", o); + } + + out +} + +/// Resolve the image to build in and pin it to a content-addressed digest. When +/// `--image` is given it's re-validated and pulled as-is; otherwise a +/// `docker.io/stellar/stellar-cli:-rust` tag is derived from the +/// running CLI and rustc versions, pulled, and pinned to the digest the engine +/// resolved so the recorded `bldimg` names the exact bytes. +pub async fn resolve_image( + cmd: &Cmd, + docker: &shared::Args, + print: &Print, +) -> Result { + if let Some(s) = &cmd.image { + if !bldimg_regex().is_match(s) { + return Err(Error::BldimgFormat { value: s.clone() }); + } + // Always pull, even when the digest is user-supplied: the engine + // requires the image locally before `run` accepts it. + docker.pull_image(s, print).await?; + return Ok(s.clone()); + } + + let cli_v = env!("CARGO_PKG_VERSION"); + let rust_v = rustc_version::version() + .map_err(|e| Error::RustcVersion(e.to_string()))? + .to_string(); + let tag = format!("{REGISTRY}:{cli_v}-rust{rust_v}"); + + print.infoln(format!("Pulling verifiable build image {tag}")); + + match docker.pull_image(&tag, print).await { + Ok(()) => {} + // A failed pull of the derived cli/rust tag usually means no image was + // published for this pair; turn it into the tag-listing hint. A missing + // `docker` binary (or other connection failure) propagates as-is. + Err(ConnectionError::PullImageFailed { stderr, .. }) => { + let (available_for_cli, all_grouped) = match list_published_tags().await { + Ok(tags) => format_available(&tags, cli_v), + Err(list_err) => ( + "".to_string(), + format!(""), + ), + }; + return Err(Error::ImageNotFound { + tag, + available_for_cli, + all_grouped, + detail: stderr, + }); + } + Err(e) => return Err(Error::DockerConnection(e)), + } + + // Pin the mutable tag to the content-addressed digest the engine resolved. + docker + .image_repo_digest(&tag) + .await? + .ok_or(Error::NoRepoDigest { tag }) +} + +#[derive(Debug, Clone)] +pub struct PublishedTag { + pub cli: Version, + pub rust: Version, + pub raw: String, +} + +#[derive(Deserialize)] +struct HubPage { + results: Vec, + next: Option, +} + +#[derive(Deserialize)] +struct HubTag { + name: String, +} + +pub async fn list_published_tags() -> Result, Error> { + let re = Regex::new(r"^(\d+\.\d+\.\d+)-rust(\d+\.\d+\.\d+)$").unwrap(); + let mut out = Vec::new(); + let mut next = Some(HUB_TAGS_URL.to_string()); + let client = reqwest::Client::builder() + .user_agent("stellar-cli") + .build() + .map_err(|e| Error::TagListUnavailable(e.to_string()))?; + while let Some(url) = next { + let page: HubPage = client + .get(&url) + .send() + .await + .map_err(|e| Error::TagListUnavailable(e.to_string()))? + .error_for_status() + .map_err(|e| Error::TagListUnavailable(e.to_string()))? + .json() + .await + .map_err(|e| Error::TagListUnavailable(e.to_string()))?; + for t in page.results { + if let Some(c) = re.captures(&t.name) { + let cli = Version::parse(&c[1]); + let rust = Version::parse(&c[2]); + if let (Ok(cli), Ok(rust)) = (cli, rust) { + out.push(PublishedTag { + cli, + rust, + raw: t.name, + }); + } + } + } + next = page.next; + } + Ok(out) +} + +fn format_available(tags: &[PublishedTag], current_cli: &str) -> (String, String) { + let current = Version::parse(current_cli).ok(); + let mut for_this_cli: Vec<&PublishedTag> = tags + .iter() + .filter(|t| Some(&t.cli) == current.as_ref()) + .collect(); + for_this_cli.sort_by(|a, b| b.rust.cmp(&a.rust)); + let available_for_cli = if for_this_cli.is_empty() { + "".to_string() + } else { + for_this_cli + .iter() + .map(|t| t.raw.as_str()) + .collect::>() + .join(", ") + }; + + let mut by_cli: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for t in tags { + by_cli + .entry(t.cli.to_string()) + .or_default() + .push(t.rust.to_string()); + } + let all_grouped = by_cli + .into_iter() + .map(|(cli, rusts)| format!("{cli}: [{}]", rusts.join(", "))) + .collect::>() + .join("; "); + + (available_for_cli, all_grouped) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pairs(args: &[String]) -> Vec<(&str, &str)> { + args.chunks(2) + .map(|c| (c[0].as_str(), c[1].as_str())) + .collect() + } + + #[test] + fn build_metadata_args_uri_and_sha256() { + let ids = SourceIds { + source_uri: Some("https://example.com/src.tar.gz".to_string()), + source_sha256: Some("a".repeat(64)), + }; + let m = build_metadata_args( + "docker.io/stellar/stellar-cli@sha256:abc", + &ids, + &["--locked".to_string(), "--features=a".to_string()], + ); + let p = pairs(&m); + // bldimg first; source_uri then source_sha256; bldopts last. + assert_eq!( + p[0], + ("--meta", "bldimg=docker.io/stellar/stellar-cli@sha256:abc") + ); + assert_eq!( + p[1], + ("--meta", "source_uri=https://example.com/src.tar.gz") + ); + assert_eq!(p[2].0, "--meta"); + assert!(p[2].1.starts_with("source_sha256=")); + assert_eq!(p[3], ("--meta", "bldopt=--locked")); + assert_eq!(p[4], ("--meta", "bldopt=--features=a")); + } + + #[test] + fn build_metadata_args_sha256_only_omits_uri() { + let ids = SourceIds { + source_sha256: Some("f".repeat(64)), + ..SourceIds::default() + }; + let m = build_metadata_args("docker.io/stellar/stellar-cli@sha256:abc", &ids, &[]); + assert!(m + .iter() + .any(|s| s == &format!("source_sha256={}", "f".repeat(64)))); + assert!(!m.iter().any(|s| s.starts_with("source_uri="))); + } + + #[test] + fn validate_source_formats_rejects_bad_sha256() { + let cmd = Cmd { + source_sha256: Some("not-a-sha".to_string()), + ..Cmd::default() + }; + let err = validate_source_formats(&cmd).unwrap_err(); + assert!(matches!(err, Error::SourceSha256Format { .. })); + } + + #[test] + fn validate_source_formats_rejects_bad_uri() { + let cmd = Cmd { + source_uri: Some("not a uri".to_string()), // no scheme + source_sha256: Some("a".repeat(64)), + ..Cmd::default() + }; + let err = validate_source_formats(&cmd).unwrap_err(); + assert!(matches!(err, Error::SourceUriFormat { .. })); + } + + #[test] + fn validate_source_formats_accepts_valid_and_absent() { + // Both absent is fine here — requiredness is enforced by clap/run(). + validate_source_formats(&Cmd::default()).unwrap(); + let cmd = Cmd { + source_uri: Some("https://example.com/src.tar.gz".to_string()), + source_sha256: Some("f".repeat(64)), + ..Cmd::default() + }; + validate_source_formats(&cmd).unwrap(); + } + + #[test] + fn bldimg_regex_accepts_docker_hub_full_ref() { + assert!(bldimg_regex().is_match(&format!( + "docker.io/stellar/stellar-cli@sha256:{}", + "a".repeat(64) + ))); + } + + #[test] + fn bldimg_regex_accepts_localhost_registry() { + assert!(bldimg_regex().is_match(&format!("localhost:5000/foo@sha256:{}", "0".repeat(64)))); + } + + #[test] + fn bldimg_regex_rejects_implicit_hub_short_ref() { + // Implicit Docker Hub short ref: no registry host prefix. + assert!(!bldimg_regex().is_match(&format!("stellar/stellar-cli@sha256:{}", "a".repeat(64)))); + } + + #[test] + fn bldimg_regex_rejects_tag_only() { + assert!(!bldimg_regex().is_match("docker.io/stellar/stellar-cli:latest")); + } + + #[test] + fn bldimg_regex_rejects_short_sha() { + assert!(!bldimg_regex().is_match("docker.io/stellar/stellar-cli@sha256:abc")); + } + + #[test] + fn source_sha256_regex_matches_64_hex() { + assert!(source_sha256_regex().is_match(&"f".repeat(64))); + assert!(!source_sha256_regex().is_match(&"f".repeat(63))); + assert!(!source_sha256_regex().is_match(&"F".repeat(64))); // upper-case rejected + } + + #[test] + fn source_uri_regex_accepts_any_scheme() { + assert!(source_uri_regex().is_match("https://example.com/src.tar.gz")); + assert!(source_uri_regex().is_match("http://example.com/foo.git")); + assert!(source_uri_regex().is_match("ipfs://Qm...abc")); + assert!(source_uri_regex().is_match("github:foo/bar")); + assert!(!source_uri_regex().is_match("foo/bar")); // no scheme + assert!(!source_uri_regex().is_match("https://has space")); // whitespace + } + + #[test] + fn reserved_meta_keys_list() { + for key in ["bldimg", "source_uri", "source_sha256", "bldopt"] { + assert!(RESERVED_META_KEYS.contains(&key)); + } + } +} diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index fc4499c02..3d8336805 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -1,4 +1,5 @@ pub mod alias; +pub mod archive; pub mod arg_parsing; pub mod asset; pub mod bindings; @@ -35,6 +36,9 @@ pub enum Cmd { Build(build::Cmd), + /// Generate the reproducible source archive used by verifiable builds + Archive(archive::Cmd), + /// Extend the time to live ledger of a contract-data ledger entry. /// /// If no keys are specified the contract itself is extended. @@ -116,6 +120,9 @@ pub enum Error { #[error(transparent)] Build(#[from] build::Error), + #[error(transparent)] + Archive(#[from] archive::Error), + #[error(transparent)] Extend(#[from] extend::Error), @@ -166,6 +173,7 @@ impl Cmd { Cmd::Build(build) => { build.run(global_args).await?; } + Cmd::Archive(archive) => archive.run(global_args)?, Cmd::Extend(extend) => extend.run(global_args).await?, Cmd::Alias(alias) => alias.run(global_args)?, Cmd::Deploy(deploy) => deploy.run(global_args).await?, diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index 4d82ce089..2e879e8f0 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -33,6 +33,7 @@ pub const HEADING_GLOBAL: &str = "Global Options"; pub const HEADING_SIGNING: &str = "Signing Options"; pub const HEADING_TRANSACTION: &str = "Transaction Options"; pub const HEADING_CONTAINER: &str = "Container Options"; +pub const HEADING_VERIFIABLE: &str = "Verifiable Options"; const ABOUT: &str = "Work seamlessly with Stellar accounts, contracts, and assets from the command line. diff --git a/cmd/soroban-cli/src/config/data.rs b/cmd/soroban-cli/src/config/data.rs index a5f733c7e..826b680c9 100644 --- a/cmd/soroban-cli/src/config/data.rs +++ b/cmd/soroban-cli/src/config/data.rs @@ -59,6 +59,12 @@ pub fn bucket_dir() -> Result { Ok(dir) } +pub fn archives_dir() -> Result { + let dir = data_local_dir()?.join("archives"); + std::fs::create_dir_all(&dir)?; + Ok(dir) +} + pub fn write(action: Action, rpc_url: &Url) -> Result { let data = Data { action, @@ -212,6 +218,18 @@ mod test { use crate::test_utils::with_env_set; use serial_test::serial; + #[test] + #[serial] + fn archives_dir_under_data_home_and_created() { + let t = assert_fs::TempDir::new().unwrap(); + with_env_set("STELLAR_DATA_HOME", t.path(), || { + let dir = archives_dir().unwrap(); + assert!(dir.ends_with("archives")); + assert!(dir.starts_with(t.path())); + assert!(dir.is_dir(), "archives_dir() should create the directory"); + }); + } + #[test] #[serial] fn test_write_read() { diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index 6141ea296..b02297608 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -645,52 +645,66 @@ impl Pwd for Args { } } -#[cfg(unix)] -fn fix_config_permissions(root: std::path::PathBuf) { - use std::os::unix::fs::PermissionsExt; - - let mut bad_dirs = Vec::new(); - let mut bad_files = Vec::new(); - let mut stack = vec![root]; - - while let Some(dir) = stack.pop() { - if let Ok(meta) = std::fs::metadata(&dir) { - if meta.permissions().mode() & 0o777 != 0o700 { - bad_dirs.push(dir.clone()); +/// Walk `root` recursively. For every regular entry whose permissions don't +/// already match the hardened mode (0o700 for dirs, 0o600 for files), set +/// them. Returns the dirs and files that were changed so callers can decide +/// whether to surface a warning. Symlinks are skipped — mode bits aren't +/// meaningful for them and `set_permissions` would follow them. +/// +/// On non-unix platforms this is a no-op; tempdirs / config dirs there rely +/// on filesystem ACLs created by the higher-level APIs. +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn enforce_hardened_tree(root: &Path) -> io::Result<(Vec, Vec)> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut changed_dirs = Vec::new(); + let mut changed_files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(p) = stack.pop() { + let Ok(meta) = std::fs::symlink_metadata(&p) else { + continue; + }; + if meta.file_type().is_symlink() { + continue; } - } - - if let Ok(entries) = std::fs::read_dir(&dir) { - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - - if path.is_dir() { - stack.push(path); - } else if let Ok(meta) = std::fs::metadata(&path) { - if meta.permissions().mode() & 0o777 != 0o600 { - bad_files.push(path); + let current = meta.permissions().mode() & 0o777; + if meta.is_dir() { + if current != 0o700 { + set_hardened_permissions(&p)?; + changed_dirs.push(p.clone()); + } + if let Ok(entries) = std::fs::read_dir(&p) { + for entry in entries.filter_map(Result::ok) { + stack.push(entry.path()); } } + } else if current != 0o600 { + set_hardened_permissions(&p)?; + changed_files.push(p); } } + Ok((changed_dirs, changed_files)) } + #[cfg(not(unix))] + { + let _ = root; + Ok((Vec::new(), Vec::new())) + } +} - let print = Print::new(false); +#[cfg(unix)] +fn fix_config_permissions(root: std::path::PathBuf) { + let Ok((dirs, files)) = enforce_hardened_tree(&root) else { + return; + }; - if !bad_dirs.is_empty() { + let print = Print::new(false); + if !dirs.is_empty() { print.warnln("Updated config directories permissions to 0700."); - - for dir in bad_dirs { - let _ = set_hardened_permissions(&dir); - } } - - if !bad_files.is_empty() { + if !files.is_empty() { print.warnln("Updated config files permissions to 0600."); - - for file in bad_files { - let _ = set_hardened_permissions(&file); - } } }