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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

12 changes: 12 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should put this under the build command. The concept of archiving a contract is an overloaded term, since contract themselves can be archived by the network. I had to dig into the code to really understand what this new command was doing too.

So something like:

stellar contract build archive

Or:

stellar contract build --archive-only

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think something even more verbose is probably even better:

stellar contract build create-source-archive


Generate the reproducible source archive used by verifiable builds

**Usage:** `stellar contract 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
234 changes: 234 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,237 @@ 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)"
);
}
}

// 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]
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"
);
}
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