diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..036a06d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Ensure consistent line endings across platforms +*.rs text eol=lf +*.sh text eol=lf diff --git a/.github/scripts/shellcheck.sh b/.github/scripts/shellcheck.sh new file mode 100755 index 0000000..c36bbea --- /dev/null +++ b/.github/scripts/shellcheck.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# runs shellcheck and prints GitHub Actions annotations for each warning and error +# https://github.com/koalaman/shellcheck + +IGNORE_DIRS=( + "./.git/*" + "./target/*" +) + +ignore_args=() +for dir in "${IGNORE_DIRS[@]}"; do + ignore_args+=(-not -path "$dir") +done + +find . -name "*.sh" "${ignore_args[@]}" -exec shellcheck -f gcc {} + | \ + while IFS=: read -r file line col severity msg; do + level="warning" + [[ "$severity" == *error* ]] && level="error" + file="${file#./}" + echo "::${level} file=${file},line=${line},col=${col}::${file}:${line}:${col}:${msg}" + done + +exit "${PIPESTATUS[0]}" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 555cec3..5d91379 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,18 +30,18 @@ jobs: target: x86_64-unknown-linux-gnu platform: linux arch: amd64 - # - os: depot-ubuntu-22.04-arm - # target: aarch64-unknown-linux-gnu - # platform: linux - # arch: arm64 + - os: depot-ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + platform: linux + arch: arm64 - os: depot-ubuntu-22.04 target: x86_64-unknown-linux-musl platform: alpine arch: amd64 - # - os: depot-ubuntu-22.04-arm - # target: aarch64-unknown-linux-musl - # platform: alpine - # arch: arm64 + - os: depot-ubuntu-22.04-arm + target: aarch64-unknown-linux-musl + platform: alpine + arch: arm64 - os: macos-14 target: x86_64-apple-darwin platform: darwin @@ -89,6 +89,19 @@ jobs: persist-credentials: false - uses: crate-ci/typos@65120634e79d8374d1aa2f27e54baa0c364fff5a # v1 + shellcheck: + runs-on: depot-ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Shellcheck + shell: bash + run: ./.github/scripts/shellcheck.sh + clippy: runs-on: ubuntu-latest timeout-minutes: 30 @@ -131,6 +144,7 @@ jobs: needs: - test - typos + - shellcheck - clippy - rustfmt timeout-minutes: 30 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20a9bad..9a58897 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,7 @@ name: Release +permissions: {} + on: push: tags: @@ -10,6 +12,10 @@ env: jobs: build: + permissions: + id-token: write + contents: read + attestations: write runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -74,21 +80,42 @@ jobs: run: cargo build --profile dist --target ${{ matrix.target }} - name: Prepare artifact + id: artifacts shell: bash run: | if [[ "${{ matrix.platform }}" == "win32" ]]; then exe=".exe" fi - cp target/${{ matrix.target }}/dist/foundryup${exe} foundryup_${{ matrix.platform }}_${{ matrix.arch }} + bin_name="foundryup_${{ matrix.platform }}_${{ matrix.arch }}${exe}" + cp target/${{ matrix.target }}/dist/foundryup${exe} "$bin_name" + printf 'bin_name=%s\n' "$bin_name" >> "$GITHUB_OUTPUT" + printf 'attestation_file=%s\n' "foundryup_${{ matrix.platform }}_${{ matrix.arch }}.attestation.txt" >> "$GITHUB_OUTPUT" + + - name: Generate attestation + id: attestation + uses: actions/attest-build-provenance@v3 + with: + subject-path: ${{ steps.artifacts.outputs.bin_name }} + + - name: Record attestation URL + env: + ATTESTATION_URL: ${{ steps.attestation.outputs.attestation-url }} + ATTESTATION_FILE: ${{ steps.artifacts.outputs.attestation_file }} + shell: bash + run: printf '%s\n' "$ATTESTATION_URL" > "$ATTESTATION_FILE" - name: Upload artifact uses: actions/upload-artifact@v6 with: name: foundryup_${{ matrix.platform }}_${{ matrix.arch }} - path: foundryup_${{ matrix.platform }}_${{ matrix.arch }} + path: | + ${{ steps.artifacts.outputs.bin_name }} + ${{ steps.artifacts.outputs.attestation_file }} if-no-files-found: error release: + permissions: + contents: write runs-on: ubuntu-latest needs: build timeout-minutes: 30 diff --git a/Cargo.lock b/Cargo.lock index 601ce29..e8a0d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -524,7 +524,7 @@ dependencies = [ [[package]] name = "foundryup" -version = "2.0.0" +version = "0.0.3" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index a3a961c..4fbcbfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "foundryup" -version = "2.0.0" +version = "0.0.3" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/foundryup-init.sh b/foundryup-init.sh index 610d823..74eebda 100755 --- a/foundryup-init.sh +++ b/foundryup-init.sh @@ -17,12 +17,13 @@ has_local() { has_local 2>/dev/null || alias local=typeset -set -u +set -eu FOUNDRYUP_REPO="foundry-rs/foundryup" BASE_DIR="${XDG_CONFIG_HOME:-$HOME}" FOUNDRY_DIR="${FOUNDRY_DIR:-$BASE_DIR/.foundry}" FOUNDRYUP_BIN_DIR="$FOUNDRY_DIR/bin" +FOUNDRYUP_IGNORE_VERIFICATION="${FOUNDRYUP_IGNORE_VERIFICATION:-false}" usage() { cat </dev/null || printf '%s' "$_payload_b64" | base64 -D 2>/dev/null || true) + + if [ -n "$_payload_json" ]; then + # Extract SHA256 hash from the payload + _expected_hash=$(printf '%s' "$_payload_json" | grep -oE '"sha256"[[:space:]]*:[[:space:]]*"[a-fA-F0-9]{64}"' | head -1 | grep -oE '[a-fA-F0-9]{64}') + fi + + rm -f "$_sigstore_file" + fi + fi + + rm -f "$_attestation_file" + fi + + if [ -z "$_expected_hash" ]; then + warn "no attestation found for this release, skipping verification" + fi + fi say "downloading foundryup..." ensure mkdir -p "$_dir" ensure downloader "$_url" "$_file" "$_arch" + + # Verify the downloaded binary against the attestation hash + if [ -n "$_expected_hash" ]; then + say "verifying binary integrity..." + local _actual_hash + _actual_hash=$(compute_sha256 "$_file") + + if [ "$_actual_hash" != "$_expected_hash" ]; then + err "hash verification failed: + expected: $_expected_hash + actual: $_actual_hash +Use --force to skip verification (INSECURE)" + fi + say "binary verified ✓" + fi + ensure chmod u+x "$_file" if [ ! -x "$_file" ]; then - err "cannot execute $_file (likely because of mounting /tmp as noexec)." - err "please copy the file to a location where you can execute binaries and run ./foundryup" - exit 1 + err "cannot execute $_file (likely because of mounting /tmp as noexec). +please copy the file to a location where you can execute binaries and run ./foundryup" fi say "installing foundryup to $FOUNDRYUP_BIN_DIR..." @@ -179,7 +248,6 @@ get_architecture() { ;; *) err "unsupported OS: $_ostype" - exit 1 ;; esac @@ -197,13 +265,19 @@ get_architecture() { ;; *) err "unsupported architecture: $_cputype" - exit 1 ;; esac RETVAL="${_ostype}_${_cputype}" } +get_ext() { + case "$1" in + win32_*) echo ".exe" ;; + *) echo "" ;; + esac +} + is_musl() { if [ -f /etc/os-release ]; then grep -qi "alpine" /etc/os-release 2>/dev/null @@ -251,6 +325,27 @@ assert_nz() { fi } +compute_sha256() { + if check_cmd sha256sum; then + sha256sum "$1" | cut -d' ' -f1 | sed 's/^\\//' + elif check_cmd shasum; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + err "need 'sha256sum' or 'shasum' for verification" + fi +} + +# Download without exiting on failure (used for optional files like attestations) +try_download() { + if check_cmd curl; then + curl --proto '=https' --tlsv1.2 --silent --fail --location "$1" --output "$2" 2>/dev/null + elif check_cmd wget; then + wget --https-only --secure-protocol=TLSv1_2 -q "$1" -O "$2" 2>/dev/null + else + return 1 + fi +} + ensure() { if ! "$@"; then err "command failed: $*" diff --git a/tests/it/installer_script.rs b/tests/it/installer_script.rs index 3a8b0f5..d745d9d 100644 --- a/tests/it/installer_script.rs +++ b/tests/it/installer_script.rs @@ -1,22 +1,37 @@ -use std::process::{Command, Stdio}; +use std::{ + io::Write, + process::{Command, Stdio}, +}; + +fn normalize_line_endings(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "") +} fn script_without_main() -> String { let script = include_str!("../../foundryup-init.sh"); - script.replace("main \"$@\" || exit 1", "") + normalize_line_endings(script).replace("main \"$@\" || exit 1", "") } -fn run_script_function(function_body: &str) -> std::process::Output { - let script = script_without_main(); - let full_script = format!("{script}\n\n{function_body}"); +fn run_script(script: &str) -> std::process::Output { + let normalized = normalize_line_endings(script); + let mut temp_file = tempfile::NamedTempFile::new().unwrap(); + temp_file.write_all(normalized.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + Command::new("sh") - .arg("-c") - .arg(&full_script) + .arg(temp_file.path()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .unwrap() } +fn run_script_function(function_body: &str) -> std::process::Output { + let script = script_without_main(); + let full_script = format!("{script}\n\n{function_body}"); + run_script(&full_script) +} + #[test] fn script_has_shebang() { let script = include_str!("../../foundryup-init.sh"); @@ -50,11 +65,8 @@ fn script_help_flag() { #[test] fn script_get_architecture_linux_amd64() { let script = script_without_main(); - let output = Command::new("sh") - .args([ - "-c", - &format!( - r#" + let output = run_script(&format!( + r#" {script} uname() {{ case "$1" in @@ -66,12 +78,7 @@ is_musl() {{ return 1; }} get_architecture echo "$RETVAL" "# - ), - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); + )); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(stdout, "linux_amd64"); } @@ -79,11 +86,8 @@ echo "$RETVAL" #[test] fn script_get_architecture_darwin_arm64() { let script = script_without_main(); - let output = Command::new("sh") - .args([ - "-c", - &format!( - r#" + let output = run_script(&format!( + r#" {script} uname() {{ case "$1" in @@ -94,12 +98,7 @@ uname() {{ get_architecture echo "$RETVAL" "# - ), - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); + )); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(stdout, "darwin_arm64"); } @@ -107,11 +106,8 @@ echo "$RETVAL" #[test] fn script_get_architecture_alpine() { let script = script_without_main(); - let output = Command::new("sh") - .args([ - "-c", - &format!( - r#" + let output = run_script(&format!( + r#" {script} uname() {{ case "$1" in @@ -123,12 +119,7 @@ is_musl() {{ return 0; }} get_architecture echo "$RETVAL" "# - ), - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); + )); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(stdout, "alpine_amd64"); } @@ -136,11 +127,8 @@ echo "$RETVAL" #[test] fn script_get_architecture_windows() { let script = script_without_main(); - let output = Command::new("sh") - .args([ - "-c", - &format!( - r#" + let output = run_script(&format!( + r#" {script} uname() {{ case "$1" in @@ -151,12 +139,7 @@ uname() {{ get_architecture echo "$RETVAL" "# - ), - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); + )); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); assert_eq!(stdout, "win32_amd64"); } @@ -189,17 +172,31 @@ fn script_need_cmd_fails_for_missing() { #[test] fn script_assert_nz_success() { let output = run_script_function("assert_nz 'value' 'test' && echo 'ok'"); - assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("ok")); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected success, got {:?}\nstdout: {}\nstderr: {}", + output.status, + stdout, + stderr + ); + assert!(stdout.contains("ok"), "stdout missing 'ok': {}", stdout); } #[test] fn script_assert_nz_failure() { let output = run_script_function("assert_nz '' 'test'"); - assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("assert_nz test")); + assert!( + !output.status.success(), + "expected failure, got {:?}\nstdout: {}\nstderr: {}", + output.status, + stdout, + stderr + ); + assert!(stderr.contains("assert_nz test"), "stderr missing 'assert_nz test': {}", stderr); } #[test] @@ -253,23 +250,15 @@ fn script_foundryup_repo_defined() { #[test] fn script_foundryup_bin_dir_default() { let script = script_without_main(); - let output = Command::new("sh") - .args([ - "-c", - &format!( - r#" + let output = run_script(&format!( + r#" unset FOUNDRY_DIR unset XDG_CONFIG_HOME HOME=/tmp/test_home {script} echo "$FOUNDRYUP_BIN_DIR" "# - ), - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); + )); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("/tmp/test_home/.foundry/bin")); } @@ -277,21 +266,13 @@ echo "$FOUNDRYUP_BIN_DIR" #[test] fn script_foundryup_bin_dir_custom() { let script = script_without_main(); - let output = Command::new("sh") - .args([ - "-c", - &format!( - r#" + let output = run_script(&format!( + r#" FOUNDRY_DIR=/custom/path {script} echo "$FOUNDRYUP_BIN_DIR" "# - ), - ]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .unwrap(); + )); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("/custom/path/bin")); } @@ -332,3 +313,96 @@ fn script_downloads_foundryup() { std::fs::remove_dir_all(&temp_dir).ok(); } + +#[test] +fn script_get_ext_windows() { + let output = run_script_function(r#"echo "$(get_ext win32_amd64)""#); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!(stdout, ".exe", "get_ext should return .exe for win32"); +} + +#[test] +fn script_get_ext_unix() { + let output = run_script_function(r#"echo "$(get_ext linux_amd64)""#); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!(stdout, "", "get_ext should return empty for linux"); +} + +#[test] +fn script_compute_sha256_known_value() { + let output = run_script_function( + r#" +printf 'hello' > /tmp/sha_test_file +hash=$(compute_sha256 /tmp/sha_test_file) +rm -f /tmp/sha_test_file +echo "$hash" +"#, + ); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "compute_sha256 should match known SHA256 of 'hello'" + ); +} + +#[test] +fn script_downloads_with_attestation_verification() { + let temp_dir = + std::env::temp_dir().join(format!("foundryup-attest-test-{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let bin_dir = temp_dir.join("bin"); + let foundryup_path = bin_dir.join("foundryup"); + + let output = Command::new("sh") + .args(["foundryup-init.sh", "-y"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("FOUNDRY_DIR", &temp_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(output.status.success(), "script failed:\nstdout: {stdout}\nstderr: {stderr}"); + assert!(foundryup_path.exists(), "foundryup binary should be installed"); + + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("binary verified"), + "attestation verification should succeed with 'binary verified ✓'. Output: {combined}" + ); + + std::fs::remove_dir_all(&temp_dir).ok(); +} + +#[test] +fn script_downloads_with_force_skips_attestation() { + let temp_dir = + std::env::temp_dir().join(format!("foundryup-force-test-{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).unwrap(); + + let output = Command::new("sh") + .args(["foundryup-init.sh", "-y", "--force"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("FOUNDRY_DIR", &temp_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(output.status.success(), "script failed:\nstdout: {stdout}\nstderr: {stderr}"); + + let combined = format!("{stdout}{stderr}"); + assert!( + combined.contains("skipping attestation verification"), + "--force should skip attestation. Output: {combined}" + ); + + std::fs::remove_dir_all(&temp_dir).ok(); +}