diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d18a3b3..a715976 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -182,10 +182,13 @@ jobs: - name: Get version id: version + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - TAG="${{ inputs.tag }}" + TAG="$INPUT_TAG" if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" fi echo "version=$TAG" >> $GITHUB_OUTPUT @@ -225,10 +228,13 @@ jobs: steps: - name: Get version id: version + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - TAG="${{ inputs.tag }}" + TAG="$INPUT_TAG" if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" fi echo "tag=$TAG" >> $GITHUB_OUTPUT @@ -259,10 +265,13 @@ jobs: steps: - name: Get version id: version + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - TAG="${{ inputs.tag }}" + TAG="$INPUT_TAG" if [ -z "$TAG" ]; then - TAG="${{ github.event.release.tag_name }}" + TAG="$RELEASE_TAG" fi VERSION="${TAG#v}" echo "tag=$TAG" >> $GITHUB_OUTPUT diff --git a/install.sh b/install.sh index ab6afb1..62f9506 100644 --- a/install.sh +++ b/install.sh @@ -90,17 +90,47 @@ install() { info "Version: $VERSION" DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}-${TARGET}.tar.gz" + CHECKSUMS_URL="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt" TEMP_DIR=$(mktemp -d) ARCHIVE="${TEMP_DIR}/${BINARY_NAME}.tar.gz" + CHECKSUMS="${TEMP_DIR}/checksums.txt" + ASSET_NAME="${BINARY_NAME}-${TARGET}.tar.gz" info "Downloading from: $DOWNLOAD_URL" if ! curl -fsSL "$DOWNLOAD_URL" -o "$ARCHIVE"; then error "Failed to download binary" fi + info "Downloading checksums..." + if ! curl -fsSL "$CHECKSUMS_URL" -o "$CHECKSUMS"; then + error "Failed to download checksums.txt — refusing to install unverified binary (set RTK_SKIP_CHECKSUM=1 to bypass at your own risk)" + fi + + if [ "${RTK_SKIP_CHECKSUM:-0}" = "1" ]; then + warn "RTK_SKIP_CHECKSUM=1 set — SKIPPING checksum verification (NOT RECOMMENDED)" + else + info "Verifying SHA-256 checksum..." + EXPECTED=$(grep "[[:space:]]${ASSET_NAME}\$" "$CHECKSUMS" | awk '{print $1}') + if [ -z "$EXPECTED" ]; then + error "checksum for ${ASSET_NAME} not found in checksums.txt — refusing to install" + fi + # sha256sum (Linux GNU) vs shasum -a 256 (macOS) — prefer whichever is available. + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}') + elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}') + else + error "Neither sha256sum nor shasum available — cannot verify checksum" + fi + if [ "$EXPECTED" != "$ACTUAL" ]; then + error "checksum mismatch! expected=${EXPECTED} actual=${ACTUAL} — refusing to install" + fi + info "Checksum verified." + fi + # Verify archive contents before extraction (CWE-22 path traversal). # Reject any entry with an absolute path or a ".." component. - info "Verifying archive..." + info "Verifying archive contents..." if tar -tzf "$ARCHIVE" | grep -qE '^/|(^|/)\.\.(/|$)'; then error "Archive contains unsafe paths (absolute or directory traversal) — refusing to extract" fi @@ -121,9 +151,13 @@ install() { # Verify installation verify() { - if command -v "$BINARY_NAME" >/dev/null 2>&1; then - info "Verification: $($BINARY_NAME --version)" + INSTALLED_BIN="${INSTALL_DIR}/${BINARY_NAME}" + if [ -x "$INSTALLED_BIN" ]; then + info "Verification: $("$INSTALLED_BIN" --version)" else + error "Binary not found at expected location: $INSTALLED_BIN" + fi + if ! command -v "$BINARY_NAME" >/dev/null 2>&1; then warn "Binary installed but not in PATH. Add to your shell profile:" warn " export PATH=\"\$HOME/.local/bin:\$PATH\"" fi diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index 74b6102..f752a21 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -191,13 +191,14 @@ impl TomlFilterRegistry { // Priority 1: project-local .rtk/filters.toml (trust-gated) let project_filter_path = std::path::Path::new(".rtk/filters.toml"); if project_filter_path.exists() { - let trust_status = crate::hooks::trust::check_trust(project_filter_path) - .unwrap_or(crate::hooks::trust::TrustStatus::Untrusted); + let (trust_status, verified_content) = + crate::hooks::trust::check_trust_with_content(project_filter_path) + .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); match trust_status { crate::hooks::trust::TrustStatus::Trusted | crate::hooks::trust::TrustStatus::EnvOverride => { - if let Ok(content) = std::fs::read_to_string(project_filter_path) { + if let Some(content) = verified_content { match Self::parse_and_compile(&content, "project") { Ok(f) => filters.extend(f), Err(e) => eprintln!("[rtk] warning: .rtk/filters.toml: {}", e), @@ -559,12 +560,13 @@ pub fn run_filter_tests(filter_name_opt: Option<&str>) -> VerifyResults { // Trust-gated: only verify project-local filters if trusted (SA-2025-RTK-002) let project_path = std::path::Path::new(".rtk/filters.toml"); if project_path.exists() { - let trust_status = crate::hooks::trust::check_trust(project_path) - .unwrap_or(crate::hooks::trust::TrustStatus::Untrusted); + let (trust_status, verified_content) = + crate::hooks::trust::check_trust_with_content(project_path) + .unwrap_or((crate::hooks::trust::TrustStatus::Untrusted, None)); match trust_status { crate::hooks::trust::TrustStatus::Trusted | crate::hooks::trust::TrustStatus::EnvOverride => { - if let Ok(content) = std::fs::read_to_string(project_path) { + if let Some(content) = verified_content { collect_test_outcomes( &content, filter_name_opt, diff --git a/src/hooks/integrity.rs b/src/hooks/integrity.rs index fc991dc..b2d88e5 100644 --- a/src/hooks/integrity.rs +++ b/src/hooks/integrity.rs @@ -37,13 +37,18 @@ pub enum IntegrityStatus { OrphanedHash, } -/// Compute SHA-256 hash of a file, returned as lowercase hex +/// Compute SHA-256 hash of a file, returned as lowercase hex. pub fn compute_hash(path: &Path) -> Result { let content = fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?; + Ok(compute_hash_bytes(&content)) +} + +/// Compute SHA-256 of an in-memory byte buffer, returned as lowercase hex. +pub fn compute_hash_bytes(content: &[u8]) -> String { let mut hasher = Sha256::new(); - hasher.update(&content); - Ok(format!("{:x}", hasher.finalize())) + hasher.update(content); + format!("{:x}", hasher.finalize()) } /// Derive the hash file path from the hook path diff --git a/src/hooks/trust.rs b/src/hooks/trust.rs index f93fe6b..2570555 100644 --- a/src/hooks/trust.rs +++ b/src/hooks/trust.rs @@ -93,9 +93,14 @@ fn canonical_key(filter_path: &Path) -> Result { /// /// Priority: env var > hash match > untrusted. /// All errors are soft — if anything fails, returns Untrusted (fail-secure). +#[cfg_attr(not(test), allow(dead_code))] pub fn check_trust(filter_path: &Path) -> Result { - // Fast path: env var override for CI pipelines only. - // Requires a known CI env var to be set to prevent .envrc injection attacks. + check_trust_with_content(filter_path).map(|(status, _)| status) +} + +/// Reads the file once, hashes those bytes, and returns the trust status with +/// the verified content. Content is `Some` only for `Trusted` / `EnvOverride`. +pub fn check_trust_with_content(filter_path: &Path) -> Result<(TrustStatus, Option)> { if std::env::var("RTK_TRUST_PROJECT_FILTERS").as_deref() == Ok("1") { let in_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok() @@ -103,13 +108,20 @@ pub fn check_trust(filter_path: &Path) -> Result { || std::env::var("JENKINS_URL").is_ok() || std::env::var("BUILDKITE").is_ok(); if in_ci { - return Ok(TrustStatus::EnvOverride); + let content = std::fs::read_to_string(filter_path) + .with_context(|| format!("Failed to read filter: {}", filter_path.display()))?; + return Ok((TrustStatus::EnvOverride, Some(content))); } eprintln!( "[rtk] WARNING: RTK_TRUST_PROJECT_FILTERS=1 ignored (CI environment not detected)" ); } + let bytes = match std::fs::read(filter_path) { + Ok(b) => b, + Err(_) => return Ok((TrustStatus::Untrusted, None)), + }; + let key = canonical_key(filter_path)?; let store = match read_store() { Ok(s) => s, @@ -124,19 +136,30 @@ pub fn check_trust(filter_path: &Path) -> Result { let entry = match store.trusted.get(&key) { Some(e) => e, - None => return Ok(TrustStatus::Untrusted), + None => return Ok((TrustStatus::Untrusted, None)), }; - let actual_hash = integrity::compute_hash(filter_path) - .with_context(|| format!("Failed to hash: {}", filter_path.display()))?; + let actual_hash = integrity::compute_hash_bytes(&bytes); if actual_hash == entry.sha256 { - Ok(TrustStatus::Trusted) + match String::from_utf8(bytes) { + Ok(content) => Ok((TrustStatus::Trusted, Some(content))), + Err(_) => { + eprintln!( + "[rtk] WARNING: trusted filter {} is not valid UTF-8 — treating as untrusted", + filter_path.display() + ); + Ok((TrustStatus::Untrusted, None)) + } + } } else { - Ok(TrustStatus::ContentChanged { - expected: entry.sha256.clone(), - actual: actual_hash, - }) + Ok(( + TrustStatus::ContentChanged { + expected: entry.sha256.clone(), + actual: actual_hash, + }, + None, + )) } } diff --git a/src/main.rs b/src/main.rs index a5bde54..df38faa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1133,6 +1133,7 @@ enum GoCommands { /// RTK-only subcommands that should never fall back to raw execution. /// If Clap fails to parse these, show the Clap error directly. +/// When adding a new RTK-only subcommand to `Commands`, add its clap name here. const RTK_META_COMMANDS: &[&str] = &[ "gain", "discover", @@ -1150,6 +1151,10 @@ const RTK_META_COMMANDS: &[&str] = &[ "untrust", "session", "rewrite", + "telemetry", + "smart", + "deps", + "json", ]; fn run_fallback(parse_error: clap::Error) -> Result {