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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 20 additions & 1 deletion FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,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] build <COMMAND>`

###### **Subcommands:**

- `archive` — Generate (or inspect) the reproducible source archive for a contract

###### **Container Options:**

Expand Down Expand Up @@ -421,6 +425,21 @@ 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 build archive`

Generate (or inspect) the reproducible source archive for a contract.

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.

**Usage:** `stellar contract build archive [OPTIONS]`

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

- `-o`, `--out-file <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.
Expand Down
265 changes: 265 additions & 0 deletions cmd/crates/soroban-test/tests/it/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1080,3 +1080,268 @@ fn build_always_injects_cli_version() {
"CLI version should not be empty"
);
}

// 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]) {
let status = 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();
assert!(status.success(), "git {args:?} failed");
}

// 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("build")
.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)"
);
}
}

// 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
// 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("build")
.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]
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("build")
.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("build")
.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("build")
.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("build")
.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("build")
.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"
);
}
2 changes: 2 additions & 0 deletions cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading