From f7a4c2da61e2659bd06f7fd4ecdbc46eea91cbc8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 17 Sep 2026 11:48:36 -0700 Subject: [PATCH 01/17] Add `stellar contract archive` command. --- Cargo.lock | 25 +- FULL_HELP_DOCS.md | 12 + cmd/crates/soroban-test/tests/it/build.rs | 205 ++++++ cmd/soroban-cli/Cargo.toml | 2 + .../src/commands/contract/archive.rs | 127 ++++ .../src/commands/contract/build.rs | 1 + .../commands/contract/build/source_archive.rs | 597 ++++++++++++++++++ cmd/soroban-cli/src/commands/contract/mod.rs | 8 + cmd/soroban-cli/src/config/locator.rs | 94 +-- 9 files changed, 1028 insertions(+), 43 deletions(-) create mode 100644 cmd/soroban-cli/src/commands/contract/archive.rs create mode 100644 cmd/soroban-cli/src/commands/contract/build/source_archive.rs diff --git a/Cargo.lock b/Cargo.lock index 23b682d8c0..7ea00c33f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2379,9 +2379,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", @@ -2949,9 +2949,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", @@ -4473,9 +4473,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", @@ -5383,6 +5383,7 @@ dependencies = [ "hex", "home", "humantime", + "ignore", "indexmap 2.11.0", "itertools 0.10.5", "jsonrpsee-types", @@ -5425,6 +5426,7 @@ dependencies = [ "strsim", "strum 0.17.1", "strum_macros 0.17.1", + "tar", "tempfile", "termcolor", "termcolor_output", @@ -6055,6 +6057,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 6a15af08c8..109e02ebae 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 @@ -421,6 +422,17 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them +## `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 84c42fd32c..b5c002a8b0 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1080,3 +1080,208 @@ fn build_always_injects_cli_version() { "CLI version should not be empty" ); } + +// 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) +} + +// `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" + ); + + // The archive can hold private source, so it's written 0600, not the umask + // default. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&out).unwrap().permissions().mode() & 0o777, + 0o600, + "the source archive should be owner-only (0600)" + ); + } +} + +// `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"); +} + +// A filename carrying terminal control/escape bytes must be sanitized before it's +// listed, so archiving a hostile tree can't inject escape sequences into the +// user's terminal. (No git init here, so the clean-tree check is skipped and the +// working tree is listed as-is.) +#[test] +#[cfg(unix)] +fn contract_archive_dry_run_sanitizes_control_chars_in_names() { + use std::os::unix::ffi::OsStrExt; + + let sandbox = TestEnv::default(); + let (_temp, workspace) = fresh_workspace(); + + // `e` + raw ESC + an ANSI color sequence + `vil.txt`. + let evil = std::ffi::OsStr::from_bytes(b"e\x1b[31mvil.txt"); + std::fs::write(workspace.join(evil), b"x").unwrap(); + + let output = sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--dry-run") + .assert() + .success() + .get_output() + .stdout + .clone(); + + // The raw ESC byte must never reach the terminal… + assert!( + !output.contains(&0x1b), + "raw ESC leaked into the archive listing" + ); + // …while the printable remainder of the name still shows, so the listing + // stays useful. + let text = String::from_utf8_lossy(&output); + assert!( + text.contains("vil.txt"), + "expected the sanitized name in the listing, got:\n{text}" + ); +} + +// `--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" + ); +} diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 038edb2131..adb27826f0 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -129,6 +129,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/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/archive.rs new file mode 100644 index 0000000000..6154d69911 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/archive.rs @@ -0,0 +1,127 @@ +use std::path::PathBuf; + +use clap::Parser; +use sha2::{Digest, Sha256}; +use soroban_spec_tools::sanitize; + +use crate::{commands::global, config::locator::write_hardened_file, 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)?; + + // Exclude our own output file from the walk so re-running over an + // unchanged tree (where a previous tarball already sits inside it) doesn't + // archive that tarball into the new one and change source_sha256. + let out_file = self.out_file.as_deref(); + + // 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, out_file)?; + let sha = hex::encode(Sha256::digest(&bytes)); + + if self.dry_run { + let names = source_archive::entry_names(&bytes)?; + let prefix = print.compute_emoji("📄"); + + // Entry names come from scanning the working tree, so a hostile + // filename could carry terminal control/escape bytes; sanitize before + // printing so the listing can't inject into the user's terminal. + for name in &names { + println!("{prefix} {}", sanitize(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, + } + })?; + } + } + // The archive is the whole working tree, so it can hold private source or + // an unignored `.env`; write it `0600` rather than the umask default. + write_hardened_file(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 5a00d1894b..23b45af6e4 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -30,6 +30,7 @@ use crate::{ }; pub mod container; +pub(crate) mod source_archive; /// A built WASM artifact with its package name and file path. #[derive(Debug, Clone)] 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 0000000000..9afc657a82 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -0,0 +1,597 @@ +//! 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 soroban_spec_tools::sanitize; + +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("could not check the git working tree at {path}: {stderr}")] + GitStatus { path: PathBuf, stderr: String }, + + #[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), + + #[error( + "refusing to archive symlink {link}: symlinks are not supported in a reproducible source archive; replace it with the real file (or ignore it via .gitignore/.ignore) and try again." + )] + Symlink { link: PathBuf }, +} + +/// 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, + })?; + + // git exits non-zero (typically 128) for both "not a git repository" and for + // genuine failures — dubious ownership, permission errors, a corrupt repo. In + // the first case there's nothing to check, so proceed; but treating the rest + // as "not a repo" would silently bypass the clean-tree gate, so surface them. + if !status.status.success() { + let stderr = String::from_utf8_lossy(&status.stderr); + if stderr.contains("not a git repository") { + return Ok(false); + } + return Err(Error::GitStatus { + path: source_root.to_path_buf(), + stderr: stderr.trim().to_string(), + }); + } + + 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. +/// +/// `exclude` is a single path to skip during the walk — the caller's own output +/// file (`contract archive --out-file`), so re-running over an unchanged tree +/// that already contains a previous tarball doesn't archive it into the new one. +pub(crate) fn build_source_archive( + source_root: &Path, + print: &Print, + warn: bool, + exclude: Option<&Path>, +) -> Result, Error> { + let tar = walk_tar(source_root, print, warn, exclude)?; + 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, + exclude: Option<&Path>, +) -> Result, Error> { + // Resolve the excluded output file to its real path (only when it already + // exists — a not-yet-written file can't be in the tree to skip). + let exclude = exclude.and_then(|p| p.canonicalize().ok()); + + 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), + })?; + let Some(file_type) = entry.file_type() else { + continue; + }; + // A symlink is neither followed (its target could sit outside the tree, + // pulling in machine-specific content and breaking source_sha256) nor + // stored as a link entry; reject it so the archive is always a faithful, + // reproducible snapshot of real files. + if file_type.is_symlink() { + return Err(Error::Symlink { + link: entry.path().to_path_buf(), + }); + } + if file_type.is_file() { + let path = entry.path(); + // Skip our own output file (a prior run's tarball); pre-filter on the + // file name so we only canonicalize the rare same-named candidate. + if let Some(ex) = &exclude { + if path.file_name() == ex.file_name() + && path.canonicalize().ok().as_deref() == Some(ex.as_path()) + { + continue; + } + } + files.push(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:", + ); + // Hits are built from scanned filename components, so sanitize control/escape + // bytes before printing to keep a hostile filename from injecting into the + // terminal. + for hit in &hits { + print.blankln(sanitize(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, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::locator::enforce_hardened_tree; + use sha2::{Digest, Sha256}; + + /// Decompress gzip and unpack the tar into `dest`. Entries are `source/…`, + /// so they land at `/source/…`. + 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) + } + + #[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, None).unwrap(); + let b = build_source_archive(root, &print, true, None).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, None).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, None).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, None).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()); + } + + // A previous run's tarball sitting inside the tree must be excluded, so + // re-archiving an otherwise-unchanged tree doesn't nest the old archive. + #[test] + fn build_source_archive_excludes_the_output_file() { + 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(); + let out = root.join("snapshot.tar.gz"); + std::fs::write(&out, b"a previous run's archive").unwrap(); + + // With the output excluded, it isn't archived; real source still is. + let names = + entry_names(&build_source_archive(root, &print, false, Some(&out)).unwrap()).unwrap(); + assert!(names.iter().any(|n| n == "source/Cargo.toml")); + assert!( + !names.iter().any(|n| n.ends_with("snapshot.tar.gz")), + "the output file must not be archived into itself: {names:?}" + ); + + // Control: without excluding it, the stray tarball would be included. + let included = + entry_names(&build_source_archive(root, &print, false, None).unwrap()).unwrap(); + assert!(included.iter().any(|n| n.ends_with("snapshot.tar.gz"))); + } + + #[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()); + } + + // A symlink in the tree is rejected rather than followed (its target could be + // outside the tree, breaking reproducibility) or stored as a link entry. + #[test] + #[cfg(unix)] + fn build_source_archive_rejects_symlinks() { + 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::os::unix::fs::symlink("Cargo.toml", root.join("link.toml")).unwrap(); + + let err = build_source_archive(root, &print, false, None).unwrap_err(); + assert!(matches!(err, Error::Symlink { .. }), "got {err:?}"); + } + + // Hardening the extracted tree strips group/other access but must keep the + // owner execute bit, so a checked-in script a build invokes stays runnable. + #[test] + #[cfg(unix)] + fn hardening_preserves_owner_execute_bit() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + + let script = root.join("build.sh"); + std::fs::write(&script, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let data = root.join("data.txt"); + std::fs::write(&data, b"x").unwrap(); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o644)).unwrap(); + + enforce_hardened_tree(root).unwrap(); + + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + // Executable file keeps owner-exec (0700); non-exec file hardened to 0600; + // group/other stripped in both. + assert_eq!(mode(&script), 0o700, "exec bit must survive hardening"); + assert_eq!(mode(&data), 0o600); + } +} diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index fc4499c029..3d8336805e 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/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index 90d8a4fcde..935e573234 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -641,52 +641,72 @@ 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 and strip all group/other access. Dirs are set to +/// `0o700`; files keep their owner bits — including the execute bit, so a +/// checked-in script that an extracted-source build invokes stays runnable — +/// with group/other removed (a `0o644` file becomes `0o600`, a `0o755` becomes +/// `0o700`). 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 { + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o700))?; + 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 { + // Keep the owner's bits (notably execute) but drop group/other. + let target = current & 0o700; + if current != target { + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(target))?; + changed_files.push(p); + } } } + Ok((changed_dirs, changed_files)) } - - let print = Print::new(false); - - if !bad_dirs.is_empty() { - print.warnln("Updated config directories permissions to 0700."); - - for dir in bad_dirs { - let _ = set_hardened_permissions(&dir); - } + #[cfg(not(unix))] + { + let _ = root; + Ok((Vec::new(), Vec::new())) } +} - if !bad_files.is_empty() { - print.warnln("Updated config files permissions to 0600."); +#[cfg(unix)] +fn fix_config_permissions(root: std::path::PathBuf) { + let Ok((dirs, files)) = enforce_hardened_tree(&root) else { + return; + }; - for file in bad_files { - let _ = set_hardened_permissions(&file); - } + let print = Print::new(false); + if !dirs.is_empty() { + print.warnln("Removed group/other access from config directories."); + } + if !files.is_empty() { + print.warnln("Removed group/other access from config files."); } } From ff9dfc6ee8160886b7a947c3a3a20311156b31eb Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Thu, 17 Sep 2026 12:45:56 -0700 Subject: [PATCH 02/17] Verify archive cleanliness against the archived files. --- cmd/crates/soroban-test/tests/it/build.rs | 29 ++ .../src/commands/contract/archive.rs | 13 +- .../commands/contract/build/source_archive.rs | 257 ++++++++++++++---- cmd/soroban-cli/src/config/locator.rs | 70 ++++- 4 files changed, 300 insertions(+), 69 deletions(-) diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index b5c002a8b0..1986796d4a 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1148,6 +1148,35 @@ fn contract_archive_writes_out() { } } +// Re-running `contract archive` with an `--out-file` written inside the repo +// must succeed: the prior run's tarball is untracked, but it's the excluded +// output, so it neither trips the clean-tree check nor gets archived into the +// new one. +#[test] +fn contract_archive_rerun_inside_repo_succeeds() { + 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"]); + + // Write the archive *inside* the workspace so the second run sees the first + // run's tarball sitting untracked in the tree. + let out = workspace.join("src.tar.gz"); + + for _ in 0..2 { + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("archive") + .arg("--out-file") + .arg(&out) + .assert() + .success() + .stderr(predicate::str::contains("Wrote source archive")); + } +} + // `contract archive --dry-run` lists the archived entries and the // source_sha256 without writing any file. #[test] diff --git a/cmd/soroban-cli/src/commands/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/archive.rs index 6154d69911..e67c1fa1ec 100644 --- a/cmd/soroban-cli/src/commands/contract/archive.rs +++ b/cmd/soroban-cli/src/commands/contract/archive.rs @@ -53,15 +53,16 @@ impl Cmd { let source_root = source_archive::resolve_source_root(); + // Exclude our own output file from both the clean-tree check and the walk, + // so re-running over an unchanged tree (where a previous tarball already + // sits inside it) neither trips the dirty check nor archives that tarball + // into the new one and changes source_sha256. + let out_file = self.out_file.as_deref(); + // 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)?; - - // Exclude our own output file from the walk so re-running over an - // unchanged tree (where a previous tarball already sits inside it) doesn't - // archive that tarball into the new one and change source_sha256. - let out_file = self.out_file.as_deref(); + source_archive::ensure_clean_tree(&source_root, out_file, &print)?; // The dry-run listing itself reveals the contents, so skip the // "not a git repository" warning there. diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index 9afc657a82..4070f4bf7f 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -102,8 +102,17 @@ pub(crate) fn resolve_source_root() -> PathBuf { /// 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)? { +/// +/// `exclude` is the caller's own output file, kept out of the check exactly as +/// it's kept out of the archive, so re-running over an unchanged tree that +/// already holds a previous tarball isn't seen as dirty. +pub(crate) fn ensure_clean_tree( + source_root: &Path, + exclude: Option<&Path>, + print: &Print, +) -> Result<(), Error> { + let selected = collect_files(source_root, exclude)?; + if tree_is_dirty(source_root, &selected)? { print.warnln(format!( "git working tree at {} is dirty; the archive would include uncommitted changes.", source_root.display(), @@ -115,38 +124,96 @@ pub(crate) fn ensure_clean_tree(source_root: &Path, print: &Print) -> Result<(), 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") +/// Whether `source_root` is a git work tree that isn't safe to archive. Checked +/// against the *archived* file set (`selected`), not git's default status +/// filtering, because the walker and `git status` apply different ignore rules — +/// the walker skips the global gitignore, `.git/info/exclude`, and parent-dir +/// ignores, and additionally honors `.ignore` — so a file could be archived +/// while status still called the tree clean (or the reverse). A tree is dirty +/// when either a tracked file is modified/staged/deleted, or a file the archive +/// would include isn't committed. Returns `Ok(false)` when it isn't a git repo +/// (nothing to verify). Errors only when git can't be invoked or fails +/// otherwise. +fn tree_is_dirty(source_root: &Path, selected: &[PathBuf]) -> Result { + // Modified/staged/deleted tracked files. `--untracked-files=no` keeps this + // independent of ignore rules; untracked files are covered by the + // committed-membership check below instead. + let Some(status) = run_git( + source_root, + &["status", "--porcelain", "--untracked-files=no"], + )? + else { + return Ok(false); // not a git repo — nothing to verify + }; + if !status.is_empty() { + return Ok(true); + } + + // Every file the archive would include must be committed; otherwise the + // archive bakes in uncommitted content while the status check above still + // saw a clean tree (e.g. a file hidden from status by a global/`info/exclude` + // ignore that the walker doesn't consult). + let tracked = tracked_files(source_root)?; + Ok(selected + .iter() + .any(|path| !tracked.contains(path.strip_prefix(source_root).unwrap_or(path)))) +} + +/// Run `git -C source_root ` under the C locale. Returns the captured +/// stdout on success, `None` when `source_root` isn't a git repository (nothing +/// to verify there), or an error for any other failure. git exits non-zero +/// (typically 128) for both "not a git repository" and genuine failures — +/// dubious ownership, permission errors, a corrupt repo — so the first is +/// distinguished by its (C-locale, hence stable English) message; the rest are +/// surfaced rather than silently treated as "not a repo". +fn run_git(source_root: &Path, args: &[&str]) -> Result>, Error> { + let output = Command::new("git") + .env("LC_ALL", "C") .arg("-C") .arg(source_root) - .arg("status") - .arg("--porcelain") + .args(args) .output() .map_err(|source| Error::GitInvoke { path: source_root.to_path_buf(), source, })?; - // git exits non-zero (typically 128) for both "not a git repository" and for - // genuine failures — dubious ownership, permission errors, a corrupt repo. In - // the first case there's nothing to check, so proceed; but treating the rest - // as "not a repo" would silently bypass the clean-tree gate, so surface them. - if !status.status.success() { - let stderr = String::from_utf8_lossy(&status.stderr); - if stderr.contains("not a git repository") { - return Ok(false); - } - return Err(Error::GitStatus { - path: source_root.to_path_buf(), - stderr: stderr.trim().to_string(), - }); + if output.status.success() { + return Ok(Some(output.stdout)); } - Ok(!status.stdout.is_empty()) + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("not a git repository") { + return Ok(None); + } + Err(Error::GitStatus { + path: source_root.to_path_buf(), + stderr: stderr.trim().to_string(), + }) +} + +/// The set of tracked files under `source_root`, as paths relative to it. +fn tracked_files(source_root: &Path) -> Result, Error> { + let out = run_git(source_root, &["ls-files", "-z"])?.unwrap_or_default(); + // `-z` gives NUL-separated, unquoted paths — so a name with spaces or other + // special bytes still matches the walker's real path. + Ok(out + .split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .map(bytes_to_path) + .collect()) +} + +fn bytes_to_path(bytes: &[u8]) -> PathBuf { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + PathBuf::from(std::ffi::OsStr::from_bytes(bytes)) + } + #[cfg(not(unix))] + { + PathBuf::from(String::from_utf8_lossy(bytes).into_owned()) + } } /// Produce the gzipped source tarball bytes. The working directory under @@ -213,6 +280,41 @@ fn walk_tar( warn: bool, exclude: Option<&Path>, ) -> Result, Error> { + let files = collect_files(source_root, exclude)?; + + 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, + }) +} + +/// The sorted set of files the archive would contain: the working tree under +/// `source_root`, honoring the project's in-tree `.gitignore`/`.ignore` (and +/// only those — see `walk_tar`), with the `.git` directory and the caller's own +/// `exclude` output file skipped. Rejects symlinks. This is the single source of +/// truth for "what goes in the archive", shared by `walk_tar` (to build it) and +/// `ensure_clean_tree` (to check the same files are committed). +fn collect_files(source_root: &Path, exclude: Option<&Path>) -> Result, Error> { // Resolve the excluded output file to its real path (only when it already // exists — a not-yet-written file can't be in the tree to skip). let exclude = exclude.and_then(|p| p.canonicalize().ok()); @@ -261,31 +363,7 @@ fn walk_tar( } } 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, - }) + Ok(files) } /// Whether a path component matches the warn list: it equals an entry, or — for @@ -353,7 +431,7 @@ fn gzip(bytes: &[u8]) -> Result, Error> { #[cfg(test)] mod tests { use super::*; - use crate::config::locator::enforce_hardened_tree; + use crate::config::locator::{enforce_hardened_tree, FileMode}; use sha2::{Digest, Sha256}; /// Decompress gzip and unpack the tar into `dest`. Entries are `source/…`, @@ -446,7 +524,7 @@ mod tests { assert!(dest.path().join("source/Cargo.toml").exists()); assert!(dest.path().join("source/src/lib.rs").exists()); - enforce_hardened_tree(dest.path()).unwrap(); + enforce_hardened_tree(dest.path(), FileMode::PreserveOwner).unwrap(); let file_mode = std::fs::metadata(dest.path().join("source/Cargo.toml")) .unwrap() .permissions() @@ -555,6 +633,81 @@ mod tests { assert_eq!(resolve_source_root(), std::env::current_dir().unwrap()); } + // A file the archive would include but git doesn't track must fail the + // clean-tree check, so uncommitted content never lands in a "clean" archive. + // Here `secret.rs` is hidden from `git status` via `.git/info/exclude` — which + // the walker deliberately ignores — so the old status-only check called the + // tree clean while the walker still archived it. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_archived_but_uncommitted_file() { + 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(); + git_init_commit(root); + + std::fs::write(root.join(".git/info/exclude"), b"secret.rs\n").unwrap(); + std::fs::write(root.join("secret.rs"), b"// uncommitted").unwrap(); + + let err = ensure_clean_tree(root, None, &print).unwrap_err(); + assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); + } + + // The caller's own output file, sitting untracked inside the repo, must not + // trip the clean-tree check when it's the excluded output — otherwise a second + // `archive -o inside.tar.gz` run would wrongly fail as dirty. Not excluding it + // proves the check does otherwise catch an untracked file. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_ignores_the_excluded_output_file() { + 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(); + git_init_commit(root); + + let out = root.join("src.tar.gz"); + std::fs::write(&out, b"a prior run's archive").unwrap(); + + ensure_clean_tree(root, Some(&out), &print) + .expect("the excluded output file must not count as dirty"); + let err = ensure_clean_tree(root, None, &print).unwrap_err(); + assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); + } + + // A modified *tracked* file is dirty even though the committed-membership + // check alone would pass it (it's tracked) — the status probe catches it. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_modified_tracked_file() { + 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(); + git_init_commit(root); + + std::fs::write(root.join("Cargo.toml"), b"# modified").unwrap(); + + let err = ensure_clean_tree(root, None, &print).unwrap_err(); + assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); + } + + // A committed, unmodified tree is clean. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_accepts_committed_tree() { + 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); + + ensure_clean_tree(root, None, &print).expect("a committed tree is clean"); + } + // A symlink in the tree is rejected rather than followed (its target could be // outside the tree, breaking reproducibility) or stored as a link entry. #[test] @@ -586,7 +739,7 @@ mod tests { std::fs::write(&data, b"x").unwrap(); std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o644)).unwrap(); - enforce_hardened_tree(root).unwrap(); + enforce_hardened_tree(root, FileMode::PreserveOwner).unwrap(); let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; // Executable file keeps owner-exec (0700); non-exec file hardened to 0600; diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index 935e573234..fec34bc679 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -641,18 +641,35 @@ impl Pwd for Args { } } +/// How `enforce_hardened_tree` normalizes a file's owner bits (group/other are +/// always stripped regardless). +#[derive(Clone, Copy)] +pub(crate) enum FileMode { + /// Force every file to exactly `0o600`. Used for config files, which are + /// data (never executable) and must stay owner-writable so the CLI can + /// rewrite them. + Exact, + /// Keep the owner's bits, including the execute bit, and only drop + /// group/other (a `0o644` file becomes `0o600`, a `0o755` becomes `0o700`). + /// Used for an extracted source tree, where a checked-in script a build + /// invokes must stay runnable. + #[cfg_attr(not(test), allow(dead_code))] + PreserveOwner, +} + /// Walk `root` recursively and strip all group/other access. Dirs are set to -/// `0o700`; files keep their owner bits — including the execute bit, so a -/// checked-in script that an extracted-source build invokes stays runnable — -/// with group/other removed (a `0o644` file becomes `0o600`, a `0o755` becomes -/// `0o700`). 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. +/// `0o700`; files are normalized per `file_mode` (see [`FileMode`]). 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)> { +pub(crate) fn enforce_hardened_tree( + root: &Path, + file_mode: FileMode, +) -> io::Result<(Vec, Vec)> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -678,8 +695,11 @@ pub(crate) fn enforce_hardened_tree(root: &Path) -> io::Result<(Vec, Ve } } } else { - // Keep the owner's bits (notably execute) but drop group/other. - let target = current & 0o700; + let target = match file_mode { + FileMode::Exact => 0o600, + // Keep the owner's bits (notably execute) but drop group/other. + FileMode::PreserveOwner => current & 0o700, + }; if current != target { std::fs::set_permissions(&p, std::fs::Permissions::from_mode(target))?; changed_files.push(p); @@ -690,14 +710,16 @@ pub(crate) fn enforce_hardened_tree(root: &Path) -> io::Result<(Vec, Ve } #[cfg(not(unix))] { - let _ = root; + let _ = (root, file_mode); Ok((Vec::new(), Vec::new())) } } #[cfg(unix)] fn fix_config_permissions(root: std::path::PathBuf) { - let Ok((dirs, files)) = enforce_hardened_tree(&root) else { + // Config files are data, never executable, and the CLI must be able to + // rewrite them, so normalize each to exactly 0600. + let Ok((dirs, files)) = enforce_hardened_tree(&root, FileMode::Exact) else { return; }; @@ -1092,6 +1114,32 @@ mod tests { ); } + #[test] + fn overwrite_repairs_read_only_file_to_0600() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let identity_dir = dir.path().join("identity"); + std::fs::create_dir_all(&identity_dir).unwrap(); + + // Pre-create alice.toml as read-only (0400). Config repair must restore + // write access (0600) so the overwrite below can actually open it. + let alice = identity_dir.join("alice.toml"); + std::fs::write(&alice, "seed_phrase = \"old\"\n").unwrap(); + std::fs::set_permissions(&alice, std::fs::Permissions::from_mode(0o400)).unwrap(); + + let value: HashMap = HashMap::new(); + KeyType::Identity + .write("alice", &value, dir.path()) + .expect("overwriting a read-only config file should succeed"); + + assert_eq!( + std::fs::metadata(&alice).unwrap().permissions().mode() & 0o777, + 0o600, + "a read-only config file should be repaired to 0600" + ); + } + #[test] fn save_contract_id_rejects_reserved_native_alias() { let dir = tempfile::tempdir().unwrap(); From c1f9af6eabdda9bddde816d9036f9af15e99db7f Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 09:39:16 -0700 Subject: [PATCH 03/17] Recurse into submodules when checking cleanliness. --- .../commands/contract/build/source_archive.rs | 83 ++++++++++++++----- 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index 4070f4bf7f..d001cd4964 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -193,8 +193,13 @@ fn run_git(source_root: &Path, args: &[&str]) -> Result>, Error> } /// The set of tracked files under `source_root`, as paths relative to it. +/// `--recurse-submodules` descends into initialized submodules (whose working +/// files the walker also archives, but which `ls-files` would otherwise report +/// only as a single gitlink path), so a clean project using a submodule isn't +/// mistaken for dirty. fn tracked_files(source_root: &Path) -> Result, Error> { - let out = run_git(source_root, &["ls-files", "-z"])?.unwrap_or_default(); + let out = + run_git(source_root, &["ls-files", "-z", "--recurse-submodules"])?.unwrap_or_default(); // `-z` gives NUL-separated, unquoted paths — so a name with spaces or other // special bytes still matches the walker's real path. Ok(out @@ -464,27 +469,29 @@ mod tests { assert!(!is_warned(OsStr::new("lib.rs"))); } + // Run a single git command in `root`, asserting it succeeds. + #[cfg(unix)] + fn git_run(root: &Path, args: &[&str]) { + 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, "git {args:?} failed"); + } + // 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); - } + git_run(root, &["init", "-q", "-b", "main"]); + git_run(root, &["add", "-A"]); + git_run(root, &["commit", "-q", "-m", "init"]); } #[test] @@ -708,6 +715,44 @@ mod tests { ensure_clean_tree(root, None, &print).expect("a committed tree is clean"); } + // A clean project that embeds an initialized git submodule must pass: the + // walker archives the submodule's files, so the tracked set has to include + // them too (via `--recurse-submodules`) — otherwise they look untracked and + // the tree is wrongly rejected as dirty. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_accepts_committed_submodule() { + let print = Print::new(true); + + // A standalone repo to embed as a submodule. + let sub = tempfile::TempDir::new().unwrap(); + std::fs::write(sub.path().join("lib.rs"), b"// sub").unwrap(); + git_init_commit(sub.path()); + + // Superproject that adds and commits the submodule. `protocol.file.allow` + // is required for a local-path submodule on modern git. + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + git_run(root, &["init", "-q", "-b", "main"]); + git_run( + root, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + &sub.path().to_string_lossy(), + "sub", + ], + ); + git_run(root, &["add", "-A"]); + git_run(root, &["commit", "-q", "-m", "init"]); + + ensure_clean_tree(root, None, &print).expect("a committed submodule must be clean"); + } + // A symlink in the tree is rejected rather than followed (its target could be // outside the tree, breaking reproducibility) or stored as a link entry. #[test] From 5208e653e9aab8cc31289c45b72653e9d0590609 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 09:39:16 -0700 Subject: [PATCH 04/17] Report accurate mode in config permission warnings. --- cmd/soroban-cli/src/config/locator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index fec34bc679..672cd84c02 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -725,10 +725,10 @@ fn fix_config_permissions(root: std::path::PathBuf) { let print = Print::new(false); if !dirs.is_empty() { - print.warnln("Removed group/other access from config directories."); + print.warnln("Updated config directory permissions to 0700."); } if !files.is_empty() { - print.warnln("Removed group/other access from config files."); + print.warnln("Updated config file permissions to 0600."); } } From 1f937bade14776ac56f267f47f3e3efb9cd6bf27 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 09:48:39 -0700 Subject: [PATCH 05/17] Avoid printing the dirty tree error twice. --- .../src/commands/contract/archive.rs | 2 +- .../commands/contract/build/source_archive.rs | 40 ++++++------------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/archive.rs index e67c1fa1ec..c193eeee94 100644 --- a/cmd/soroban-cli/src/commands/contract/archive.rs +++ b/cmd/soroban-cli/src/commands/contract/archive.rs @@ -62,7 +62,7 @@ impl Cmd { // 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, out_file, &print)?; + source_archive::ensure_clean_tree(&source_root, out_file)?; // The dry-run listing itself reveals the contents, so skip the // "not a git repository" warning there. diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index d001cd4964..cbda69b146 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -96,27 +96,19 @@ 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. +/// 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 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. /// /// `exclude` is the caller's own output file, kept out of the check exactly as /// it's kept out of the archive, so re-running over an unchanged tree that /// already holds a previous tarball isn't seen as dirty. -pub(crate) fn ensure_clean_tree( - source_root: &Path, - exclude: Option<&Path>, - print: &Print, -) -> Result<(), Error> { +pub(crate) fn ensure_clean_tree(source_root: &Path, exclude: Option<&Path>) -> Result<(), Error> { let selected = collect_files(source_root, exclude)?; if tree_is_dirty(source_root, &selected)? { - 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(), }); @@ -648,7 +640,6 @@ mod tests { #[test] #[cfg(unix)] fn ensure_clean_tree_rejects_archived_but_uncommitted_file() { - 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(); @@ -657,7 +648,7 @@ mod tests { std::fs::write(root.join(".git/info/exclude"), b"secret.rs\n").unwrap(); std::fs::write(root.join("secret.rs"), b"// uncommitted").unwrap(); - let err = ensure_clean_tree(root, None, &print).unwrap_err(); + let err = ensure_clean_tree(root, None).unwrap_err(); assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); } @@ -668,7 +659,6 @@ mod tests { #[test] #[cfg(unix)] fn ensure_clean_tree_ignores_the_excluded_output_file() { - 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(); @@ -677,9 +667,9 @@ mod tests { let out = root.join("src.tar.gz"); std::fs::write(&out, b"a prior run's archive").unwrap(); - ensure_clean_tree(root, Some(&out), &print) + ensure_clean_tree(root, Some(&out)) .expect("the excluded output file must not count as dirty"); - let err = ensure_clean_tree(root, None, &print).unwrap_err(); + let err = ensure_clean_tree(root, None).unwrap_err(); assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); } @@ -688,7 +678,6 @@ mod tests { #[test] #[cfg(unix)] fn ensure_clean_tree_rejects_modified_tracked_file() { - 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(); @@ -696,7 +685,7 @@ mod tests { std::fs::write(root.join("Cargo.toml"), b"# modified").unwrap(); - let err = ensure_clean_tree(root, None, &print).unwrap_err(); + let err = ensure_clean_tree(root, None).unwrap_err(); assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); } @@ -704,7 +693,6 @@ mod tests { #[test] #[cfg(unix)] fn ensure_clean_tree_accepts_committed_tree() { - 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(); @@ -712,7 +700,7 @@ mod tests { std::fs::write(root.join("src/lib.rs"), b"// code").unwrap(); git_init_commit(root); - ensure_clean_tree(root, None, &print).expect("a committed tree is clean"); + ensure_clean_tree(root, None).expect("a committed tree is clean"); } // A clean project that embeds an initialized git submodule must pass: the @@ -722,8 +710,6 @@ mod tests { #[test] #[cfg(unix)] fn ensure_clean_tree_accepts_committed_submodule() { - let print = Print::new(true); - // A standalone repo to embed as a submodule. let sub = tempfile::TempDir::new().unwrap(); std::fs::write(sub.path().join("lib.rs"), b"// sub").unwrap(); @@ -750,7 +736,7 @@ mod tests { git_run(root, &["add", "-A"]); git_run(root, &["commit", "-q", "-m", "init"]); - ensure_clean_tree(root, None, &print).expect("a committed submodule must be clean"); + ensure_clean_tree(root, None).expect("a committed submodule must be clean"); } // A symlink in the tree is rejected rather than followed (its target could be From 741e955b75fd8a6e7d8767c6e7819a5f19457829 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 09:48:39 -0700 Subject: [PATCH 06/17] Show the full archive command description in help. --- FULL_HELP_DOCS.md | 8 ++++++-- cmd/soroban-cli/src/commands/contract/mod.rs | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 109e02ebae..66d344e8aa 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -85,7 +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 +- `archive` — Generate (or inspect) the reproducible source archive for a contract - `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 @@ -424,7 +424,11 @@ To view the commands that will be executed, without executing them, use the --pr ## `stellar contract archive` -Generate the reproducible source archive used by verifiable builds +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. **Usage:** `stellar contract archive [OPTIONS]` diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index 3d8336805e..89a9453217 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -36,7 +36,6 @@ 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. From 15135779fe3b10aae2e32678d3e1fd4e9aba7a67 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 09:57:16 -0700 Subject: [PATCH 07/17] Move archive under the build command. --- FULL_HELP_DOCS.md | 11 ++++++---- cmd/crates/soroban-test/tests/it/build.rs | 7 +++++++ .../src/commands/contract/build.rs | 20 +++++++++++++++++++ .../commands/contract/{ => build}/archive.rs | 2 +- cmd/soroban-cli/src/commands/contract/mod.rs | 7 ------- 5 files changed, 35 insertions(+), 12 deletions(-) rename cmd/soroban-cli/src/commands/contract/{ => build}/archive.rs (99%) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 66d344e8aa..73375667c3 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -85,7 +85,6 @@ 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 (or inspect) the reproducible source archive for a contract - `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 @@ -362,7 +361,11 @@ In workspaces builds all crates unless a package name is specified, or the comma To view the commands that will be executed, without executing them, use the --print-commands-only option. -**Usage:** `stellar contract build [OPTIONS]` +**Usage:** `stellar contract build [OPTIONS] [COMMAND]` + +###### **Subcommands:** + +- `archive` — Generate (or inspect) the reproducible source archive for a contract ###### **Container Options:** @@ -422,7 +425,7 @@ To view the commands that will be executed, without executing them, use the --pr - `--print-commands-only` — Print commands to build without executing them -## `stellar contract archive` +## `stellar contract build archive` Generate (or inspect) the reproducible source archive for a contract. @@ -430,7 +433,7 @@ Produces the same gzipped tarball that `stellar contract build --verifiable` bui 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. -**Usage:** `stellar contract archive [OPTIONS]` +**Usage:** `stellar contract build archive [OPTIONS]` ###### **Options:** diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 1986796d4a..8ff8c2e814 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1119,6 +1119,7 @@ fn contract_archive_writes_out() { sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .arg("--out-file") .arg(&out) @@ -1168,6 +1169,7 @@ fn contract_archive_rerun_inside_repo_succeeds() { sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .arg("--out-file") .arg(&out) @@ -1192,6 +1194,7 @@ fn contract_archive_dry_run_lists_entries() { sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .arg("--dry-run") .assert() @@ -1221,6 +1224,7 @@ fn contract_archive_dry_run_sanitizes_control_chars_in_names() { let output = sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .arg("--dry-run") .assert() @@ -1257,6 +1261,7 @@ fn contract_archive_rejects_bad_out_file_extension() { sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .arg("--out-file") .arg(&out) @@ -1279,6 +1284,7 @@ fn contract_archive_requires_out_file_without_dry_run() { sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .assert() .failure() @@ -1302,6 +1308,7 @@ fn contract_archive_dirty_tree_errors() { sandbox .new_assert_cmd("contract") .current_dir(&workspace) + .arg("build") .arg("archive") .arg("--out-file") .arg(&out) diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index 23b45af6e4..bdaddecdef 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -29,6 +29,7 @@ use crate::{ wasm, }; +pub mod archive; pub mod container; pub(crate) mod source_archive; @@ -136,6 +137,14 @@ pub struct Cmd { /// `--image` build container. #[command(flatten, next_help_heading = HEADING_CONTAINER)] pub run_args: ContainerRunArgs, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(clap::Subcommand, Debug, Clone)] +pub enum SubCommand { + Archive(archive::Cmd), } /// Shared build options for meta and optimization, reused by deploy and upload. @@ -245,6 +254,9 @@ pub enum Error { #[error(transparent)] Container(#[from] container::Error), + + #[error(transparent)] + Archive(#[from] archive::Error), } pub(crate) const WASM_TARGET: &str = "wasm32v1-none"; @@ -268,6 +280,7 @@ impl Default for Cmd { build_args: BuildArgs::default(), container_args: ContainerArgs::default(), run_args: ContainerRunArgs::default(), + command: None, } } } @@ -276,6 +289,13 @@ impl Cmd { /// Builds the project and returns the built WASM artifacts. #[allow(clippy::too_many_lines)] pub async fn run(&self, global_args: &global::Args) -> Result, Error> { + // `contract build archive` generates the source archive instead of + // building; it produces no wasm artifacts. + if let Some(SubCommand::Archive(cmd)) = &self.command { + cmd.run(global_args)?; + return Ok(Vec::new()); + } + let print = Print::new(global_args.quiet); // When an image is given, build inside that container instead of locally. diff --git a/cmd/soroban-cli/src/commands/contract/archive.rs b/cmd/soroban-cli/src/commands/contract/build/archive.rs similarity index 99% rename from cmd/soroban-cli/src/commands/contract/archive.rs rename to cmd/soroban-cli/src/commands/contract/build/archive.rs index c193eeee94..bb75295755 100644 --- a/cmd/soroban-cli/src/commands/contract/archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/archive.rs @@ -6,7 +6,7 @@ use soroban_spec_tools::sanitize; use crate::{commands::global, config::locator::write_hardened_file, print::Print}; -use super::build::source_archive; +use super::source_archive; /// Accepted `--out-file` suffixes (lower-case). The archive is always a gzipped /// tarball, so the filename must say so. diff --git a/cmd/soroban-cli/src/commands/contract/mod.rs b/cmd/soroban-cli/src/commands/contract/mod.rs index 89a9453217..fc4499c029 100644 --- a/cmd/soroban-cli/src/commands/contract/mod.rs +++ b/cmd/soroban-cli/src/commands/contract/mod.rs @@ -1,5 +1,4 @@ pub mod alias; -pub mod archive; pub mod arg_parsing; pub mod asset; pub mod bindings; @@ -36,8 +35,6 @@ pub enum Cmd { Build(build::Cmd), - 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. @@ -119,9 +116,6 @@ pub enum Error { #[error(transparent)] Build(#[from] build::Error), - #[error(transparent)] - Archive(#[from] archive::Error), - #[error(transparent)] Extend(#[from] extend::Error), @@ -172,7 +166,6 @@ 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?, From 914f6c59a7b6839c36ad462ea61d70ac5001d240 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:05:05 -0700 Subject: [PATCH 08/17] Reject files that hide changes from git. --- .../commands/contract/build/source_archive.rs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index cbda69b146..0160a9e091 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -70,6 +70,11 @@ pub enum Error { )] GitDirty { path: PathBuf }, + #[error( + "refusing to archive: {paths:?} marked assume-unchanged or skip-worktree, so git can't confirm they match the committed source; clear the flag (git update-index --no-assume-unchanged / --no-skip-worktree ) and try again." + )] + GitUnverifiable { paths: Vec }, + #[error("could not write source archive to {path}: {source}")] ArchiveWrite { path: PathBuf, @@ -108,6 +113,16 @@ pub(crate) fn resolve_source_root() -> PathBuf { /// already holds a previous tarball isn't seen as dirty. pub(crate) fn ensure_clean_tree(source_root: &Path, exclude: Option<&Path>) -> Result<(), Error> { let selected = collect_files(source_root, exclude)?; + + // Files git has been told to ignore working-tree changes for can't be + // verified by the dirty check below, so reject them first (more specific). + let unverifiable = unverifiable_files(source_root, &selected)?; + if !unverifiable.is_empty() { + return Err(Error::GitUnverifiable { + paths: unverifiable, + }); + } + if tree_is_dirty(source_root, &selected)? { return Err(Error::GitDirty { path: source_root.to_path_buf(), @@ -201,6 +216,30 @@ fn tracked_files(source_root: &Path) -> Result Result, Error> { + let Some(out) = run_git(source_root, &["ls-files", "-v", "-z"])? else { + return Ok(Vec::new()); + }; + // Each record is `` (see `git ls-files -v`); the path + // starts after the tag and its separating space. + let flagged: std::collections::HashSet = out + .split(|b| *b == 0) + .filter(|r| r.len() > 2 && (r[0] == b'S' || r[0].is_ascii_lowercase())) + .map(|r| bytes_to_path(&r[2..])) + .collect(); + Ok(selected + .iter() + .filter(|p| flagged.contains(p.strip_prefix(source_root).unwrap_or(p))) + .cloned() + .collect()) +} + fn bytes_to_path(bytes: &[u8]) -> PathBuf { #[cfg(unix)] { @@ -689,6 +728,41 @@ mod tests { assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); } + // A file marked `assume-unchanged` is skipped by `git status`/`git diff`, so a + // modification to it would be archived while looking clean. We can't vouch it + // matches committed source, so it must be refused. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_assume_unchanged_file() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + git_init_commit(root); + + git_run(root, &["update-index", "--assume-unchanged", "Cargo.toml"]); + std::fs::write(root.join("Cargo.toml"), b"# modified out of view").unwrap(); + + let err = ensure_clean_tree(root, None).unwrap_err(); + assert!(matches!(err, Error::GitUnverifiable { .. }), "got {err:?}"); + } + + // Same guarantee for `skip-worktree`, the other index flag that hides + // working-tree changes from git. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_skip_worktree_file() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + git_init_commit(root); + + git_run(root, &["update-index", "--skip-worktree", "Cargo.toml"]); + std::fs::write(root.join("Cargo.toml"), b"# modified out of view").unwrap(); + + let err = ensure_clean_tree(root, None).unwrap_err(); + assert!(matches!(err, Error::GitUnverifiable { .. }), "got {err:?}"); + } + // A committed, unmodified tree is clean. #[test] #[cfg(unix)] From 05fb73cb0f52aabd91fe15dea0b28f649e58f8b9 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:10:13 -0700 Subject: [PATCH 09/17] Harden output file before writing its contents. --- cmd/soroban-cli/src/config/locator.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index 672cd84c02..ceac2d6789 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -743,23 +743,26 @@ pub(crate) fn set_hardened_permissions(path: &Path) -> io::Result<()> { Ok(()) } -/// Writes `contents` to `path`, creating the file with `0600` on Unix and -/// resetting the mode to exactly `0600` afterwards regardless of any +/// Writes `contents` to `path` at mode `0600` on Unix, regardless of any /// pre-existing permissions. Falls back to `std::fs::write` on non-Unix /// platforms. pub(crate) fn write_hardened_file(path: &Path, contents: &[u8]) -> io::Result<()> { #[cfg(unix)] { use std::io::Write as _; - use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path)?; + // `mode(0o600)` only applies when the file is created; a pre-existing file + // keeps its old (possibly group/other-readable) mode. Harden the now-empty + // (truncated) file to 0600 *before* writing, so the contents are never + // briefly exposed — and even a partial write on failure stays private. + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; file.write_all(contents)?; - set_hardened_permissions(path)?; } #[cfg(not(unix))] From 951d4881c13a7d6182d6db5d632d6ec9fcbe4756 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:22:20 -0700 Subject: [PATCH 10/17] Cover submodules in the cleanliness checks. --- .../commands/contract/build/source_archive.rs | 101 +++++++++++++----- 1 file changed, 76 insertions(+), 25 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index 0160a9e091..b7eab35cf5 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -145,9 +145,17 @@ fn tree_is_dirty(source_root: &Path, selected: &[PathBuf]) -> Result.ignore` / + // `diff.ignoreSubmodules` config that would otherwise hide a submodule's + // modified tracked files, whose changed bytes the walker still archives. let Some(status) = run_git( source_root, - &["status", "--porcelain", "--untracked-files=no"], + &[ + "status", + "--porcelain", + "--untracked-files=no", + "--ignore-submodules=none", + ], )? else { return Ok(false); // not a git repo — nothing to verify @@ -223,7 +231,13 @@ fn tracked_files(source_root: &Path) -> Result Result, Error> { - let Some(out) = run_git(source_root, &["ls-files", "-v", "-z"])? else { + // `--recurse-submodules` so a flagged file inside an initialized submodule + // (which the walker archives) is caught too, matching `tracked_files`. + let Some(out) = run_git( + source_root, + &["ls-files", "-v", "-z", "--recurse-submodules"], + )? + else { return Ok(Vec::new()); }; // Each record is `` (see `git ls-files -v`); the path @@ -525,6 +539,37 @@ mod tests { git_run(root, &["commit", "-q", "-m", "init"]); } + // A committed superproject with one committed submodule at `sub/`. Returns the + // superproject's tempdir, the submodule's tempdir (kept alive so its origin + // path stays valid), and the superproject root. + #[cfg(unix)] + fn superproject_with_submodule() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) { + let sub = tempfile::TempDir::new().unwrap(); + std::fs::write(sub.path().join("f.txt"), b"// sub").unwrap(); + git_init_commit(sub.path()); + + // `protocol.file.allow` is required for a local-path submodule on modern git. + let super_dir = tempfile::TempDir::new().unwrap(); + let root = super_dir.path().to_path_buf(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + git_run(&root, &["init", "-q", "-b", "main"]); + git_run( + &root, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + &sub.path().to_string_lossy(), + "sub", + ], + ); + git_run(&root, &["add", "-A"]); + git_run(&root, &["commit", "-q", "-m", "init"]); + (super_dir, sub, root) + } + #[test] #[cfg(unix)] fn build_source_archive_git_is_prefixed_and_deterministic() { @@ -784,33 +829,39 @@ mod tests { #[test] #[cfg(unix)] fn ensure_clean_tree_accepts_committed_submodule() { - // A standalone repo to embed as a submodule. - let sub = tempfile::TempDir::new().unwrap(); - std::fs::write(sub.path().join("lib.rs"), b"// sub").unwrap(); - git_init_commit(sub.path()); + let (_super, _sub, root) = superproject_with_submodule(); + ensure_clean_tree(&root, None).expect("a committed submodule must be clean"); + } - // Superproject that adds and commits the submodule. `protocol.file.allow` - // is required for a local-path submodule on modern git. - let temp = tempfile::TempDir::new().unwrap(); - let root = temp.path(); - std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); - git_run(root, &["init", "-q", "-b", "main"]); + // A submodule configured `ignore = all` hides its modified tracked files from + // `git status`, but the walker still archives the changed bytes. The check must + // override that config (`--ignore-submodules=none`) and catch it. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_modified_ignored_submodule() { + let (_super, _sub, root) = superproject_with_submodule(); + git_run(&root, &["config", "submodule.sub.ignore", "all"]); + std::fs::write(root.join("sub/f.txt"), b"// modified out of view").unwrap(); + + let err = ensure_clean_tree(&root, None).unwrap_err(); + assert!(matches!(err, Error::GitDirty { .. }), "got {err:?}"); + } + + // A submodule file marked `assume-unchanged` is hidden from status; the flag + // query must recurse into submodules to catch it, else its modified bytes get + // archived while the tree looks clean. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_assume_unchanged_submodule_file() { + let (_super, _sub, root) = superproject_with_submodule(); git_run( - root, - &[ - "-c", - "protocol.file.allow=always", - "submodule", - "add", - "-q", - &sub.path().to_string_lossy(), - "sub", - ], + &root.join("sub"), + &["update-index", "--assume-unchanged", "f.txt"], ); - git_run(root, &["add", "-A"]); - git_run(root, &["commit", "-q", "-m", "init"]); + std::fs::write(root.join("sub/f.txt"), b"// modified out of view").unwrap(); - ensure_clean_tree(root, None).expect("a committed submodule must be clean"); + let err = ensure_clean_tree(&root, None).unwrap_err(); + assert!(matches!(err, Error::GitUnverifiable { .. }), "got {err:?}"); } // A symlink in the tree is rejected rather than followed (its target could be From 56777510806bc80908107fdc8a802446dcef1161 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:36:10 -0700 Subject: [PATCH 11/17] Reject build flags combined with archive subcommand. --- FULL_HELP_DOCS.md | 2 +- cmd/crates/soroban-test/tests/it/build.rs | 22 +++++++++++++++++++ .../src/commands/contract/build.rs | 3 +++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index 73375667c3..dd0245fd2c 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -361,7 +361,7 @@ In workspaces builds all crates unless a package name is specified, or the comma To view the commands that will be executed, without executing them, use the --print-commands-only option. -**Usage:** `stellar contract build [OPTIONS] [COMMAND]` +**Usage:** `stellar contract build [OPTIONS] build ` ###### **Subcommands:** diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 8ff8c2e814..79455f4c2a 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1149,6 +1149,28 @@ fn contract_archive_writes_out() { } } +// Parent `build` flags can't be combined with the `archive` subcommand: the +// archive ignores them (it always uses the working directory), so accepting e.g. +// `build --manifest-path x archive` would silently drop the flag. clap must +// reject the combination instead. +#[test] +fn contract_build_archive_rejects_parent_build_args() { + let sandbox = TestEnv::default(); + let (_temp, workspace) = fresh_workspace(); + + sandbox + .new_assert_cmd("contract") + .current_dir(&workspace) + .arg("build") + .arg("--manifest-path") + .arg("Cargo.toml") + .arg("archive") + .arg("--dry-run") + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + // Re-running `contract archive` with an `--out-file` written inside the repo // must succeed: the prior run's tarball is untracked, but it's the excluded // output, so it neither trips the clean-tree check nor gets archived into the diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index bdaddecdef..64256dd497 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -56,6 +56,9 @@ pub struct BuiltContract { /// --print-commands-only option. #[derive(Parser, Debug, Clone)] #[allow(clippy::struct_excessive_bools)] +// Either the build flags or the `archive` subcommand — never both, since the +// subcommand path ignores the parent build flags entirely. +#[command(args_conflicts_with_subcommands = true)] pub struct Cmd { /// Path to Cargo.toml #[arg(long)] From 4d9fc7b70f60901d3c66e601b8cf8d53319fa999 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:43:31 -0700 Subject: [PATCH 12/17] Escape untrusted paths in archive error messages. --- .../commands/contract/build/source_archive.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index b7eab35cf5..e5696920ee 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -75,7 +75,7 @@ pub enum Error { )] GitUnverifiable { paths: Vec }, - #[error("could not write source archive to {path}: {source}")] + #[error("could not write source archive to {path:?}: {source}")] ArchiveWrite { path: PathBuf, source: std::io::Error, @@ -85,7 +85,7 @@ pub enum Error { ArchiveExtract(std::io::Error), #[error( - "refusing to archive symlink {link}: symlinks are not supported in a reproducible source archive; replace it with the real file (or ignore it via .gitignore/.ignore) and try again." + "refusing to archive symlink {link:?}: symlinks are not supported in a reproducible source archive; replace it with the real file (or ignore it via .gitignore/.ignore) and try again." )] Symlink { link: PathBuf }, } @@ -879,6 +879,30 @@ mod tests { assert!(matches!(err, Error::Symlink { .. }), "got {err:?}"); } + // A symlink filename is working-tree-controlled, so a hostile repo could put + // terminal escape bytes in it. The rejection error must escape them, or + // `archive --dry-run` would emit raw control sequences before the sanitized + // listing is ever reached. + #[test] + #[cfg(unix)] + fn symlink_error_escapes_control_bytes_in_name() { + use std::os::unix::ffi::OsStrExt; + 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(); + // `e` + raw ESC + ANSI color sequence + `vil`. + let evil = std::ffi::OsStr::from_bytes(b"e\x1b[31mvil"); + std::os::unix::fs::symlink("Cargo.toml", root.join(evil)).unwrap(); + + let err = build_source_archive(root, &print, false, None).unwrap_err(); + assert!( + !err.to_string().contains('\u{1b}'), + "raw ESC leaked into the symlink error: {:?}", + err.to_string() + ); + } + // Hardening the extracted tree strips group/other access but must keep the // owner execute bit, so a checked-in script a build invokes stays runnable. #[test] From 10ebc2a1e2d118129dc61a7f89c15744d442b683 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:43:31 -0700 Subject: [PATCH 13/17] Drop help references to the not-yet-added verifiable build. --- FULL_HELP_DOCS.md | 2 +- cmd/soroban-cli/src/commands/contract/build/archive.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index dd0245fd2c..0c8373787a 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -429,7 +429,7 @@ To view the commands that will be executed, without executing them, use the --pr 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`. +Produces a gzipped tarball of the source tree 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 publishing the archive. 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. diff --git a/cmd/soroban-cli/src/commands/contract/build/archive.rs b/cmd/soroban-cli/src/commands/contract/build/archive.rs index bb75295755..e6e421659e 100644 --- a/cmd/soroban-cli/src/commands/contract/build/archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/archive.rs @@ -14,11 +14,10 @@ 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`. +/// Produces a gzipped tarball of the source tree 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 +/// publishing the archive. /// /// The archive is the current working directory, honoring the project's /// `.gitignore` and `.ignore` files (the `.git` directory itself is always From a44868010c44bd6de481aaedef106bb9d1b8055a Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:43:31 -0700 Subject: [PATCH 14/17] Assert git setup succeeds in archive tests. --- cmd/crates/soroban-test/tests/it/build.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 79455f4c2a..c839090ade 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -1081,9 +1081,10 @@ fn build_always_injects_cli_version() { ); } -// Convenience: drive a git command in a fixture directory. +// Convenience: drive a git command in a fixture directory, asserting it succeeds +// so a failed setup can't silently push tests down the non-git path. fn git_in(dir: &Path, args: &[&str]) { - std::process::Command::new("git") + let status = std::process::Command::new("git") .args(args) .current_dir(dir) .env("GIT_AUTHOR_NAME", "Test") @@ -1092,6 +1093,7 @@ fn git_in(dir: &Path, args: &[&str]) { .env("GIT_COMMITTER_EMAIL", "test@example.com") .status() .unwrap(); + assert!(status.success(), "git {args:?} failed"); } // Init a tempdir copy of the workspace fixture and return the workspace path. From 6ea93bb0ba8091e80f718e26a5600f76e9ddf0e2 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:49:21 -0700 Subject: [PATCH 15/17] Reject uninitialized submodules when archiving. --- .../commands/contract/build/source_archive.rs | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index e5696920ee..f4d0855df6 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -8,7 +8,7 @@ //! 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 +//! archive) and the `contract build archive` command (which generates and //! inspects it). use std::{ @@ -75,6 +75,11 @@ pub enum Error { )] GitUnverifiable { paths: Vec }, + #[error( + "refusing to archive: submodule(s) {paths:?} are not initialized, so their committed source would be missing from the archive; run `git submodule update --init --recursive` and try again." + )] + SubmoduleUninitialized { paths: Vec }, + #[error("could not write source archive to {path:?}: {source}")] ArchiveWrite { path: PathBuf, @@ -114,6 +119,16 @@ pub(crate) fn resolve_source_root() -> PathBuf { pub(crate) fn ensure_clean_tree(source_root: &Path, exclude: Option<&Path>) -> Result<(), Error> { let selected = collect_files(source_root, exclude)?; + // An uninitialized submodule is an empty dir the walker archives nothing for, + // yet its committed source belongs in the archive — reject rather than hash an + // incomplete tree. + let uninitialized = uninitialized_submodules(source_root)?; + if !uninitialized.is_empty() { + return Err(Error::SubmoduleUninitialized { + paths: uninitialized, + }); + } + // Files git has been told to ignore working-tree changes for can't be // verified by the dirty check below, so reject them first (more specific). let unverifiable = unverifiable_files(source_root, &selected)?; @@ -254,6 +269,26 @@ fn unverifiable_files(source_root: &Path, selected: &[PathBuf]) -> Result Result, Error> { + let Some(out) = run_git(source_root, &["submodule", "status", "--recursive"])? else { + return Ok(Vec::new()); + }; + // Each line is ` ()`; `-` flags an uninitialized + // submodule, and the path is the second whitespace-separated token. + Ok(String::from_utf8_lossy(&out) + .lines() + .filter_map(|l| { + l.strip_prefix('-') + .and_then(|rest| rest.split_whitespace().nth(1)) + }) + .map(PathBuf::from) + .collect()) +} + fn bytes_to_path(bytes: &[u8]) -> PathBuf { #[cfg(unix)] { @@ -833,6 +868,21 @@ mod tests { ensure_clean_tree(&root, None).expect("a committed submodule must be clean"); } + // An uninitialized submodule is an empty dir: the walker archives nothing for + // it, so the archive would silently omit its committed source. Reject it. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_uninitialized_submodule() { + let (_super, _sub, root) = superproject_with_submodule(); + git_run(&root, &["submodule", "deinit", "-f", "sub"]); + + let err = ensure_clean_tree(&root, None).unwrap_err(); + assert!( + matches!(err, Error::SubmoduleUninitialized { .. }), + "got {err:?}" + ); + } + // A submodule configured `ignore = all` hides its modified tracked files from // `git status`, but the walker still archives the changed bytes. The check must // override that config (`--ignore-submodules=none`) and catch it. From bd1dea27c166b157644c0acee40307decf68339c Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 10:57:47 -0700 Subject: [PATCH 16/17] Reject all skip-worktree files, present or not. --- .../commands/contract/build/source_archive.rs | 60 ++++++++++++++----- 1 file changed, 44 insertions(+), 16 deletions(-) diff --git a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs index f4d0855df6..02093fe794 100644 --- a/cmd/soroban-cli/src/commands/contract/build/source_archive.rs +++ b/cmd/soroban-cli/src/commands/contract/build/source_archive.rs @@ -239,12 +239,15 @@ fn tracked_files(source_root: &Path) -> Result Result, Error> { // `--recurse-submodules` so a flagged file inside an initialized submodule // (which the walker archives) is caught too, matching `tracked_files`. @@ -255,18 +258,24 @@ fn unverifiable_files(source_root: &Path, selected: &[PathBuf]) -> Result = selected + .iter() + .map(|p| p.strip_prefix(source_root).unwrap_or(p)) + .collect(); + // Each record is `` (see `git ls-files -v`); the path // starts after the tag and its separating space. - let flagged: std::collections::HashSet = out - .split(|b| *b == 0) - .filter(|r| r.len() > 2 && (r[0] == b'S' || r[0].is_ascii_lowercase())) - .map(|r| bytes_to_path(&r[2..])) - .collect(); - Ok(selected - .iter() - .filter(|p| flagged.contains(p.strip_prefix(source_root).unwrap_or(p))) - .cloned() - .collect()) + let mut unverifiable = Vec::new(); + for record in out.split(|b| *b == 0).filter(|r| r.len() > 2) { + let tag = record[0]; + let path = bytes_to_path(&record[2..]); + let is_skip_worktree = tag == b'S' || tag == b's'; + let is_assume_unchanged = tag.is_ascii_lowercase(); + if is_skip_worktree || (is_assume_unchanged && selected.contains(path.as_path())) { + unverifiable.push(path); + } + } + Ok(unverifiable) } /// Submodule paths that are present as gitlinks but not checked out. `git @@ -843,6 +852,25 @@ mod tests { assert!(matches!(err, Error::GitUnverifiable { .. }), "got {err:?}"); } + // A `skip-worktree` file absent from disk (e.g. a sparse checkout) never + // reaches the walker's selected set, but its committed source still belongs in + // the archive — so it must be rejected, not silently dropped. + #[test] + #[cfg(unix)] + fn ensure_clean_tree_rejects_absent_skip_worktree_file() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Cargo.toml"), b"# crate").unwrap(); + std::fs::write(root.join("extra.rs"), b"// committed").unwrap(); + git_init_commit(root); + + git_run(root, &["update-index", "--skip-worktree", "extra.rs"]); + std::fs::remove_file(root.join("extra.rs")).unwrap(); + + let err = ensure_clean_tree(root, None).unwrap_err(); + assert!(matches!(err, Error::GitUnverifiable { .. }), "got {err:?}"); + } + // A committed, unmodified tree is clean. #[test] #[cfg(unix)] From 893da30949477b9397d65c52a13e0d90649372a8 Mon Sep 17 00:00:00 2001 From: Nando Vieira Date: Fri, 18 Sep 2026 11:02:05 -0700 Subject: [PATCH 17/17] Harden config permissions best-effort across the tree. --- cmd/soroban-cli/src/config/locator.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index ceac2d6789..f7dbb7078d 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -663,6 +663,9 @@ pub(crate) enum FileMode { /// warning. Symlinks are skipped — mode bits aren't meaningful for them and /// `set_permissions` would follow them. /// +/// Best-effort: an entry whose `chmod` fails is skipped and traversal continues, +/// so one unfixable file can't leave the rest of the tree group/other-readable. +/// /// 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)] @@ -685,8 +688,9 @@ pub(crate) fn enforce_hardened_tree( } let current = meta.permissions().mode() & 0o777; if meta.is_dir() { - if current != 0o700 { - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o700))?; + if current != 0o700 + && std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o700)).is_ok() + { changed_dirs.push(p.clone()); } if let Ok(entries) = std::fs::read_dir(&p) { @@ -700,8 +704,9 @@ pub(crate) fn enforce_hardened_tree( // Keep the owner's bits (notably execute) but drop group/other. FileMode::PreserveOwner => current & 0o700, }; - if current != target { - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(target))?; + if current != target + && std::fs::set_permissions(&p, std::fs::Permissions::from_mode(target)).is_ok() + { changed_files.push(p); } }