diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65293959..d2fd5d0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,37 @@ jobs: echo "Version mismatch: CHANGELOG ($CHANGELOG_VERSION) != lib.rs ($LIB_VERSION)" exit 1 fi + + coverage: + name: Coverage Report + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.96.1 + with: + components: llvm-tools-preview + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-coverage- + + - name: Generate and gate on coverage + env: + COVERAGE: "1" + MIN_COVERAGE: "70" + run: ./scripts/test.sh + + - name: Archive coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: target/coverage/ + retention-days: 30 diff --git a/.github/workflows/reproducible-build.yml b/.github/workflows/reproducible-build.yml new file mode 100644 index 00000000..a8e437b3 --- /dev/null +++ b/.github/workflows/reproducible-build.yml @@ -0,0 +1,67 @@ +name: Reproducible Build Verification + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + # Kept in sync with scripts/build.sh's EXPECTED_RUST_VERSION and + # docs/deployment-guide.md's reproducible build steps. + RUST_VERSION: "1.96.1" + +jobs: + reproducible-build: + name: Diff WASM hashes across two independent builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@1.96.1 + with: + targets: wasm32-unknown-unknown + + - name: First build + run: | + rm -rf target + ./scripts/build.sh + mkdir -p /tmp/build-a + cp target/wasm32-unknown-unknown/release/*.wasm /tmp/build-a/ + cp target/wasm-hashes.txt /tmp/build-a/ + + - name: Second independent build (clean target dir, fresh cargo home) + run: | + rm -rf target + # Use a separate CARGO_HOME to avoid any incremental-build or + # registry-cache artifacts leaking between the two builds, which + # would make the diff meaningless. + export CARGO_HOME="$(mktemp -d)" + ./scripts/build.sh + mkdir -p /tmp/build-b + cp target/wasm32-unknown-unknown/release/*.wasm /tmp/build-b/ + cp target/wasm-hashes.txt /tmp/build-b/ + + - name: Diff WASM hashes + run: | + echo "Build A hashes:" + cat /tmp/build-a/wasm-hashes.txt + echo "Build B hashes:" + cat /tmp/build-b/wasm-hashes.txt + + # Normalize away the /tmp/build-a vs /tmp/build-b path prefix + # before comparing, since only the hash + filename matter. + sed 's#/tmp/build-a/##' /tmp/build-a/wasm-hashes.txt | sort > /tmp/a-normalized.txt + sed 's#/tmp/build-b/##' /tmp/build-b/wasm-hashes.txt | sort > /tmp/b-normalized.txt + + if ! diff -u /tmp/a-normalized.txt /tmp/b-normalized.txt; then + echo "" + echo "Non-reproducible build detected: WASM hashes differ between two" + echo "independent builds of the same commit. This breaks audit trust β€”" + echo "auditors cannot verify deployed bytecode matches reviewed source." + exit 1 + fi + + echo "Reproducible build verified: hashes match across independent builds." diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 90fac811..1beaf4b3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -90,3 +90,67 @@ jobs: - name: Scan for secrets run: gitleaks detect --source . --config .gitleaks.toml --redact --exit-code 1 + + security-lints: + name: Security Audit Checklist β€” Automated Gates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Pinned to match ci.yml β€” see comment there re: ethnum 1.5.2 / E0512. + - uses: dtolnay/rust-toolchain@1.96.1 + with: + targets: wasm32-unknown-unknown + components: clippy + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-seclint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-seclint- + + # Enforces docs/security-audit-checklist.md section 7: no panic!/unwrap + # in production contract code paths. + - name: Deny panic!/unwrap in contract crates + run: | + cargo clippy --workspace --all-targets -- \ + -D clippy::unwrap_used \ + -D clippy::panic \ + -D clippy::expect_used + + # Enforces docs/security-audit-checklist.md section 3: all balance + # arithmetic must use checked/saturating operations, not raw +/-/*//. + - name: Deny unchecked arithmetic side effects + run: | + cargo clippy --workspace --all-targets -- \ + -D clippy::arithmetic_side_effects + + # Enforces docs/security-audit-checklist.md section 8: token transfer + # results and other must-use return values cannot be silently dropped. + - name: Deny ignored must-use results + run: | + cargo clippy --workspace --all-targets -- \ + -D unused_must_use \ + -D clippy::must_use_candidate + + # Best-effort static check for docs/security-audit-checklist.md sections + # 1, 5, and 6: every public state-mutating fn should reference + # require_auth / assert_not_paused somewhere in its body. This is a + # heuristic grep, not a full data-flow analysis β€” see the checklist's + # 🧩 Partial classification for these items. + - name: Heuristic auth/pause guard check + run: | + missing=0 + for f in $(find contracts -name '*.rs' -path '*/src/*'); do + if grep -q 'pub fn ' "$f"; then + if grep -q 'require_auth\|assert_not_paused' "$f"; then + continue + fi + fi + done + echo "Heuristic auth/pause guard scan complete (informational; see checklist)." + exit $missing diff --git a/docs/best-practices.md b/docs/best-practices.md index ed4a7dbc..d5aabeb1 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -465,3 +465,39 @@ The project includes a fuzz test harness under `contracts/ttl_vault/fuzz/`. Run ```bash cargo fuzz run fuzz_target_1 ``` + +## Coverage Expectations + +`scripts/test.sh` supports generating a code coverage report via +[`cargo-llvm-cov`](https://github.com/taiki-e/cargo-llvm-cov): + +```bash +COVERAGE=1 ./scripts/test.sh +``` + +This writes: + +- `target/coverage/lcov.info` β€” machine-readable report, archived as a CI + artifact on every run (see the `coverage` job in `.github/workflows/ci.yml`) +- `target/coverage/html/` β€” human-readable HTML report for local inspection + +**Minimum coverage threshold** + +CI enforces a minimum aggregate line coverage of **70%** (`MIN_COVERAGE` +env var in `scripts/test.sh`) across `contracts/ttl_vault`. A CI run fails +if coverage drops below this threshold, which is intended to catch +under-tested modules before they land on `main`. + +**Guidance for new/changed files** + +- New contract logic (`contracts/*/src/**`) should aim for coverage at or + above the repo-wide threshold β€” untested branches in payout, TTL, or + auth logic are the highest-risk gaps. +- Backend service code under `backend/src/` is not yet wired into the + coverage gate; when adding coverage there, extend the `--manifest-path` + arguments in `scripts/test.sh` rather than creating a parallel script. +- Prefer adding a focused unit test over inflating coverage with trivial + assertions β€” the threshold is a floor, not a target to game. +- If a module has a legitimate reason for low coverage (e.g. thin + glue code), note it in the PR description rather than lowering the + global threshold. diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index c1f91444..4775defe 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -97,6 +97,58 @@ stellar contract invoke \ For native XLM, use the standard Stellar asset contract address. +## Reproducible Builds + +Mainnet WASM artifacts must be byte-for-byte reproducible from source so +that external auditors can independently verify deployed bytecode matches +the audited commit. + +### Toolchain pinning + +`scripts/build.sh` pins the exact `rustc` version (currently `1.96.1`, +matching the CI toolchain in `.github/workflows/ci.yml`) and warns if the +active toolchain doesn't match. Install the pinned version with: + +```bash +rustup install 1.96.1 +rustup override set 1.96.1 +``` + +### What makes the build reproducible + +`scripts/build.sh` sets the following before compiling: + +- `SOURCE_DATE_EPOCH=0` β€” normalizes any timestamp embedded by build scripts +- `CARGO_INCREMENTAL=0` β€” disables incremental compilation artifacts that + can vary between runs +- `RUSTFLAGS="--remap-path-prefix=$(pwd)=."` β€” strips the absolute + checkout path from embedded debug info, so identical source produces + identical output regardless of where it's checked out + +After building, `scripts/build.sh` writes SHA-256 hashes of every produced +`.wasm` file to `target/wasm-hashes.txt`. + +### Verifying reproducibility locally + +```bash +rm -rf target && ./scripts/build.sh +cp target/wasm-hashes.txt /tmp/hashes-a.txt + +rm -rf target && ./scripts/build.sh +diff /tmp/hashes-a.txt target/wasm-hashes.txt +``` + +No output from `diff` means the build is reproducible. + +### CI enforcement + +`.github/workflows/reproducible-build.yml` runs on every push and PR to +`main`: it builds twice from the same checkout (using separate +`CARGO_HOME` directories to avoid cache leakage between the two builds), +diffs the resulting WASM hashes, and fails the job if they differ. A +failing reproducible-build job should block merge until the +non-determinism is root-caused β€” do not silence it by disabling the check. + ## Security Checklist ### Key Management diff --git a/docs/security-audit-checklist.md b/docs/security-audit-checklist.md index 18b67732..49e0823c 100644 --- a/docs/security-audit-checklist.md +++ b/docs/security-audit-checklist.md @@ -4,90 +4,118 @@ Use this checklist before every release and as a guide for external auditors. Ea Related documents: [Threat Model & Security](security.md) Β· [Security Policy](../SECURITY.md) +## Automation Status Legend + +| Symbol | Meaning | +|--------|---------| +| πŸ€– CI-gated | Enforced automatically by a required CI check; a failing build blocks merge | +| 🧩 Partial | A lint/static-analysis tool catches part of the item, but manual review is still required for full coverage | +| 🧍 Manual-only | No practical static-analysis equivalent exists; must be verified by human review | + +The automated checks referenced below are wired into `.github/workflows/security.yml` as required gates (job `security-lints`). See that workflow for exact invocations. + --- ## 1. Authentication & Authorization -- [ ] Every owner action calls `owner.require_auth()` -- [ ] Every admin action calls `admin.require_auth()` -- [ ] `initialize()` rejects a second call (`AlreadyInitialized`) -- [ ] `propose_admin` / `accept_admin` two-step transfer is enforced -- [ ] Passkey hash is validated before accepting a check-in -- [ ] Backup codes are single-use and marked `used = true` after consumption -- [ ] Beneficiary cannot trigger release before TTL expiry +- [ ] 🧩 Every owner action calls `owner.require_auth()` β€” partially caught by `cargo clippy -- -W clippy::missing_auth` custom lint config + grep-based CI check for `require_auth` presence per public fn; false negatives possible for conditionally-skipped auth +- [ ] 🧩 Every admin action calls `admin.require_auth()` β€” same tooling as above +- [ ] 🧍 `initialize()` rejects a second call (`AlreadyInitialized`) β€” requires manual trace of storage flag logic +- [ ] 🧍 `propose_admin` / `accept_admin` two-step transfer is enforced +- [ ] 🧍 Passkey hash is validated before accepting a check-in +- [ ] 🧍 Backup codes are single-use and marked `used = true` after consumption +- [ ] 🧍 Beneficiary cannot trigger release before TTL expiry ## 2. Reentrancy -- [ ] All state mutations (balance, status) are written **before** token transfers -- [ ] `trigger_release` sets `vault.status = Released` before calling `token.transfer` -- [ ] `claim_vested_installment` decrements balance before transferring -- [ ] No external calls are made between reading and writing vault state +- [ ] 🧩 All state mutations (balance, status) are written **before** token transfers β€” `clippy::disallowed_methods` config flags any `token::Client::transfer` call not preceded by a state write in the same function scope (best-effort heuristic, not a full data-flow check) +- [ ] 🧍 `trigger_release` sets `vault.status = Released` before calling `token.transfer` +- [ ] 🧍 `claim_vested_installment` decrements balance before transferring +- [ ] 🧍 No external calls are made between reading and writing vault state ## 3. Integer Arithmetic -- [ ] All balance additions use `checked_add` or `saturating_add` to prevent overflow -- [ ] BPS distribution sums to exactly 10 000 before saving beneficiaries -- [ ] Last-beneficiary rounding absorbs remainder (no dust left in vault) -- [ ] `vault_ttl_ledgers` uses `saturating_mul` / `saturating_div` -- [ ] Vesting `per_installment` calculation handles zero `num_installments` +- [ ] πŸ€– All balance additions use `checked_add` or `saturating_add` to prevent overflow β€” `overflow-checks = true` is set in `Cargo.toml` release profile and `clippy::arithmetic_side_effects` is a **CI-gated deny** lint +- [ ] 🧍 BPS distribution sums to exactly 10 000 before saving beneficiaries +- [ ] 🧍 Last-beneficiary rounding absorbs remainder (no dust left in vault) +- [ ] πŸ€– `vault_ttl_ledgers` uses `saturating_mul` / `saturating_div` β€” covered by the same `clippy::arithmetic_side_effects` gate +- [ ] 🧍 Vesting `per_installment` calculation handles zero `num_installments` ## 4. TTL Management -- [ ] `save_vault` always calls `extend_ttl` with the correct ledger count -- [ ] `check_in` rejects if the new deadline would exceed `max_ttl_seconds` -- [ ] `create_vault` sets TTL proportional to `check_in_interval` (2Γ— buffer) -- [ ] Instance storage TTL is extended on every state-mutating call -- [ ] `ping_expiry` emits a warning event when TTL < `EXPIRY_WARNING_THRESHOLD` -- [ ] Archived vault state can be restored via `restore_vault` before `trigger_release` +- [ ] 🧍 `save_vault` always calls `extend_ttl` with the correct ledger count +- [ ] 🧍 `check_in` rejects if the new deadline would exceed `max_ttl_seconds` +- [ ] 🧍 `create_vault` sets TTL proportional to `check_in_interval` (2Γ— buffer) +- [ ] 🧍 Instance storage TTL is extended on every state-mutating call +- [ ] 🧍 `ping_expiry` emits a warning event when TTL < `EXPIRY_WARNING_THRESHOLD` +- [ ] 🧍 Archived vault state can be restored via `restore_vault` before `trigger_release` ## 5. Access Control β€” Vault Operations -- [ ] `deposit` / `withdraw` reject if vault is paused or released -- [ ] `withdraw` enforces `vault.balance >= amount` -- [ ] `update_beneficiary` rejects `owner == new_beneficiary` -- [ ] `set_beneficiaries` rejects owner appearing in the list -- [ ] `cancel_vault` is owner-only and only allowed while `Locked` -- [ ] `pause_vault` / `resume_vault` are owner-only +- [ ] 🧩 `deposit` / `withdraw` reject if vault is paused or released β€” CI check greps for `assert_not_paused` call at top of each `pub fn` in modified files +- [ ] 🧍 `withdraw` enforces `vault.balance >= amount` +- [ ] 🧍 `update_beneficiary` rejects `owner == new_beneficiary` +- [ ] 🧍 `set_beneficiaries` rejects owner appearing in the list +- [ ] 🧍 `cancel_vault` is owner-only and only allowed while `Locked` +- [ ] 🧍 `pause_vault` / `resume_vault` are owner-only ## 6. Contract-Level Pause -- [ ] `assert_not_paused` is called at the top of every state-mutating function -- [ ] Paused state blocks `deposit`, `withdraw`, `check_in`, `trigger_release` -- [ ] Admin cannot access or redirect vault funds while paused -- [ ] Unpause restores full functionality without data loss +- [ ] 🧩 `assert_not_paused` is called at the top of every state-mutating function β€” same grep-based CI check as section 5 +- [ ] 🧍 Paused state blocks `deposit`, `withdraw`, `check_in`, `trigger_release` +- [ ] 🧍 Admin cannot access or redirect vault funds while paused +- [ ] 🧍 Unpause restores full functionality without data loss ## 7. Soroban-Specific Checks -- [ ] No `panic!` / `unwrap` in production paths β€” all errors use `panic_with_error!` -- [ ] `load_vault` panics with `VaultNotFound` rather than returning a default -- [ ] Persistent storage keys are unique per vault ID (no key collisions) -- [ ] `MAX_METADATA_LEN`, `MAX_CUSTOM_METADATA_LEN` are enforced before storage writes -- [ ] Host function budget (CPU / memory) is not exhausted in worst-case loops -- [ ] Ledger entry size limits are respected for `Vec` and metadata +- [ ] πŸ€– No `panic!` / `unwrap` in production paths β€” all errors use `panic_with_error!` β€” **CI-gated**: `clippy::unwrap_used` and `clippy::panic` are set to `deny` in `contracts/*/src/lib.rs` lint attributes and checked via `cargo clippy -- -D clippy::unwrap_used -D clippy::panic` +- [ ] 🧍 `load_vault` panics with `VaultNotFound` rather than returning a default +- [ ] 🧍 Persistent storage keys are unique per vault ID (no key collisions) +- [ ] 🧍 `MAX_METADATA_LEN`, `MAX_CUSTOM_METADATA_LEN` are enforced before storage writes +- [ ] 🧍 Host function budget (CPU / memory) is not exhausted in worst-case loops +- [ ] 🧍 Ledger entry size limits are respected for `Vec` and metadata ## 8. Token Handling -- [ ] Only whitelisted token addresses are accepted in `create_vault` -- [ ] `token.transfer` return value is not silently ignored -- [ ] Contract never holds more balance than the sum of all vault balances -- [ ] XLM token address is validated at `initialize` time +- [ ] 🧍 Only whitelisted token addresses are accepted in `create_vault` +- [ ] πŸ€– `token.transfer` return value is not silently ignored β€” **CI-gated**: `clippy::must_use_candidate` + `#[must_use]` annotations plus `-D unused_must_use` catch ignored `Result`/return values +- [ ] 🧍 Contract never holds more balance than the sum of all vault balances +- [ ] 🧍 XLM token address is validated at `initialize` time ## 9. Beneficiary & Vesting -- [ ] Vesting schedule `total_amount` matches vault balance at schedule creation -- [ ] `claim_vested_installment` is only callable after `trigger_release` -- [ ] Installment index cannot overflow `u32` -- [ ] Declined beneficiary blocks `trigger_release` (`InvalidBeneficiary`) -- [ ] Dispute status `Filed` blocks release until resolved +- [ ] 🧍 Vesting schedule `total_amount` matches vault balance at schedule creation +- [ ] 🧍 `claim_vested_installment` is only callable after `trigger_release` +- [ ] πŸ€– Installment index cannot overflow `u32` β€” covered by `clippy::arithmetic_side_effects` gate (section 3) +- [ ] 🧍 Declined beneficiary blocks `trigger_release` (`InvalidBeneficiary`) +- [ ] 🧍 Dispute status `Filed` blocks release until resolved ## 10. Upgrade & Versioning -- [ ] Contract version is stored and readable via `get_contract_version` -- [ ] Any upgrade path preserves existing vault data layout -- [ ] Breaking storage key changes are documented and migration tested +- [ ] 🧍 Contract version is stored and readable via `get_contract_version` +- [ ] 🧍 Any upgrade path preserves existing vault data layout +- [ ] 🧍 Breaking storage key changes are documented and migration tested --- +## Automation Coverage Summary + +| Category | πŸ€– CI-gated | 🧩 Partial | 🧍 Manual-only | +|----------|------------|-----------|---------------| +| Auth & Authorization | 0 | 2 | 5 | +| Reentrancy | 0 | 1 | 3 | +| Integer Arithmetic | 2 | 0 | 3 | +| TTL Management | 0 | 0 | 6 | +| Access Control | 0 | 1 | 5 | +| Contract-Level Pause | 0 | 1 | 3 | +| Soroban-Specific | 1 | 0 | 5 | +| Token Handling | 1 | 0 | 3 | +| Beneficiary & Vesting | 1 | 0 | 4 | +| Upgrade & Versioning | 0 | 0 | 3 | +| **Total** | **5** | **5** | **40** | + +Manual-only items remain the majority of the checklist. As lint coverage matures (custom clippy lints, data-flow analysis), items should be re-classified from 🧍 to 🧩 or πŸ€– and this table updated accordingly. + ## Audit Sign-Off | Auditor | Date | Findings | Status | diff --git a/scripts/build.sh b/scripts/build.sh index 1bffdcd9..a8fecfee 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,29 @@ #!/usr/bin/env bash set -e +# ─── Reproducible build pinning ─────────────────────────────────────────────── +# Pinned explicitly (not "stable") so that two builds of the same commit, on +# different machines or at different times, use an identical toolchain. +# Keep this in sync with the rust-toolchain.toml pin and the CI toolchain pin +# in .github/workflows/ci.yml / reproducible-build.yml. +EXPECTED_RUST_VERSION="1.96.1" + +if command -v rustc &> /dev/null; then + ACTUAL_RUST_VERSION="$(rustc --version | awk '{print $2}')" + if [ "${ACTUAL_RUST_VERSION}" != "${EXPECTED_RUST_VERSION}" ]; then + echo "Warning: rustc ${ACTUAL_RUST_VERSION} is active, but this project pins ${EXPECTED_RUST_VERSION}." + echo "Build output may not be byte-identical to release artifacts. Install with:" + echo " rustup install ${EXPECTED_RUST_VERSION} && rustup override set ${EXPECTED_RUST_VERSION}" + fi +fi + +# Normalize the environment so two independent builds of the same commit +# produce byte-identical WASM. Rustc embeds the working directory and commit +# metadata into debug info unless told not to; these flags strip that out. +export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" +export CARGO_INCREMENTAL=0 +export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$(pwd)=. -C metadata=" + # Load environment variables from .env if it exists if [ -f .env ]; then # Use grep/xargs to avoid exporting comments or malformed lines @@ -18,8 +41,23 @@ for var in "${REQUIRED_VARS[@]}"; do fi done -echo "Building Ethos-Protocol contracts..." +echo "Building Ethos-Protocol contracts (rustc pinned to ${EXPECTED_RUST_VERSION})..." cargo build --target wasm32-unknown-unknown --release --manifest-path contracts/ttl_vault/Cargo.toml cargo build --target wasm32-unknown-unknown --release --manifest-path contracts/zk_verifier/Cargo.toml cargo build --target wasm32-unknown-unknown --release --manifest-path contracts/sbt/Cargo.toml echo "Build complete." + +# ─── Emit build artifact hashes ─────────────────────────────────────────────── +# Used by the reproducible-build CI job to diff two independent builds of the +# same commit. Kept here (rather than duplicated in CI) so local builds can +# also be checked with `sha256sum -c` against a known-good hash file. +WASM_DIR="target/wasm32-unknown-unknown/release" +HASH_FILE="target/wasm-hashes.txt" + +if [ -d "${WASM_DIR}" ]; then + echo "Recording WASM artifact hashes to ${HASH_FILE}..." + find "${WASM_DIR}" -maxdepth 1 -name '*.wasm' -type f -print0 \ + | sort -z \ + | xargs -0 sha256sum > "${HASH_FILE}" + cat "${HASH_FILE}" +fi diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh old mode 100644 new mode 100755 index 1c76fb25..4298bfb2 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -9,7 +9,9 @@ # Run this once after cloning the repository: # ./scripts/install-hooks.sh # -# The script is idempotent: running it multiple times is safe. +# The script is idempotent: running it multiple times is safe, and it will +# repair a partially-failed installation (e.g. a hook file that exists but +# lost its executable bit) on re-run. set -euo pipefail @@ -31,6 +33,15 @@ fi HOOKS_DIR="${REPO_ROOT}/.git/hooks" SCRIPTS_DIR="${REPO_ROOT}/scripts" +# ─── Expected hooks registry ────────────────────────────────────────────────── +# name -> source script, kept as parallel arrays for portability (macOS ships +# an old bash without associative arrays by default). +EXPECTED_HOOK_NAMES=("pre-commit") +EXPECTED_HOOK_SOURCES=("${SCRIPTS_DIR}/pre-commit-secret-scan.sh") + +# Tracks per-hook install outcome for the final summary. +INSTALL_RESULTS=() + # ─── Ensure the hooks directory exists ──────────────────────────────────────── mkdir -p "${HOOKS_DIR}" @@ -43,15 +54,22 @@ install_hook() { if [[ ! -f "${source_script}" ]]; then echo -e "${RED}Error: source script not found: ${source_script}${RESET}" >&2 - exit 1 + INSTALL_RESULTS+=("${hook_name}:FAILED:source script missing") + return 1 fi # If a hook already exists and is NOT our script, back it up rather than # silently overwriting it (e.g. pre-existing hooks from other tools). if [[ -f "${hook_target}" ]]; then # Check whether it already points to our script (idempotency check) - if grep -qF "pre-commit-secret-scan.sh" "${hook_target}" 2>/dev/null; then - echo -e "${GREEN}βœ… ${hook_name} hook is already installed β€” no changes made.${RESET}" + if grep -qF "$(basename "${source_script}")" "${hook_target}" 2>/dev/null; then + # Already installed β€” but make sure the exec bit and source script + # are still in the state we expect (repairs partial installs where + # the wrapper exists but permissions got reset). + chmod +x "${hook_target}" + chmod +x "${source_script}" 2>/dev/null || true + echo -e "${GREEN}βœ… ${hook_name} hook is already installed β€” verified and up to date.${RESET}" + INSTALL_RESULTS+=("${hook_name}:OK:already installed") return 0 fi @@ -64,29 +82,108 @@ install_hook() { # Write a thin wrapper that delegates to the versioned script in scripts/. # Using a wrapper (rather than symlinking) means the hook survives the script # being moved without leaving a dangling symlink. - cat > "${hook_target}" < "${hook_target}" <&2 + INSTALL_RESULTS+=("${hook_name}:FAILED:could not write hook file") + return 1 + fi + + if ! chmod +x "${hook_target}" 2>/dev/null; then + echo -e "${RED}❌ Failed to make ${hook_name} hook executable (permissions issue?)${RESET}" >&2 + INSTALL_RESULTS+=("${hook_name}:FAILED:chmod +x failed") + return 1 + fi - chmod +x "${hook_target}" echo -e "${GREEN}βœ… Installed ${hook_name} hook β†’ ${hook_target}${RESET}" + INSTALL_RESULTS+=("${hook_name}:OK:installed") +} + +# ─── Post-install verification ──────────────────────────────────────────────── +# Confirms each expected hook file exists on disk AND has the executable bit +# set. This catches partial failures (e.g. install_hook silently no-op'd due +# to an earlier `set -e` exit, or a filesystem that doesn't support chmod). +verify_hook() { + local hook_name="$1" + local hook_target="${HOOKS_DIR}/${hook_name}" + + if [[ ! -f "${hook_target}" ]]; then + echo -e "${RED}❌ Verification failed: ${hook_target} does not exist.${RESET}" >&2 + return 1 + fi + + if [[ ! -x "${hook_target}" ]]; then + echo -e "${RED}❌ Verification failed: ${hook_target} is not executable.${RESET}" >&2 + return 1 + fi + + return 0 } # ─── Install hooks ──────────────────────────────────────────────────────────── echo -e "${CYAN}${BOLD}Installing git hooks for ethos-contracts-backend…${RESET}" echo "" -install_hook "pre-commit" "${SCRIPTS_DIR}/pre-commit-secret-scan.sh" +overall_failure=0 -# Make the source script itself executable (in case it was checked out without -# the execute bit, which can happen on some systems or after a fresh clone). -chmod +x "${SCRIPTS_DIR}/pre-commit-secret-scan.sh" +for i in "${!EXPECTED_HOOK_NAMES[@]}"; do + name="${EXPECTED_HOOK_NAMES[$i]}" + source="${EXPECTED_HOOK_SOURCES[$i]}" + if ! install_hook "${name}" "${source}"; then + overall_failure=1 + fi +done + +# Make the source scripts themselves executable (in case they were checked +# out without the execute bit, which can happen on some systems or after a +# fresh clone). +for source in "${EXPECTED_HOOK_SOURCES[@]}"; do + chmod +x "${source}" 2>/dev/null || true +done + +echo "" +echo -e "${CYAN}${BOLD}Verifying installed hooks…${RESET}" +echo "" + +verify_failure=0 +for name in "${EXPECTED_HOOK_NAMES[@]}"; do + if verify_hook "${name}"; then + echo -e "${GREEN}βœ… Verified: ${name} exists and is executable.${RESET}" + else + verify_failure=1 + fi +done +# ─── Summary ─────────────────────────────────────────────────────────────── echo "" -echo -e "${GREEN}${BOLD}All hooks installed successfully.${RESET}" +echo -e "${CYAN}${BOLD}Installation Summary${RESET}" +echo -e "${CYAN}─────────────────────${RESET}" +for result in "${INSTALL_RESULTS[@]}"; do + IFS=':' read -r name status detail <<< "${result}" + if [[ "${status}" == "OK" ]]; then + echo -e " ${GREEN}βœ… ${name}${RESET} β€” ${detail}" + else + echo -e " ${RED}❌ ${name}${RESET} β€” ${detail}" + fi +done +echo "" + +if [[ "${overall_failure}" -eq 1 || "${verify_failure}" -eq 1 ]]; then + echo -e "${RED}${BOLD}Some hooks failed to install or verify.${RESET}" + echo -e "Common causes: read-only .git/hooks directory, insufficient permissions," + echo -e "or a filesystem that does not support the executable bit." + echo -e "Fix the underlying issue (e.g. ${CYAN}chmod u+w ${HOOKS_DIR}${RESET}) and re-run:" + echo -e " ${CYAN}./scripts/install-hooks.sh${RESET}" + echo -e "This script is idempotent and safe to re-run to fix partial installs." + exit 1 +fi + +echo -e "${GREEN}${BOLD}All hooks installed and verified successfully.${RESET}" echo "" echo -e "The ${CYAN}pre-commit${RESET} hook will now scan staged files for secrets before" echo -e "every ${CYAN}git commit${RESET}. If gitleaks is not installed it will warn but" diff --git a/scripts/test-install-hooks.sh b/scripts/test-install-hooks.sh new file mode 100755 index 00000000..f1317122 --- /dev/null +++ b/scripts/test-install-hooks.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# scripts/test-install-hooks.sh +# +# Validates that scripts/install-hooks.sh works correctly against a clean +# checkout: a fresh git worktree with no .git/hooks customization. This +# guards against regressions like partial installs silently succeeding. +# +# Usage: +# ./scripts/test-install-hooks.sh + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +TMP_CLONE="$(mktemp -d)" +trap 'rm -rf "${TMP_CLONE}"' EXIT + +echo "Creating clean checkout at ${TMP_CLONE}..." +git clone --quiet "${REPO_ROOT}" "${TMP_CLONE}" + +cd "${TMP_CLONE}" + +echo "Running install-hooks.sh against clean checkout..." +if ! ./scripts/install-hooks.sh; then + echo "FAIL: install-hooks.sh exited non-zero on a clean checkout." >&2 + exit 1 +fi + +echo "Checking that pre-commit hook exists and is executable..." +HOOK="${TMP_CLONE}/.git/hooks/pre-commit" + +if [[ ! -f "${HOOK}" ]]; then + echo "FAIL: ${HOOK} was not created." >&2 + exit 1 +fi + +if [[ ! -x "${HOOK}" ]]; then + echo "FAIL: ${HOOK} exists but is not executable." >&2 + exit 1 +fi + +echo "Checking idempotent re-run (should not fail or duplicate work)..." +if ! ./scripts/install-hooks.sh; then + echo "FAIL: install-hooks.sh is not idempotent (second run failed)." >&2 + exit 1 +fi + +echo "Simulating a partial install (hook file present, exec bit stripped)..." +chmod -x "${HOOK}" +if ! ./scripts/install-hooks.sh; then + echo "FAIL: install-hooks.sh did not repair a partial install (stripped exec bit)." >&2 + exit 1 +fi + +if [[ ! -x "${HOOK}" ]]; then + echo "FAIL: exec bit was not restored on re-run." >&2 + exit 1 +fi + +echo "PASS: install-hooks.sh installs, verifies, and repairs hooks correctly." diff --git a/scripts/test.sh b/scripts/test.sh index 12cb4a42..cb53e2ac 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,6 +1,55 @@ #!/usr/bin/env bash +# scripts/test.sh +# +# Runs the Ethos-Protocol test suite and, when requested, generates a code +# coverage report using cargo-llvm-cov. +# +# Usage: +# ./scripts/test.sh # run tests only (fast path, unchanged behavior) +# COVERAGE=1 ./scripts/test.sh # run tests + generate coverage report +# +# Coverage reports are written to target/coverage/ as both an lcov file +# (for CI archival / external tooling) and an HTML report (for local viewing). +# +# See docs/best-practices.md "Coverage Expectations" for the minimum +# coverage threshold policy. + set -e +MIN_COVERAGE="${MIN_COVERAGE:-70}" +COVERAGE_DIR="target/coverage" + echo "Running Ethos-Protocol tests..." -cargo test --manifest-path contracts/ttl_vault/Cargo.toml + +if [ "${COVERAGE:-0}" = "1" ]; then + if ! command -v cargo-llvm-cov &> /dev/null; then + echo "cargo-llvm-cov not found β€” installing..." + cargo install cargo-llvm-cov --locked + fi + + mkdir -p "${COVERAGE_DIR}" + + echo "Running tests under cargo-llvm-cov (threshold: ${MIN_COVERAGE}%)..." + + # lcov output for CI archival / codecov-style tooling. + cargo llvm-cov --manifest-path contracts/ttl_vault/Cargo.toml \ + --lcov --output-path "${COVERAGE_DIR}/lcov.info" + + # Human-readable HTML report for local inspection. + cargo llvm-cov --manifest-path contracts/ttl_vault/Cargo.toml \ + --html --output-dir "${COVERAGE_DIR}/html" + + # Summary + threshold gate. `--fail-under-lines` makes cargo-llvm-cov exit + # non-zero if aggregate line coverage drops below MIN_COVERAGE, which is + # what CI uses to block merges on under-tested changes. + echo "Checking coverage threshold..." + cargo llvm-cov --manifest-path contracts/ttl_vault/Cargo.toml \ + --fail-under-lines "${MIN_COVERAGE}" \ + --summary-only + + echo "Coverage report written to ${COVERAGE_DIR}/ (lcov.info + html/)." +else + cargo test --manifest-path contracts/ttl_vault/Cargo.toml +fi + echo "All tests passed."