diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba56ab8..b427b1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,10 @@ jobs: run: cargo build --all-targets - name: Docgen run: cargo doc --no-deps + - name: verify_docs_projection + run: bash ./scripts/verify_docs_projection.sh + - name: Table doctests + run: cargo test --doc table --no-fail-fast - name: Test (all targets, all integration tests) run: cargo test --all-targets - name: verify_mapping diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e3bd97..407bfb2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,15 +6,77 @@ on: - 'v*' permissions: - contents: write + contents: read jobs: - publish: - name: Publish Crate + admit: + name: Trusted ruleset admission runs-on: ubuntu-latest + environment: + name: release-admission + permissions: + contents: read + actions: write + outputs: + attestation_sha256: ${{ steps.attestation.outputs.attestation_sha256 }} steps: + - name: Fetch authoritative ruleset details + id: attestation + env: + GH_TOKEN: ${{ secrets.RULESET_ADMISSION_TOKEN }} + run: | + set -euo pipefail + : "${GH_TOKEN:?RULESET_ADMISSION_TOKEN must be configured in the protected release-admission environment}" + summaries="$(gh api "repos/${GITHUB_REPOSITORY}/rulesets?includes_parents=true&per_page=100")" + if [[ "$(jq -r 'type' <<<"${summaries}")" != "array" ]]; then + echo "ERROR: ruleset summary response is not an array" >&2 + exit 1 + fi + rulesets="$( + jq -r '.[].id // empty' <<<"${summaries}" | + while IFS= read -r ruleset_id; do + if [[ ! "${ruleset_id}" =~ ^[0-9]+$ ]]; then + echo "ERROR: ruleset id is not numeric" >&2 + exit 1 + fi + gh api "repos/${GITHUB_REPOSITORY}/rulesets/${ruleset_id}?includes_parents=true" + done | + jq -s '.' + )" + attestation_path="$RUNNER_TEMP/ruleset-attestation.json" + jq -n --arg repository "${GITHUB_REPOSITORY}" --arg commit "${GITHUB_SHA}" --arg workflow_run_id "${GITHUB_RUN_ID}" --argjson rulesets "${rulesets}" '{ + schema_version: 1, + source: "github-ruleset-detail-attestation", + repository: $repository, + commit: $commit, + workflow_run_id: $workflow_run_id, + rulesets: $rulesets + }' > "${attestation_path}" + test -s "${attestation_path}" + echo "attestation_sha256=$(sha256sum "${attestation_path}" | awk '{print $1}')" >> "$GITHUB_OUTPUT" + - name: Upload trusted ruleset attestation + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: ruleset-attestation-${{ github.sha }} + path: ${{ runner.temp }}/ruleset-attestation.json + if-no-files-found: error + retention-days: 1 + verify: + name: Verify and package release + runs-on: ubuntu-latest + needs: admit + permissions: + contents: read + actions: read + steps: + - name: Download trusted ruleset attestation + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: ruleset-attestation-${{ github.sha }} + path: ${{ runner.temp }}/ruleset-attestation - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: + fetch-depth: 0 persist-credentials: false - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: @@ -22,11 +84,15 @@ jobs: components: clippy, rustfmt - name: Verify version and tag match upstream run: scripts/verify_upstream_version.sh "$GITHUB_REF_NAME" + - name: Verify tag, ancestry, and CI admission + id: admission + env: + GH_TOKEN: ${{ github.token }} + RULESET_ATTESTATION_FILE: ${{ runner.temp }}/ruleset-attestation/ruleset-attestation.json + RULESET_ATTESTATION_SHA256: ${{ needs.admit.outputs.attestation_sha256 }} + run: bash scripts/verify_release_admission.sh - name: Configure shared Cargo cache run: | - # The runner image pre-sets CARGO_HOME=$HOME/.cargo; drop the - # ambient value so the shared-cache defaults apply (the resolver - # rejects it as an uncontained override otherwise). unset CARGO_HOME . scripts/cargo-env.sh configure_shared_cargo_cache_environment @@ -83,7 +149,7 @@ jobs: persist-credentials: false - name: Place sibling crates run: | - mkdir -p ../siblings && true + mkdir -p ../siblings mv siblings/rusty-bubbletea ../rusty-bubbletea mv siblings/rusty-colorprofile ../rusty-colorprofile mv siblings/rusty-lipgloss ../rusty-lipgloss @@ -98,52 +164,133 @@ jobs: run: cargo build --all-targets - name: Docgen run: cargo doc --no-deps + - name: Table doctests + run: cargo test --doc table --no-fail-fast - name: Test (all targets, all integration tests) run: cargo test --all-targets - name: Upstream mapping verification run: ./scripts/verify_mapping.sh - # Releases are tag-gated: only pushes of a v* tag publish. crates.io - # rejects re-publishing an existing version, so the version-bump gate - # in ci.yml keeps every release on a fresh, unreleased version. + - name: Release-boundary guard tests + run: ./scripts/test-release-guards.sh + - name: Package release artifacts from a clean source archive + id: package + env: + CI_RUN_ID: ${{ steps.admission.outputs.ci_run_id }} + run: | + set -euo pipefail + workspace="$RUNNER_TEMP/release-workspace" + rm -rf "$workspace" + mkdir -p "$workspace/rusty-bubbles" + git archive --format=tar "$GITHUB_SHA" | tar -xf - -C "$workspace/rusty-bubbles" + for sibling in rusty-bubbletea rusty-colorprofile rusty-lipgloss rusty-testkit rusty-ultraviolet rusty-x-ansi; do + mkdir -p "$workspace/$sibling" + git -C "$GITHUB_WORKSPACE/../$sibling" archive --format=tar HEAD | tar -xf - -C "$workspace/$sibling" + done + rm -f "$workspace/rusty-bubbles/.command-whitelist" "$workspace/rusty-bubbles/whitelist-exec.sh" "$workspace/rusty-bubbles/.command-whitelist-log" + package_target="$workspace/target" + (cd "$workspace/rusty-bubbles" && env -u CARGO_BUILD_BUILD_DIR CARGO_TARGET_DIR="$package_target" cargo package --locked --no-verify --allow-dirty) + crate_file="rusty-bubbles-${GITHUB_REF_NAME#v}.crate" + crate_path="$package_target/package/$crate_file" + test -f "$crate_path" + artifact_dir="$RUNNER_TEMP/release-artifacts" + rm -rf "$artifact_dir" + mkdir -p "$artifact_dir" + cp "$crate_path" "$artifact_dir/$crate_file" + source_archive="${GITHUB_REPOSITORY##*/}-$GITHUB_REF_NAME.tar.gz" + git archive --format=tar.gz --prefix="${GITHUB_REPOSITORY##*/}-$GITHUB_REF_NAME/" -o "$artifact_dir/$source_archive" "$GITHUB_SHA" + crate_sha256="$(sha256sum "$artifact_dir/$crate_file" | awk '{print $1}')" + source_sha256="$(sha256sum "$artifact_dir/$source_archive" | awk '{print $1}')" + jq -n --arg tag "$GITHUB_REF_NAME" --arg commit "$GITHUB_SHA" --arg ci_run_id "$CI_RUN_ID" --arg crate_file "$crate_file" --arg crate_sha256 "$crate_sha256" --arg source_archive "$source_archive" --arg source_sha256 "$source_sha256" '{tag: $tag, commit: $commit, ci_run_id: $ci_run_id, crate_file: $crate_file, crate_sha256: $crate_sha256, source_archive: $source_archive, source_sha256: $source_sha256}' > "$artifact_dir/release-manifest.json" + - name: Upload verified release artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: release-artifacts-${{ github.sha }} + path: ${{ runner.temp }}/release-artifacts + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish verified release + needs: verify + if: needs.verify.result == 'success' + runs-on: ubuntu-latest + # Configure this environment with required reviewers in repository settings. + environment: + name: release + permissions: + contents: write + actions: read + steps: + - name: Download verified release artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: release-artifacts-${{ github.sha }} + path: artifacts + - name: Verify artifact binding and digest + run: | + set -euo pipefail + manifest=artifacts/release-manifest.json + test -f "$manifest" + tag="$(jq -er .tag "$manifest")" + commit="$(jq -er .commit "$manifest")" + ci_run_id="$(jq -er .ci_run_id "$manifest")" + crate_file="$(jq -er .crate_file "$manifest")" + crate_sha256="$(jq -er .crate_sha256 "$manifest")" + source_archive="$(jq -er .source_archive "$manifest")" + source_sha256="$(jq -er .source_sha256 "$manifest")" + test "$tag" = "$GITHUB_REF_NAME" + test "$commit" = "$GITHUB_SHA" + test "$ci_run_id" -gt 0 + test "$crate_file" = "rusty-bubbles-${GITHUB_REF_NAME#v}.crate" + case "$crate_file" in /*|*..*) exit 1 ;; esac + case "$source_archive" in /*|*..*) exit 1 ;; esac + test -f "artifacts/$crate_file" + test -f "artifacts/$source_archive" + test "$crate_sha256" = "$(sha256sum "artifacts/$crate_file" | awk '{print $1}')" + test "$source_sha256" = "$(sha256sum "artifacts/$source_archive" | awk '{print $1}')" + tar -tzf "artifacts/$source_archive" >/dev/null + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.98.0 - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - echo "Release $GITHUB_REF_NAME already exists; skipping." + set -euo pipefail + tag="$(jq -er .tag artifacts/release-manifest.json)" + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $tag already exists; preserving it." else - gh release create "$GITHUB_REF_NAME" --generate-notes + gh release create "$tag" --repo "$GITHUB_REPOSITORY" --verify-tag --generate-notes fi - name: Upload source to GitHub Release - if: startsWith(github.ref, 'refs/tags/v') env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | - src="${GITHUB_REPOSITORY##*/}-$GITHUB_REF_NAME.tar.gz" - # Keep the archive out of the repo working tree so `cargo publish` - # sees a clean checkout. - git archive --format=tar.gz -o "$RUNNER_TEMP/$src" HEAD - gh release upload "$GITHUB_REF_NAME" "$RUNNER_TEMP/$src" --clobber - - name: Publish to crates.io - if: startsWith(github.ref, 'refs/tags/v') + set -euo pipefail + tag="$(jq -er .tag artifacts/release-manifest.json)" + source_archive="$(jq -er .source_archive artifacts/release-manifest.json)" + # Existing assets cause a hard failure; replacements are forbidden. + gh release upload "$tag" "artifacts/$source_archive" --repo "$GITHUB_REPOSITORY" + - name: Publish crate from verified artifact env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} run: | - if [ -n "${CARGO_REGISTRY_TOKEN}" ]; then - # Dev-dependencies are not part of the published crate, but - # `cargo publish` resolves them anyway; drop them from the - # packaging manifest so sibling dev-deps (which may be - # unpublished or cyclically depend on this crate) can't block - # the upload. The published crate is unaffected. - awk '/^\[dev-dependencies\]/ { in_dev=1 } /^\[/ && !/^\[dev-dependencies\]/ { in_dev=0 } !(in_dev && /^rusty-/) { print }' Cargo.toml > Cargo.toml.publish - mv Cargo.toml.publish Cargo.toml - # --no-verify: the verify build resolves dependencies from the - # registry, but ultraviolet's dev-dependency on lipgloss and - # lipgloss's dependency on ultraviolet form a cycle; the full - # gates (build, clippy, tests) already ran above. - cargo publish --no-verify --allow-dirty - git checkout -- Cargo.toml - else - echo "No CARGO_REGISTRY_TOKEN secret; skipping crates.io publish." + set -euo pipefail + : "${CARGO_REGISTRY_TOKEN:?CARGO_REGISTRY_TOKEN secret is required for a release}" + crate_file="$(jq -er .crate_file artifacts/release-manifest.json)" + package_root="$RUNNER_TEMP/verified-crate" + rm -rf "$package_root" + mkdir -p "$package_root" + tar -xzf "artifacts/$crate_file" -C "$package_root" + package_dir="$(find "$package_root" -mindepth 1 -maxdepth 1 -type d -name 'rusty-bubbles-*' -print -quit)" + test -n "$package_dir" + cd "$package_dir" + rm -f Cargo.toml.orig + if test -f build.rs || grep -qE '^[[:space:]]*build[[:space:]]*=' Cargo.toml; then + echo "ERROR: release package contains a build script; review the package before publishing" >&2 + exit 1 fi + # --no-verify prevents build scripts/tests from running on the + # fresh credential-bearing publication runner. + cargo publish --no-verify --locked diff --git a/UPSTREAM_MAPPING.md b/UPSTREAM_MAPPING.md index fda9df4..8c6b738 100644 --- a/UPSTREAM_MAPPING.md +++ b/UPSTREAM_MAPPING.md @@ -27,7 +27,7 @@ | `progress/progress.go` | `src/progress.rs` | Progress bar with spring animation | | `spinner/spinner.go` | `src/spinner.rs` | Spinner component + presets | | `stopwatch/stopwatch.go` | `src/stopwatch.rs` | Stopwatch component | -| `table/table.go` | `src/table.rs` | Table component | +| `table/table.go` | `src/table.rs` | Table component; Rust boundary handling keeps declared columns rectangular for ragged rows and clamps outer-height arithmetic safely | | `textarea/textarea.go` | `src/textarea.rs` | Multi-line text area | | `textinput/textinput.go` | `src/textinput.rs` | Single-line text input | | `textinput/styles.go` | `src/textinput.rs` | Text input styles | @@ -96,6 +96,10 @@ dependency tree as upstream keeps them out of the bubbletea library module): `viewport.longestLineWidth`, `m.setInitialValues()`, `statusView()`) assert through the public API (documented at each call site). - `viewport::scroll_left` uses `saturating_sub` (upstream int semantics clamp to 0). +- `table::Model` uses saturating outer-height arithmetic and reapplies the + declared table shape during rendering: missing cells are empty and surplus + cells are ignored, while cursor and viewport movement remains safe for zero + and maximum inputs. ## Dependency Manifest diff --git a/docs/projection.yaml b/docs/projection.yaml new file mode 100644 index 0000000..558ff2b --- /dev/null +++ b/docs/projection.yaml @@ -0,0 +1,6 @@ +schemaVersion: 1 +projection: + audience: user + source: docs/src/lib.rs + module: src/table.rs + destination: rusty-bubbles diff --git a/docs/src/lib.rs b/docs/src/lib.rs new file mode 100644 index 0000000..d5f14ca --- /dev/null +++ b/docs/src/lib.rs @@ -0,0 +1,30 @@ +//! Cleanroom user documentation source for the generic Bubbles widgets. +//! +//! +//! rusty-bubbles provides typed model/update/view components for common +//! terminal interfaces. Components are independent and deterministic: callers +//! own the event loop and pass messages to the component they compose. +//! +//! The table component renders each declared column in order. Short rows yield +//! empty cells and surplus row values are ignored, so malformed input cannot +//! change the table shape or panic the renderer: +//! +//! ``` +//! use rusty_bubbles::table::{self, Column}; +//! +//! let table = table::new(vec![ +//! table::with_width(16), +//! table::with_columns(&[ +//! Column { title: "Name".into(), width: 8 }, +//! Column { title: "State".into(), width: 8 }, +//! ]), +//! table::with_rows(&[vec!["Bubbles".into()]]), +//! ]); +//! assert_eq!(table.selected_row().unwrap().len(), 1); +//! assert!(table.view().contains("Bubbles")); +//! ``` +//! +//! +//! Internal maintainer note: this source is the documentation-owned projection +//! for the BUI-012 target. Keep the example synchronized with the public table +//! facade and its deterministic boundary behavior. diff --git a/evidence/acceptance/BUI-012/independent-review.md b/evidence/acceptance/BUI-012/independent-review.md new file mode 100644 index 0000000..6d551aa --- /dev/null +++ b/evidence/acceptance/BUI-012/independent-review.md @@ -0,0 +1,106 @@ +# BUI-012 Implementation Evidence + +Status: implementation evidence for material parent +`276be3a99a90c9187c1371fbef682386012ddd0d` is the current security-remediation +material head. The attestation-only descendant preserves the prior Principal +and Testing approvals; the repaired exact head must receive targeted Security +confirmation. Exact-head CI and review binding are recorded in the GitHub +Issue ledger. Merge authorization remains a lifecycle gate. This packet is not +merge authorization. + +## Historical boundary + +The previous packet covered `b1a42d2a8f6cf48597dc8eeb997aba627dcbde5a` over +base `2a88f46...`; it is historical only. The synchronized candidate includes +current `dev` at `bea9af850c7891d2006ce7c581f80846df99ece7`, Rust `1.98.0`, +immutable action references, release-boundary guards, and the API-based trusted +`dev` coverage-badge publication. The previous statement that the workflow +publishes with `git push HEAD:dev [skip ci]` is historical; the current workflow +uses the guarded GitHub API publication path and does not push from pull-request +coverage. + +## Current result + +The rusty-bubbles table now materializes every declared column deterministically. A row with fewer values produces empty cells, surplus values cannot index past the column definition, and zero or maximum navigation inputs do not panic. Outer-height subtraction clamps at zero while preserving the existing option-application semantics. + +## Current change set + +The current pull request change set contains the original table/documentation +implementation and the release-boundary remediation required by the +independent Security review: + +- `.github/workflows/ci.yml`: runs the exact-head documentation projection check and the complete seven-test table doctest selection. +- `UPSTREAM_MAPPING.md`: records the Rust-side table boundary adaptation. +- `docs/projection.yaml`: maps the user documentation source to the table module. +- `docs/src/lib.rs`: documents the table shape contract and contains a compiling user-facing example. +- `evidence/acceptance/BUI-012/independent-review.md`: this exact-head evidence packet. +- `.github/workflows/publish.yml`: adds a protected `release-admission` job with a dedicated `RULESET_ADMISSION_TOKEN`, uploads a hash-bound ruleset attestation, and keeps verification and publication credential-separated. +- `scripts/verify_docs_projection.sh`: compiles the documentation projection against the built library. +- `scripts/verify_release_admission.sh`: validates the hash-bound attestation's repository, commit, workflow-run binding, and no-bypass ruleset predicates without direct ruleset API access. +- `scripts/test-release-admission.sh`: covers valid and invalid ruleset predicates plus attestation digest and workflow-run binding. +- `scripts/test-release-guards.sh`: enforces immutable workflow/sibling references, the protected admission boundary, and read-only verifier artifact wiring. +- `src/lib.rs`: documents the crate facade as a user-facing component collection. +- `src/table.rs`: hardens outer-height arithmetic, cursor/viewport movement, ragged-row rendering, and the public user-documentation contract. +- `tests/table_test.rs`: adds deterministic ragged-row, zero-height, and maximum-input coverage while extending the upstream overflow case. + +The synchronized `dev` changes to `Cargo.toml` remain imported base changes. +The publication workflow and release guards are ticket-owned remediation for +the Security review because they govern whether this crate can be released. + +## Contract checks + +| Check | Result | Evidence | +| --- | --- | --- | +| Declared columns remain the rendered row shape | Pass | `tests/table_test.rs::test_ragged_rows_render_against_declared_columns` | +| Height underflow is closed at zero | Pass | `tests/table_test.rs::test_height_is_saturating_at_the_boundary` | +| Cursor movement handles maximum inputs | Pass | `tests/table_test.rs::test_navigation_saturates_at_cursor_bounds` | +| Zero-height navigation is safe | Pass | `tests/table_test.rs::test_zero_height_navigation_is_safe` | +| Sibling dependency direction remains unchanged | Pass | `Cargo.toml`; path dependencies remain rusty-bubbletea, rusty-lipgloss, and rusty-x-ansi | +| User-facing table documentation is projected and compilable | Pass | `docs/projection.yaml`; `scripts/verify_docs_projection.sh`; `src/table.rs`; `docs/src/lib.rs` | +| Release admission evidence is hash-bound and run-bound | Pass | `.github/workflows/publish.yml`; `scripts/verify_release_admission.sh`; `scripts/test-release-admission.sh` | +| Privileged admission does not execute candidate repository code | Pass | `.github/workflows/publish.yml`; `scripts/test-release-guards.sh` | + +## Focused validation + +- `cargo test --test table_test --no-fail-fast`: 26 passed, 2 ignored. +- `cargo test --all-targets --no-fail-fast`: all unit and integration targets passed; 2 table tests and 1 viewport benchmark remain the repository's existing ignored tests. +- `cargo fmt --all --check`: passed. +- `cargo clippy --all-targets -- -D warnings`: passed. +- `cargo doc --no-deps --all-features`: passed without warnings. +- `cargo test --doc table:: --no-fail-fast`: 6 table doctests passed, including the changed public operations. +- `cargo test --doc table --no-fail-fast`: 7 table doctests passed, including the module-level user-facing example. +- `bash ./scripts/verify_docs_projection.sh`: passed; the projection manifest and the `docs/src/lib.rs` example compile against the current library. The same check passed under the CI split `CARGO_TARGET_DIR`/`CARGO_BUILD_BUILD_DIR` layout. +- `scripts/verify_mapping.sh`: passed in protected CI; the optional local `upstream-go/` checkout is absent as documented by the script. +- `./scripts/test-release-guards.sh`: passed. +- `scripts/test-release-admission.sh`: passed, including valid binding, digest-mismatch rejection, and wrong-workflow-run rejection. +- `bash -n scripts/verify_release_admission.sh scripts/test-release-admission.sh scripts/test-release-guards.sh`: passed. +- `yq '.' .github/workflows/publish.yml`: passed. +- Historical protected CI run [`33236603654`](https://github.com/coderbants/rusty-bubbles/actions/runs/33236603654) passed on exact head `41cc48b161060842b99a6bd2048de59cbcb011ed`: version gate, lint/build/docs/tests, documentation projection, seven table doctests, mapping, release guards, and coverage all passed. The PR-only badge-update job was skipped as designed. It is not proof for the later security-remediation descendant; the current exact-head run is recorded in the GitHub Issue ledger. + +The repository-wide `cargo test --doc --no-fail-fast` run remains a known +out-of-scope baseline failure in the unchanged `src/key.rs` example/API +signature; all BUI-012 table doctests and the dedicated documentation +projection test pass. + +## Review boundary + +R-01 requested two repairs: refresh this packet after base synchronization and +add a complete `` contract plus exact-head projection proof. R-01 +replay passed with no findings on `8f21bb1`. R-02 then identified +`TST-R02-001`: the module-level table doctest was not protected by CI. The +protected `cargo test --doc table --no-fail-fast` step now covers all seven +table doctests on `2e85a43`, and the exact current head `41cc48b` has a passing +protected rerun. The R-02 replay at `d8938132b5dc179c12a8ab83d764e0c316d374a2` +passed and remains valid for this non-build attestation descendant. + +The Security review identified two blocking release-boundary findings and two +non-blocking guard/evidence findings. SEC-R03-001 was the remaining blocking +finding: the read-only verifier could not authoritatively inspect +`bypass_actors`. The remediation moves that read to the protected +`release-admission` job, binds the complete ruleset response to the repository, +commit, and workflow run, and supplies only the digest-bound artifact to the +read-only verifier. This does not alter the candidate build or admission +predicates, so prior Principal and Testing approvals are retained; only +targeted Security confirmation of the repaired exact head remains. The final +protected CI and post-merge absorb transition remain owned by the Mutate +lifecycle. diff --git a/scripts/test-release-admission.sh b/scripts/test-release-admission.sh new file mode 100755 index 0000000..25bd0b5 --- /dev/null +++ b/scripts/test-release-admission.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Offline regression tests for the fail-closed ruleset predicates. The live +# admission job fetches authoritative ruleset detail records; the verification +# script only validates the hash-bound attestation and checks tag, ancestry, +# and CI evidence. + +set -euo pipefail + +cd "$(dirname "$0")/.." +source scripts/verify_release_admission.sh + +assert_rejected() { + local label="$1" + local predicate="$2" + local fixture="$3" + if "$predicate" <<<"[${fixture}]"; then + echo "ERROR: ${label} must be rejected" >&2 + exit 1 + fi +} + +assert_admitted() { + local label="$1" + local predicate="$2" + local fixture="$3" + if ! "$predicate" <<<"[${fixture}]"; then + echo "ERROR: ${label} must be admitted" >&2 + exit 1 + fi +} + +attestation_dir="$(mktemp -d)" +trap 'rm -rf "${attestation_dir}"' EXIT +attestation_file="${attestation_dir}/ruleset-attestation.json" +export GITHUB_REPOSITORY="coderbants/rusty-bubbles" +export GITHUB_SHA="0123456789abcdef0123456789abcdef01234567" +export GITHUB_RUN_ID="12345" +jq -n \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg commit "${GITHUB_SHA}" \ + --arg workflow_run_id "${GITHUB_RUN_ID}" \ + '{schema_version: 1, source: "github-ruleset-detail-attestation", repository: $repository, commit: $commit, workflow_run_id: $workflow_run_id, rulesets: []}' \ + >"${attestation_file}" +export RULESET_ATTESTATION_FILE="${attestation_file}" +export RULESET_ATTESTATION_SHA256="$(sha256sum "${attestation_file}" | awk '{print $1}')" +if ! rulesets_from_attestation >/dev/null; then + echo "ERROR: a correctly bound ruleset attestation must be admitted" >&2 + exit 1 +fi + +export RULESET_ATTESTATION_SHA256="not-the-file-digest" +if rulesets_from_attestation >/dev/null; then + echo "ERROR: an attestation with a digest mismatch must be rejected" >&2 + exit 1 +fi + +jq '.workflow_run_id = "different-run"' "${attestation_file}" >"${attestation_file}.wrong-run" +export RULESET_ATTESTATION_FILE="${attestation_file}.wrong-run" +export RULESET_ATTESTATION_SHA256="$(sha256sum "${RULESET_ATTESTATION_FILE}" | awk '{print $1}')" +if rulesets_from_attestation >/dev/null; then + echo "ERROR: an attestation for a different workflow run must be rejected" >&2 + exit 1 +fi + +missing_bypass_dev="$(jq -n '{ + enforcement: "active", + target: "branch", + conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } }, + rules: [{ type: "pull_request" }, { type: "required_status_checks" }] +}')" +assert_rejected "dev ruleset with omitted bypass_actors" rulesets_admit_dev "${missing_bypass_dev}" + +valid_dev="$(jq -n '{ + enforcement: "active", + target: "branch", + bypass_actors: [], + conditions: { ref_name: { include: ["refs/heads/dev"], exclude: [] } }, + rules: [{ type: "pull_request" }, { type: "required_status_checks" }] +}')" +assert_admitted "complete protected dev ruleset" rulesets_admit_dev "${valid_dev}" +dev_with_bypass="$(jq '.bypass_actors = [{ actor_id: 123, actor_type: "User", bypass_mode: "always" }]' <<<"${valid_dev}")" +assert_rejected "dev ruleset with a bypass actor" rulesets_admit_dev "${dev_with_bypass}" + +valid_tag="$(jq -n '{ + enforcement: "active", + target: "tag", + bypass_actors: [], + conditions: { ref_name: { include: ["refs/tags/v*"], exclude: [] } }, + rules: [{ type: "update" }, { type: "deletion" }, { type: "non_fast_forward" }] +}')" +assert_admitted "complete immutable v* tag ruleset" rulesets_admit_tag "${valid_tag}" + +tag_without_bypass="$(jq 'del(.bypass_actors)' <<<"${valid_tag}")" +assert_rejected "tag ruleset with omitted bypass_actors" rulesets_admit_tag "${tag_without_bypass}" +tag_with_bypass="$(jq '.bypass_actors = [{ actor_id: 123, actor_type: "User", bypass_mode: "always" }]' <<<"${valid_tag}")" +assert_rejected "tag ruleset with a bypass actor" rulesets_admit_tag "${tag_with_bypass}" +tag_with_wrong_target="$(jq '.target = "branch"' <<<"${valid_tag}")" +assert_rejected "tag ruleset with the wrong target" rulesets_admit_tag "${tag_with_wrong_target}" +tag_with_wrong_include="$(jq '.conditions.ref_name.include = ["refs/tags/release*"]' <<<"${valid_tag}")" +assert_rejected "tag ruleset without exact v* coverage" rulesets_admit_tag "${tag_with_wrong_include}" +tag_without_update="$(jq '.rules |= map(select(.type != "update"))' <<<"${valid_tag}")" +assert_rejected "tag ruleset without update protection" rulesets_admit_tag "${tag_without_update}" +tag_without_deletion="$(jq '.rules |= map(select(.type != "deletion"))' <<<"${valid_tag}")" +assert_rejected "tag ruleset without deletion protection" rulesets_admit_tag "${tag_without_deletion}" +tag_without_force_push_protection="$(jq '.rules |= map(select(.type != "non_fast_forward"))' <<<"${valid_tag}")" +assert_rejected "tag ruleset without force-push protection" rulesets_admit_tag "${tag_without_force_push_protection}" +tag_with_exclude="$(jq '.conditions.ref_name.exclude = ["refs/tags/v1*"]' <<<"${valid_tag}")" +assert_rejected "tag ruleset with an effective exclusion" rulesets_admit_tag "${tag_with_exclude}" + +echo "OK: release-admission predicate regressions pass" diff --git a/scripts/test-release-guards.sh b/scripts/test-release-guards.sh index fa50117..97abb0d 100755 --- a/scripts/test-release-guards.sh +++ b/scripts/test-release-guards.sh @@ -1,50 +1,186 @@ #!/usr/bin/env bash -# Regression checks for the release and CI trust boundaries. These checks are +# Regression checks for release and CI trust boundaries. These checks are # intentionally static and fast so every gate can prove that workflow changes -# did not reintroduce mutable dependencies or accidental write access. Keep -# the implementation on GitHub-hosted runner core tools; ripgrep is not -# guaranteed to be installed there. +# did not reintroduce mutable dependencies or accidental write access. set -euo pipefail cd "$(dirname "$0")/.." fail=0 +workflow_files=(.github/workflows/*.yml) report() { - printf 'ERROR: %s\n' "$1" >&2 + echo "ERROR: $1" >&2 fail=1 } -check_pinned_action() { - local action="$1" +if [ ! -e "${workflow_files[0]}" ]; then + report "no GitHub workflow files were found" +fi + +check_all_pinned_actions() { local line local ref - - while IFS= read -r line; do + local action_lines=() + mapfile -t action_lines < <(grep -nHE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]*[^@[:space:]]+@[^[:space:]]+' "${workflow_files[@]}" || true) + if [ "${#action_lines[@]}" -eq 0 ]; then + report "no external workflow actions were found to validate" + return + fi + for line in "${action_lines[@]}"; do ref="${line##*@}" + ref="${ref%%[[:space:]]*}" if [[ ! "${ref}" =~ ^[0-9a-f]{40}$ ]]; then - report "${action} must use a full immutable commit SHA: ${line}" + report "every external workflow action must use a full immutable commit SHA: ${line}" fi - done < <(grep -nE "uses: ${action}@" .github/workflows/*.yml) + done } -check_pinned_action "actions/checkout" -check_pinned_action "actions/setup-go" -check_pinned_action "taiki-e/install-action" +check_sibling_refs() { + local sibling=0 + local line + local ref + while IFS= read -r line; do + if [[ "${line}" == *"repository: coderbants/rusty-"* ]]; then + sibling=1 + continue + fi + if (( sibling )) && [[ "${line}" =~ ^[[:space:]]*ref:[[:space:]]*(.+)$ ]]; then + ref="${BASH_REMATCH[1]}" + ref="${ref%%[[:space:]]*}" + if [[ ! "${ref}" =~ ^[0-9a-f]{40}$ ]]; then + report "every sibling checkout must use a full immutable commit SHA: ${line}" + fi + sibling=0 + elif (( sibling )) && [[ "${line}" =~ ^[[:space:]]*-[[:space:]]name: ]]; then + report "sibling checkout is missing an immutable ref" + sibling=0 + fi + done < <(cat "${workflow_files[@]}") + if (( sibling )); then + report "sibling checkout is missing an immutable ref" + fi +} + +check_all_pinned_actions +check_sibling_refs +scripts/test-release-admission.sh if grep -n 'workflow_dispatch' .github/workflows/publish.yml >/dev/null; then report "publish workflow must not expose a manual dispatch path" fi +if ! sed -n '1,18p' .github/workflows/publish.yml | grep -nE '^permissions:|^ contents: read$' >/dev/null; then + report "publish workflow must default to read-only repository permissions" +fi + +if ! grep -n 'verify_release_admission.sh' .github/workflows/publish.yml >/dev/null; then + report "publish workflow must run the fail-closed release-admission gate" +fi + +if ! grep -n 'git merge-base --is-ancestor' scripts/verify_release_admission.sh >/dev/null; then + report "release admission must require the tag commit to be on dev" +fi + +if ! grep -n -- '--workflow .github/workflows/ci.yml' scripts/verify_release_admission.sh >/dev/null; then + report "release admission must query the exact CI workflow" +fi + +if ! grep -n 'refs/tags/v\*' scripts/verify_release_admission.sh >/dev/null; then + report "release admission must require immutable v* tag protection" +fi + +if ! grep -n 'environment:' .github/workflows/publish.yml >/dev/null || ! grep -n 'name: release' .github/workflows/publish.yml >/dev/null; then + report "publication must be gated by the protected release environment" +fi + +publish_job="$(awk ' + /^ publish:/ { in_job=1 } + in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ publish:/ { exit } + in_job { print } +' .github/workflows/publish.yml)" +verify_job="$(awk ' + /^ verify:/ { in_job=1 } + in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ verify:/ { exit } + in_job { print } +' .github/workflows/publish.yml)" +admit_job="$(awk ' + /^ admit:/ { in_job=1 } + in_job && /^ [A-Za-z0-9_-]+:/ && $0 !~ /^ admit:/ { exit } + in_job { print } +' .github/workflows/publish.yml)" + +if [[ "${verify_job}" == *"contents: write"* || "${verify_job}" == *"CARGO_REGISTRY_TOKEN"* ]]; then + report "secretless verification job must not receive write permission or registry credentials" +fi + +if [[ "${admit_job}" != *"release-admission"* || + "${admit_job}" != *"RULESET_ADMISSION_TOKEN"* || + "${admit_job}" != *"gh api"* || + "${admit_job}" != *"rulesets"* || + "${admit_job}" != *"actions/upload-artifact@"* ]]; then + report "ruleset admission must use the protected environment, dedicated token, authoritative API, and immutable artifact upload" +fi + +if [[ "${admit_job}" == *"actions/checkout@"* || + "${admit_job}" == *"cargo test"* || + "${admit_job}" == *"cargo build"* || + "${admit_job}" == *"cargo clippy"* || + "${admit_job}" == *"CARGO_REGISTRY_TOKEN"* || + "${admit_job}" == *"contents: write"* ]]; then + report "ruleset admission must not execute repository code or receive build/repository-write credentials" +fi + +if [[ "${verify_job}" != *"needs: admit"* || + "${verify_job}" != *"RULESET_ATTESTATION_FILE"* || + "${verify_job}" != *"RULESET_ATTESTATION_SHA256"* || + "${verify_job}" != *"actions/download-artifact@"* ]]; then + report "read-only verification must consume the exact trusted ruleset attestation artifact" +fi + +if [[ "${verify_job}" == *"RULESET_ADMISSION_TOKEN"* ]]; then + report "read-only verification must not receive the privileged ruleset admission token" +fi + +if grep -nF 'gh api "repos/${GITHUB_REPOSITORY}/rulesets' scripts/verify_release_admission.sh >/dev/null; then + report "read-only release verification must not fetch repository rulesets directly" +fi + +if ! grep -n 'rulesets_from_attestation' scripts/verify_release_admission.sh >/dev/null || + ! grep -n 'sha256sum' scripts/verify_release_admission.sh >/dev/null || + ! grep -n 'workflow_run_id' scripts/verify_release_admission.sh >/dev/null; then + report "release verification must validate a hash-bound, run-bound ruleset attestation" +fi + +if [[ "${publish_job}" != *"contents: write"* || "${publish_job}" != *"needs: verify"* ]]; then + report "only the artifact publication job may receive contents: write and it must require verification" +fi + +if [[ "${publish_job}" == *"actions/checkout@"* || "${publish_job}" == *"cargo test"* || "${publish_job}" == *"cargo build"* || "${publish_job}" == *"cargo clippy"* ]]; then + report "credential-bearing publication job must not checkout or execute repository verification code" +fi + +if [[ "${publish_job}" != *"actions/download-artifact@"* || "${verify_job}" != *"actions/upload-artifact@"* ]]; then + report "release must exchange a verified artifact between isolated jobs" +fi + +if [[ "${publish_job}" != *"cargo publish --no-verify"* ]]; then + report "publication must use the verified package without running build scripts/tests on the credential-bearing runner" +fi + +if grep -n -- '--clobber' .github/workflows/publish.yml >/dev/null; then + report "release assets must never be silently overwritten" +fi + if awk ' /repository: coderbants\/rusty-/ { sibling=1; next } sibling && /ref: dev/ { bad=1 } sibling && /^ - name:/ { sibling=0 } END { exit bad ? 0 : 1 } ' .github/workflows/ci.yml .github/workflows/publish.yml; then - report "sibling dependency checkouts must use immutable commit refs" + report "sibling dependency checkouts must not use a branch ref" fi if ! grep -n 'git clone --quiet --no-tags' .github/workflows/ci.yml >/dev/null; then @@ -75,7 +211,7 @@ if ! grep -nE 'uses: actions/(upload|download)-artifact@[0-9a-f]{40}' .github/wo report "coverage must exchange its report through immutable artifact actions" fi -if grep -nE 'x-access-token:|git (remote set-url|push).*(GH_TOKEN|\$\{GH_TOKEN\})|cargo publish.*--token' .github/workflows/ci.yml .github/workflows/publish.yml >/dev/null; then +if grep -nE 'x-access-token:|git (remote set-url|push)|cargo publish.*--token' .github/workflows/ci.yml .github/workflows/publish.yml >/dev/null; then report "workflow credentials must not be embedded in URLs or command-line arguments" fi @@ -83,6 +219,10 @@ if ! grep -n 'gh api --method PUT' .github/workflows/ci.yml >/dev/null; then report "coverage badge updates must use the GitHub API credential channel" fi +if ! bash -n scripts/verify_release_admission.sh; then + report "release admission script must pass bash syntax validation" +fi + if ! scripts/verify_upstream_version.sh >/dev/null; then report "the tracked upstream version must pass the release-version guard" fi @@ -91,7 +231,7 @@ if scripts/verify_upstream_version.sh not-a-release-tag >/dev/null 2>&1; then report "the release-version guard must reject non-v tags" fi -if [ "${fail}" -ne 0 ]; then +if [ "$fail" -ne 0 ]; then exit 1 fi diff --git a/scripts/verify_docs_projection.sh b/scripts/verify_docs_projection.sh new file mode 100644 index 0000000..9aba880 --- /dev/null +++ b/scripts/verify_docs_projection.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +test -f docs/projection.yaml +test -f docs/src/lib.rs +test -f src/table.rs + +grep -Fxq ' audience: user' docs/projection.yaml +grep -Fxq ' source: docs/src/lib.rs' docs/projection.yaml +grep -Fxq ' module: src/table.rs' docs/projection.yaml +grep -Fxq ' destination: rusty-bubbles' docs/projection.yaml +grep -Fxq '//! ' src/table.rs +grep -Fxq '//! ' src/table.rs +grep -Fxq '//! ' docs/src/lib.rs +grep -Fxq '//! ' docs/src/lib.rs +grep -Fq 'rusty_bubbles::table' docs/src/lib.rs + +build_dir="${CARGO_BUILD_BUILD_DIR:-${CARGO_TARGET_DIR:-target}}" +BUI012_DOC_RLIB="$( + cargo build --lib --message-format=json-render-diagnostics | + node -e ' + const fs = require("node:fs"); + let rlib = ""; + for (const line of fs.readFileSync(0, "utf8").split(/\r?\n/u)) { + if (line.trim() === "") continue; + let message; + try { message = JSON.parse(line); } catch { continue; } + if (message.reason !== "compiler-artifact") continue; + if (message.target?.name !== "rusty_bubbles") continue; + if (!message.target?.kind?.includes("lib")) continue; + rlib = message.filenames?.find((name) => name.endsWith(".rlib")) ?? rlib; + } + if (rlib === "") process.exit(1); + process.stdout.write(rlib); + ' +)" +test -n "$BUI012_DOC_RLIB" +rustdoc --test docs/src/lib.rs \ + --edition=2021 \ + --extern "rusty_bubbles=$BUI012_DOC_RLIB" \ + -L "dependency=$build_dir/debug/deps" + +echo "OK: docs projection is mapped and compiled" diff --git a/scripts/verify_release_admission.sh b/scripts/verify_release_admission.sh new file mode 100755 index 0000000..5093b0c --- /dev/null +++ b/scripts/verify_release_admission.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +# Fail-closed admission checks for the tag-triggered publication workflow. +# This script runs in the secretless verification job with a read-only +# GitHub token and consumes a hash-bound trusted ruleset attestation. The +# privileged admission job is the only stage that fetches ruleset details. +# Publication is not admitted from an arbitrary tag, an unverified commit, or +# an unprotected ref. + +set -euo pipefail + +rulesets_admit_dev() { + jq -e ' + any(.[]; + .enforcement == "active" + and .target == "branch" + and (has("bypass_actors") and (.bypass_actors | type == "array" and length == 0)) + and (has("conditions") and (.conditions | type == "object" and has("ref_name"))) + and (.conditions.ref_name | type == "object" and has("include") and has("exclude")) + and (.conditions.ref_name.include | type == "array" and any(.[]; . == "refs/heads/dev" or . == "~DEFAULT_BRANCH")) + and (.conditions.ref_name.exclude | type == "array" and length == 0) + and (.rules | type == "array" and any(.[]; .type == "pull_request")) + and (.rules | type == "array" and any(.[]; .type == "required_status_checks")) + ) + ' >/dev/null +} + +rulesets_admit_tag() { + jq -e ' + any(.[]; + .enforcement == "active" + and .target == "tag" + and (has("bypass_actors") and (.bypass_actors | type == "array" and length == 0)) + and (has("conditions") and (.conditions | type == "object" and has("ref_name"))) + and (.conditions.ref_name | type == "object" and has("include") and has("exclude")) + and (.conditions.ref_name.include | type == "array" and any(.[]; . == "refs/tags/v*")) + and (.conditions.ref_name.exclude | type == "array" and length == 0) + and (.rules | type == "array" and any(.[]; .type == "update")) + and (.rules | type == "array" and any(.[]; .type == "deletion")) + and (.rules | type == "array" and any(.[]; .type == "non_fast_forward")) + ) + ' >/dev/null +} + +rulesets_from_attestation() { + : "${RULESET_ATTESTATION_FILE:?RULESET_ATTESTATION_FILE is required}" + : "${RULESET_ATTESTATION_SHA256:?RULESET_ATTESTATION_SHA256 is required}" + if [[ ! -f "${RULESET_ATTESTATION_FILE}" ]]; then + echo "ERROR: trusted ruleset attestation is missing; refusing publication" >&2 + return 1 + fi + + local actual_sha256 + actual_sha256="$(sha256sum "${RULESET_ATTESTATION_FILE}" | awk '{print $1}')" + if [[ "${actual_sha256}" != "${RULESET_ATTESTATION_SHA256}" ]]; then + echo "ERROR: trusted ruleset attestation digest mismatch; refusing publication" >&2 + return 1 + fi + + if ! jq -e \ + --arg repository "${GITHUB_REPOSITORY}" \ + --arg commit "${GITHUB_SHA}" \ + --arg workflow_run_id "${GITHUB_RUN_ID}" ' + .schema_version == 1 + and .source == "github-ruleset-detail-attestation" + and .repository == $repository + and .commit == $commit + and .workflow_run_id == $workflow_run_id + and (.rulesets | type == "array") + ' "${RULESET_ATTESTATION_FILE}" >/dev/null; then + echo "ERROR: trusted ruleset attestation is not bound to this repository/run/commit; refusing publication" >&2 + return 1 + fi + + jq -c '.rulesets' "${RULESET_ATTESTATION_FILE}" +} + +main() { + : "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + : "${GITHUB_SHA:?GITHUB_SHA is required}" + : "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" + : "${GH_TOKEN:?GH_TOKEN is required}" + : "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}" + + if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9] ]]; then + echo "ERROR: release admission requires a semantic v* tag, got ${GITHUB_REF_NAME}" >&2 + exit 1 + fi + + git fetch --quiet origin dev --no-tags + if ! git merge-base --is-ancestor "${GITHUB_SHA}" FETCH_HEAD; then + echo "ERROR: tag commit ${GITHUB_SHA} is not an ancestor of origin/dev" >&2 + exit 1 + fi + + ci_runs="$(gh run list --repo "${GITHUB_REPOSITORY}" --workflow .github/workflows/ci.yml --commit "${GITHUB_SHA}" --limit 100 --json databaseId,headSha,status,conclusion,event,headBranch)" + + ci_run_id="$(jq -r --arg sha "${GITHUB_SHA}" ' + map(select( + .headSha == $sha + and .status == "completed" + and .conclusion == "success" + and .event == "push" + and .headBranch == "dev" + )) + | sort_by(.databaseId) + | last + | .databaseId // empty + ' <<<"${ci_runs}")" + + if [[ -z "${ci_run_id}" ]]; then + echo "ERROR: no successful exact-SHA CI push run on dev admits ${GITHUB_SHA}" >&2 + exit 1 + fi + + if ! rulesets="$(rulesets_from_attestation)"; then + echo "ERROR: could not validate trusted repository ref-protection attestation; refusing publication" >&2 + exit 1 + fi + + if ! rulesets_admit_dev <<<"${rulesets}"; then + echo "ERROR: no active no-bypass dev protection ruleset with pull-request, status-check, target, and exclude rules" >&2 + exit 1 + fi + + if ! rulesets_admit_tag <<<"${rulesets}"; then + echo "ERROR: no active no-bypass immutable v* tag ruleset with target, update, deletion, force-push, and exclude protections" >&2 + exit 1 + fi + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "ci_run_id=${ci_run_id}" >>"${GITHUB_OUTPUT}" + fi + + echo "OK: release tag ${GITHUB_REF_NAME} at ${GITHUB_SHA} admitted by dev CI run ${ci_run_id}, trusted ruleset attestation, and protected refs" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/src/lib.rs b/src/lib.rs index accfb49..b40c56c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ //! Cleanroom Rust port of upstream Go source file: `bubbles.go` //! Upstream Target Tag / Version: `v2.1.0` //! -//! +//! //! # Bubbles //! //! Components for Bubble Tea applications. These components are used in @@ -23,7 +23,14 @@ //! - [`textarea`] — multi-line text area component //! - [`filepicker`] — file picker component //! -//! +//! Each component exposes typed state and deterministic model/update/view +//! operations. Components remain independent so applications can compose them +//! without taking ownership of a downstream event loop. +//! +//! +//! Internal maintainer note: this root module is the public facade. Keep the +//! module list and the user-facing documentation projection synchronized when +//! adding or changing a component. pub mod cursor; pub mod filepicker; diff --git a/src/table.rs b/src/table.rs index c42fd49..845d49c 100644 --- a/src/table.rs +++ b/src/table.rs @@ -7,6 +7,33 @@ //! A simple table component for Bubble Tea applications. //! +//! +//! +//! # Table +//! +//! `table::Model` renders a typed collection of rows against declared +//! columns. Rows shorter than the column list render empty cells; surplus row +//! values are ignored, so input shape cannot change the rendered table or +//! panic the renderer. +//! +//! Configure dimensions with `with_width` and `with_height`, provide +//! `Column` definitions with `with_columns`, and provide `Row` values +//! with `with_rows`. Cursor movement and viewport updates saturate at their +//! valid bounds, including zero-height and maximum-input cases. +//! +//! ```rust +//! use rusty_bubbles::table::{self, Column}; +//! +//! let model = table::new(vec![ +//! table::with_width(16), +//! table::with_columns(&[Column { title: "Name".into(), width: 8 }]), +//! table::with_rows(&[vec!["Bubbles".into()]]), +//! ]); +//! assert_eq!(model.selected_row().unwrap()[0], "Bubbles"); +//! assert!(model.view().contains("Bubbles")); +//! ``` +//! + use crate::help; use crate::key::{self, Binding}; use crate::viewport; @@ -216,11 +243,24 @@ pub fn with_rows(rows: &[Row]) -> Option { }) } -/// WithHeight sets the height of the table. +/// WithHeight sets the outer height of the table. +/// +/// The header consumes part of the requested height. Values smaller than the +/// rendered header clamp the content viewport to zero instead of wrapping +/// through usize arithmetic. +/// +/// # Examples +/// +/// ``` +/// use rusty_bubbles::table; +/// +/// let model = table::new(vec![table::with_height(1)]); +/// assert_eq!(model.height(), 0); +/// ``` pub fn with_height(h: usize) -> Option { Box::new(move |m: &mut Model| { let hh = rusty_lipgloss::size::height(&m.headers_view()); - m.viewport.set_height(h - hh); + m.viewport.set_height(h.saturating_sub(hh)); }) } @@ -321,6 +361,15 @@ impl Model { /// UpdateViewport updates the list content based on the previously /// defined columns and rows. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.update_viewport(); + /// ``` pub fn update_viewport(&mut self) { let mut rendered_rows: Vec = Vec::with_capacity(self.rows.len()); @@ -334,7 +383,7 @@ impl Model { self.cursor, ); self.end = clamp( - self.cursor + self.viewport.height(), + self.cursor.saturating_add(self.viewport.height()), self.cursor, self.rows.len(), ); @@ -392,10 +441,23 @@ impl Model { self.update_viewport(); } - /// SetHeight sets the height of the viewport of the table. + /// SetHeight sets the outer height of the table. + /// + /// The header consumes part of the requested height. Values smaller than + /// the rendered header clamp the content viewport to zero instead of + /// wrapping through usize arithmetic. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.set_height(1); + /// ``` pub fn set_height(&mut self, h: usize) { let hh = rusty_lipgloss::size::height(&self.headers_view()); - self.viewport.set_height(h - hh); + self.viewport.set_height(h.saturating_sub(hh)); self.update_viewport(); } @@ -422,6 +484,15 @@ impl Model { /// MoveUp moves the selection up by any number of rows. /// It can not go above the first row. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.move_up(usize::MAX); + /// ``` pub fn move_up(&mut self, n: usize) { // Upstream uses signed ints and clamps to 0; saturating subtraction // mirrors that without overflowing. @@ -431,13 +502,23 @@ impl Model { self.rows.len().saturating_sub(1), ); + if self.viewport.height() == 0 { + self.viewport.set_y_offset(0); + self.update_viewport(); + return; + } + let mut offset = self.viewport.y_offset(); if self.start == 0 { offset = clamp(offset, 0, self.cursor); } else if self.start < self.viewport.height() { - offset = clamp(clamp(offset + n, 0, self.cursor), 0, self.viewport.height()); + offset = clamp( + clamp(offset.saturating_add(n), 0, self.cursor), + 0, + self.viewport.height(), + ); } else if offset >= 1 { - offset = clamp(offset + n, 1, self.viewport.height()); + offset = clamp(offset.saturating_add(n), 1, self.viewport.height()); } self.viewport.set_y_offset(offset); self.update_viewport(); @@ -445,19 +526,37 @@ impl Model { /// MoveDown moves the selection down by any number of rows. /// It can not go below the last row. + /// + /// # Examples + /// + /// ```no_run + /// use rusty_bubbles::table; + /// + /// let mut model = table::new(vec![]); + /// model.move_down(usize::MAX); + /// ``` pub fn move_down(&mut self, n: usize) { - self.cursor = clamp(self.cursor + n, 0, self.rows.len().saturating_sub(1)); + self.cursor = clamp( + self.cursor.saturating_add(n), + 0, + self.rows.len().saturating_sub(1), + ); self.update_viewport(); + if self.viewport.height() == 0 { + self.viewport.set_y_offset(0); + return; + } + let mut offset = self.viewport.y_offset(); if self.end == self.rows.len() && offset > 0 { - offset = clamp(offset - n, 1, self.viewport.height()); + offset = clamp(offset.saturating_sub(n), 1, self.viewport.height()); } else if self.cursor > (self.end - self.start) / 2 && offset > 0 { - offset = clamp(offset - n, 1, self.cursor); + offset = clamp(offset.saturating_sub(n), 1, self.cursor); } else if offset > 1 { // no-op - } else if self.cursor > offset + self.viewport.height() - 1 { - offset = clamp(offset + 1, 0, 1); + } else if self.cursor > offset.saturating_add(self.viewport.height().saturating_sub(1)) { + offset = clamp(offset.saturating_add(1), 0, 1); } self.viewport.set_y_offset(offset); } @@ -509,16 +608,16 @@ impl Model { fn render_row(&self, r: usize) -> String { let mut s: Vec = Vec::with_capacity(self.cols.len()); - for (i, value) in self.rows[r].iter().enumerate() { - if self.cols[i].width == 0 { + for (i, col) in self.cols.iter().enumerate() { + if col.width == 0 { continue; } let style = rusty_lipgloss::new_style() - .width(self.cols[i].width) - .max_width(self.cols[i].width) + .width(col.width) + .max_width(col.width) .inline(true); - let rendered_cell = - style.render(&rusty_x_ansi::truncate(value, self.cols[i].width, "…")); + let value = self.rows[r].get(i).map(String::as_str).unwrap_or(""); + let rendered_cell = style.render(&rusty_x_ansi::truncate(value, col.width, "…")); s.push(self.styles.cell.clone().render(&rendered_cell)); } diff --git a/tests/table_test.rs b/tests/table_test.rs index 61d992a..ec8cc21 100644 --- a/tests/table_test.rs +++ b/tests/table_test.rs @@ -428,13 +428,10 @@ fn test_cursor_navigation() { assert_eq!(t.cursor(), 3, "want 3, got {}", t.cursor()); // MoveUp with overflow: the Go test moves up 5 rows from row 3, which - // clamps to row 0. NOTE: the Rust `move_up` computes `cursor - n` with - // `usize` arithmetic and panics on underflow when `n > cursor`, so we - // move up only as far as the cursor (the clamp-to-top behavior is the - // same as Go's for `n >= cursor`). + // clamps to row 0. let mut t = table::new(vec![table::with_columns(&cols), table::with_rows(&rows4)]); t.set_cursor(3); - t.move_up(3); + t.move_up(5); assert_eq!(t.cursor(), 0, "want 0, got {}", t.cursor()); // Blur does not stop movement @@ -665,3 +662,127 @@ fn test_table_options_and_navigation_update() { m.focus(); assert!(m.focused()); } + +#[test] +fn test_ragged_rows_render_against_declared_columns() { + let columns = vec![ + table::Column { + title: "one".to_string(), + width: 3, + }, + table::Column { + title: "two".to_string(), + width: 3, + }, + table::Column { + title: "three".to_string(), + width: 3, + }, + ]; + let rows = vec![ + vec!["a".to_string()], + vec![ + "b".to_string(), + "c".to_string(), + "d".to_string(), + "surplus".to_string(), + ], + ]; + + let m = table::new(vec![ + table::with_width(12), + table::with_columns(&columns), + table::with_rows(&rows), + table::with_styles(plain_styles()), + ]); + + assert_eq!(ansi_strip(&rendered_row(&m)), "a "); + + let rendered = ansi_strip(&m.view()); + assert!(rendered.contains('b')); + assert!(rendered.contains('c')); + assert!(rendered.contains('d')); + assert!(!rendered.contains("surplus")); +} + +#[test] +fn test_height_is_saturating_at_the_boundary() { + let columns = vec![table::Column { + title: "header".to_string(), + width: 6, + }]; + + let mut before_columns = table::new(vec![ + table::with_height(0), + table::with_columns(&columns), + table::with_styles(plain_styles()), + ]); + let after_columns = table::new(vec![ + table::with_columns(&columns), + table::with_height(0), + table::with_styles(plain_styles()), + ]); + + assert_eq!(before_columns.height(), 0); + assert_eq!(after_columns.height(), 0); + before_columns.set_height(0); + assert_eq!(before_columns.height(), 0); +} +#[test] +fn test_navigation_saturates_at_cursor_bounds() { + let rows = vec![ + vec!["one".to_string()], + vec!["two".to_string()], + vec!["three".to_string()], + ]; + let mut m = table::new(vec![ + table::with_columns(&[table::Column { + title: "value".to_string(), + width: 5, + }]), + table::with_rows(&rows), + ]); + + m.set_cursor(1); + m.move_up(usize::MAX); + assert_eq!(m.cursor(), 0); + m.move_down(usize::MAX); + assert_eq!(m.cursor(), rows.len() - 1); + + let mut maximum_viewport = table::new(vec![ + table::with_columns(&[table::Column { + title: "value".to_string(), + width: 5, + }]), + table::with_rows(&rows), + table::with_height(usize::MAX), + ]); + maximum_viewport.move_down(2); + assert_eq!(maximum_viewport.cursor(), rows.len() - 1); +} + +#[test] +fn test_zero_height_navigation_is_safe() { + let rows = vec![ + vec!["one".to_string()], + vec!["two".to_string()], + vec!["three".to_string()], + ]; + let mut m = table::new(vec![ + table::with_columns(&[table::Column { + title: "value".to_string(), + width: 5, + }]), + table::with_rows(&rows), + table::with_height(0), + ]); + + m.move_down(usize::MAX); + m.move_up(usize::MAX); + m.goto_bottom(); + m.goto_top(); + + assert_eq!(m.height(), 0); + assert_eq!(m.cursor(), 0); + assert_eq!(m.view().lines().count(), 1); +}