From 6bfa0ef4634bf84f5deef5eba787df59f76f9067 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 11:23:30 +0800 Subject: [PATCH 01/17] guard: add version policy --- .github/scripts/release/r2/publish.sh | 7 + .github/workflows/guard.yml | 15 +- .github/workflows/release-beta.yml | 15 ++ .github/workflows/release-stable.yml | 15 ++ .runseal/hooks/commit-msg | 39 +++++ .runseal/hooks/pre-commit | 7 + .runseal/wrappers/guard.seal | 214 +++++++++++++++++++++++ .runseal/wrappers/init.seal | 10 +- Cargo.lock | 74 +++++++- app/Cargo.toml | 3 +- app/src/core/tool/hash/mod.rs | 73 ++++++++ app/src/core/tool/help/hash_version.rs | 59 +++++++ app/src/core/tool/help/json.rs | 17 ++ app/src/core/tool/help/mod.rs | 9 + app/src/core/tool/json.rs | 41 ++++- app/src/core/tool/mod.rs | 4 + app/src/core/tool/version/mod.rs | 60 +++++++ app/tests/internal_tool.rs | 16 ++ app/tests/internal_tool/hash_version.rs | 103 +++++++++++ app/tests/operator.rs | 2 + app/tests/operator/guard.rs | 220 ++++++++++++++++++++++++ app/tests/operator/init.rs | 23 ++- 22 files changed, 1003 insertions(+), 23 deletions(-) create mode 100644 .runseal/hooks/commit-msg create mode 100644 .runseal/hooks/pre-commit create mode 100644 .runseal/wrappers/guard.seal create mode 100644 app/src/core/tool/hash/mod.rs create mode 100644 app/src/core/tool/help/hash_version.rs create mode 100644 app/src/core/tool/version/mod.rs create mode 100644 app/tests/internal_tool/hash_version.rs create mode 100644 app/tests/operator/guard.rs diff --git a/.github/scripts/release/r2/publish.sh b/.github/scripts/release/r2/publish.sh index 0262ea2..1bb23e3 100755 --- a/.github/scripts/release/r2/publish.sh +++ b/.github/scripts/release/r2/publish.sh @@ -117,6 +117,10 @@ metadata = { }, } +guard_hash = env.get("GUARD_VERSION_HASH", "") +if guard_hash: + metadata["guard"] = {"version": {"hash": guard_hash}} + if env["RELEASE_CHANNEL"] == "beta": match = re.match(r"^v?(\d+\.\d+\.\d+)-beta\.([1-9][0-9]*)$", env["RELEASE_VERSION"]) if not match: @@ -131,6 +135,9 @@ if env["RELEASE_CHANNEL"] == "beta": metadata["betaNumber"] = beta_number metadata["betaVersion"] = env["RELEASE_VERSION"] metadata["stateSource"] = env.get("STATE_SOURCE") or "workflow input" +else: + metadata["stableVersion"] = env["RELEASE_VERSION"] + metadata["stateSource"] = env.get("STATE_SOURCE") or "workflow input" Path(env["METADATA_PATH"]).write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") PY diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index a55e9fc..c8216a0 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -30,15 +30,6 @@ jobs: components: rustfmt, clippy - name: Format - run: cargo fmt --all --check - - - name: Clippy - run: cargo clippy --locked --workspace --all-targets -- -D warnings - - - name: Test - run: cargo test --locked --workspace - - - name: Install Flavor if: runner.os != 'Windows' run: | set -euo pipefail @@ -55,5 +46,7 @@ jobs: & (Join-Path $env:RUNNER_TEMP 'manage-flavor.ps1') install --bin-dir $binDir $binDir | Out-File -Append -Encoding utf8 $env:GITHUB_PATH - - name: Flavor self-check - run: flavor check --root . --config flavor.toml + - name: Guard + run: | + set -euo pipefail + PATH="$PWD/target/debug:$PATH" cargo run --quiet --locked -p runseal -- :guard diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index ad4f385..a407bf2 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -51,6 +51,11 @@ jobs: id: beta run: python3 .github/scripts/release/metadata/beta.py + - name: Guard version policy + run: | + set -euo pipefail + PATH="$PWD/target/debug:$PATH" cargo run --quiet --locked -p runseal -- :guard version-check + verify: needs: metadata runs-on: ubuntu-latest @@ -157,6 +162,14 @@ jobs: path: dist/${{ needs.metadata.outputs.release_version }} merge-multiple: true + - name: Resolve guard version hash + id: guard_hash + run: | + set -euo pipefail + PATH="$PWD/target/debug:$PATH" + hash=$(cargo run --quiet --locked -p runseal -- :guard version-hash) + echo "value=$hash" >> "$GITHUB_OUTPUT" + - name: Create checksums run: sh .github/scripts/release/assets/checksums.sh "${RELEASE_VERSION}" "${RELEASE_ROOT}" @@ -168,6 +181,8 @@ jobs: - name: Publish to R2 id: r2 + env: + GUARD_VERSION_HASH: ${{ steps.guard_hash.outputs.value }} run: bash .github/scripts/release/r2/publish.sh - name: Verify R2 publish diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 442daf4..3c78b26 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -50,6 +50,11 @@ jobs: id: stable run: python3 .github/scripts/release/metadata/stable.py + - name: Guard version policy + run: | + set -euo pipefail + PATH="$PWD/target/debug:$PATH" cargo run --quiet --locked -p runseal -- :guard version-check + verify: needs: metadata runs-on: ubuntu-latest @@ -155,6 +160,14 @@ jobs: path: dist/${{ needs.metadata.outputs.release_version }} merge-multiple: true + - name: Resolve guard version hash + id: guard_hash + run: | + set -euo pipefail + PATH="$PWD/target/debug:$PATH" + hash=$(cargo run --quiet --locked -p runseal -- :guard version-hash) + echo "value=$hash" >> "$GITHUB_OUTPUT" + - name: Create checksums run: sh .github/scripts/release/assets/checksums.sh "${RELEASE_VERSION}" "${RELEASE_ROOT}" @@ -166,6 +179,8 @@ jobs: - name: Publish to R2 id: r2 + env: + GUARD_VERSION_HASH: ${{ steps.guard_hash.outputs.value }} run: bash .github/scripts/release/r2/publish.sh - name: Verify R2 publish diff --git a/.runseal/hooks/commit-msg b/.runseal/hooks/commit-msg new file mode 100644 index 0000000..10f9150 --- /dev/null +++ b/.runseal/hooks/commit-msg @@ -0,0 +1,39 @@ +#!/usr/bin/env sh +# runseal init hook: generated by runseal :init +set -eu + +if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then + echo "commit message file not found" >&2 + exit 1 +fi + +subject=$(sed -n ' +/^[[:space:]]*#/d +/[^[:space:]]/ { + s/^[[:space:]]*// + s/[[:space:]]*$// + p + q +} +' "$1") + +if [ -z "$subject" ]; then + echo "commit subject is empty" >&2 + exit 1 +fi + +case "$subject" in + "Merge "*|"Revert "*|"fixup!"*|"squash!"*) + exit 0 + ;; +esac + +if ! printf '%s\n' "$subject" | grep -Eq '^[a-z][a-z0-9-]*(/[a-z0-9-]+)*: [^[:space:]].*'; then + echo "commit subject must look like ': '" >&2 + echo "actual: $subject" >&2 + exit 1 +fi + +if [ "${#subject}" -gt 72 ]; then + echo "warning: commit subject is ${#subject} chars; prefer <= 72" >&2 +fi diff --git a/.runseal/hooks/pre-commit b/.runseal/hooks/pre-commit new file mode 100644 index 0000000..1521564 --- /dev/null +++ b/.runseal/hooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env sh +# runseal init hook: generated by runseal :init +set -eu +root=$(git rev-parse --show-toplevel) +cd "$root" + +runseal :guard diff --git a/.runseal/wrappers/guard.seal b/.runseal/wrappers/guard.seal new file mode 100644 index 0000000..062caf6 --- /dev/null +++ b/.runseal/wrappers/guard.seal @@ -0,0 +1,214 @@ +print() { + printf '%s\n' "$1" +} + +error() { + printf '%s\n' "$1" >&2 +} + +fail() { + error "$1" + exit 1 +} + +usage() { + print "Usage: runseal :guard [version-check|version-hash]" + print "" + print "Run repository guard checks or one explicit version-policy helper." + print "" + print "Commands:" + print " version-check validate version policy against stable metadata" + print " version-hash print the current guard.version.hash value" +} + +require_tool() { + exists=$(runseal @tool process exists "$1") + if [ "$exists" = true ]; then + else + fail "guard: missing required tool: $1" + fi +} + +mode=full +if [ "$#" -gt 0 ]; then + case "$1" in + version-check) + mode=version-check + shift + ;; + version-hash) + mode=version-hash + shift + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + fail "guard: unknown command: $1" + ;; + esac +fi + +if [ "$#" -gt 0 ]; then + fail "guard: unexpected arguments" +fi + +if [ "$mode" = version-hash ]; then + runseal @tool hash tree app/tests + exit 0 +fi + +require_tool git +require_tool cargo +require_tool curl +require_tool cat +require_tool runseal + +public_url="${RUNSEAL_RELEASES_PUBLIC_URL:-}" +if [ -z "$public_url" ]; then + public_url="https://releases.runseal.perish.uk" +fi + +metadata_url="${RUNSEAL_STABLE_METADATA_URL:-}" +if [ -z "$metadata_url" ]; then + metadata_url="$public_url/stable/latest/metadata.json" +fi + +tmp_dir="${RUNSEAL_REPO_TMP_DIR:-.local/tmp}" +if [ -n "${RUNNER_TEMP:-}" ]; then + tmp_dir="${RUNNER_TEMP}/runseal-guard" +fi +runseal @tool fs mkdir "$tmp_dir" 700 +metadata_file="$tmp_dir/runseal-guard-stable-metadata.json" +cargo_metadata=$(cargo metadata --no-deps --format-version 1) +current_version=$(runseal @tool json get "$cargo_metadata" '.packages[0].version') +current_hash=$(runseal @tool hash tree app/tests) +status=$(curl -sS -o "$metadata_file" -w "%{http_code}" "$metadata_url?version=$current_version") +if [ "$status" = 404 ]; then + print "guard version policy: no stable metadata; skipping" + if [ "$mode" = version-check ]; then + exit 0 + fi +else + if [ "$status" = 200 ]; then + else + fail "guard version policy: failed to fetch stable metadata: HTTP $status" + fi + + metadata=$(cat "$metadata_file") + has_prior_hash=$(runseal @tool json has "$metadata" .guard.version.hash) + if [ "$has_prior_hash" = true ]; then + prior_hash=$(runseal @tool json get "$metadata" .guard.version.hash) + else + prior_hash= + fi + if [ -z "$prior_hash" ]; then + print "guard version policy: stable metadata has no guard.version.hash; skipping" + if [ "$mode" = version-check ]; then + exit 0 + fi + else + has_stable_version=$(runseal @tool json has "$metadata" .stableVersion) + if [ "$has_stable_version" = true ]; then + prior_version=$(runseal @tool json get "$metadata" .stableVersion) + else + prior_version= + fi + if [ -z "$prior_version" ]; then + has_release_version=$(runseal @tool json has "$metadata" .releaseVersion) + if [ "$has_release_version" = true ]; then + prior_version=$(runseal @tool json get "$metadata" .releaseVersion) + fi + fi + if [ -z "$prior_version" ]; then + fail "guard version policy: stable metadata is missing stableVersion/releaseVersion" + fi + + current_order=$(runseal @tool version compare "$current_version" "$prior_version") + prior_major=$(runseal @tool version part "$prior_version" major) + prior_minor=$(runseal @tool version part "$prior_version" minor) + current_major=$(runseal @tool version part "$current_version" major) + current_minor=$(runseal @tool version part "$current_version" minor) + same_minor_lineage=false + + if [ "$current_order" = lt ]; then + fail "guard version policy: version regressed below prior stable $prior_version" + fi + if [ "$current_order" = eq ]; then + fail "guard version policy: version matches prior stable $prior_version" + fi + if [ "$current_major" = "$prior_major" ]; then + if [ "$current_minor" = "$prior_minor" ]; then + same_minor_lineage=true + fi + fi + + if [ "$current_hash" = "$prior_hash" ]; then + if [ "$same_minor_lineage" = false ]; then + fail "guard version policy: unchanged guard.version.hash requires a patch-only bump above $prior_version" + fi + print "guard version policy: hash unchanged -> patch bump ok ($prior_version -> $current_version)" + else + if [ "$same_minor_lineage" = true ]; then + fail "guard version policy: changed guard.version.hash requires a minor-or-higher bump above $prior_version" + fi + print "guard version policy: hash changed -> minor-or-higher bump ok ($prior_version -> $current_version)" + fi + + if [ "$mode" = version-check ]; then + exit 0 + fi + fi +fi + +require_tool flavor +require_tool sh +require_tool bash +require_tool python3 + +print "==> cargo fmt" +cargo fmt --all --check + +print "==> cargo clippy" +cargo clippy --locked --workspace --all-targets -- -D warnings + +print "==> cargo test" +cargo test --locked --workspace + +print "==> seal wrappers" +cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/cloudflare.seal +cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/guard.seal +cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/init.seal +cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/pr.seal +cargo run --quiet --locked -p runseal -- @transpile --input-lang=seal --output-lang=sealir .runseal/wrappers/release.seal + +print "==> flavor self-check" +flavor check --root . --config flavor.toml + +print "==> shell syntax" +sh -n manage.sh +sh -n .github/scripts/release/assets/checksums.sh +sh -n .github/scripts/release/assets/package.sh +sh -n .github/scripts/release/assets/verify.sh +sh -n .github/scripts/release/github/cleanup-artifacts.sh +bash -n .github/scripts/release/r2/check.sh +bash -n .github/scripts/release/r2/publish.sh +bash -n .github/scripts/release/r2/summary.sh +bash -n .github/scripts/release/r2/verify.sh +sh -n .github/scripts/release/smoke/smoke.sh + +print "==> python syntax" +python3 -m py_compile .github/scripts/release/metadata/beta.py +python3 -m py_compile .github/scripts/release/metadata/stable.py + +has_pwsh=$(runseal @tool process exists pwsh) +if [ "$has_pwsh" = true ]; then + print "==> PowerShell syntax" + pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw 'manage.ps1')) | Out-Null" + pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/assets/package.ps1')) | Out-Null" + pwsh -NoProfile -NonInteractive -Command "[scriptblock]::Create((Get-Content -Raw '.github/scripts/release/smoke/smoke.ps1')) | Out-Null" +else + print "==> PowerShell syntax" + print "skip: pwsh not found" +fi diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal index cfc7cfb..904da5e 100644 --- a/.runseal/wrappers/init.seal +++ b/.runseal/wrappers/init.seal @@ -93,9 +93,10 @@ require_tool runseal require_tool flavor require_tool sh require_tool bash +require_tool cp require_tool sed require_tool grep -print "ok: git, python3, cargo, runseal, flavor, sh, bash, sed, grep" +print "ok: git, python3, cargo, runseal, flavor, sh, bash, cp, sed, grep" print "==> checking repository entrypoints" require_path Cargo.toml @@ -104,7 +105,10 @@ require_path flavor.toml require_path manage.sh require_path manage.ps1 require_path runseal.toml +require_path .runseal/hooks/pre-commit +require_path .runseal/hooks/commit-msg require_path .runseal/wrappers/cloudflare.seal +require_path .runseal/wrappers/guard.seal require_path .runseal/wrappers/init.seal require_path .runseal/wrappers/pr.seal require_path .runseal/wrappers/release.seal @@ -123,13 +127,13 @@ runseal @tool fs mkdir "$hooks_dir" 700 prepare_hook "$pre_commit" -runseal @tool fs write-base64 "$pre_commit" IyEvdXNyL2Jpbi9lbnYgc2gKIyBydW5zZWFsIGluaXQgaG9vazogZ2VuZXJhdGVkIGJ5IC5ydW5zZWFsL3dyYXBwZXJzL2luaXQuc2VhbApzZXQgLWV1CnJvb3Q9JChnaXQgcmV2LXBhcnNlIC0tc2hvdy10b3BsZXZlbCkKY2QgIiRyb290IgoKZWNobyAiPT0+IGNhcmdvIGZtdCIKY2FyZ28gZm10IC0tYWxsIC0tY2hlY2sKCmVjaG8gIj09PiBjYXJnbyBjbGlwcHkiCmNhcmdvIGNsaXBweSAtLWxvY2tlZCAtLXdvcmtzcGFjZSAtLWFsbC10YXJnZXRzIC0tIC1EIHdhcm5pbmdzCgplY2hvICI9PT4gY2FyZ28gdGVzdCIKY2FyZ28gdGVzdCAtLWxvY2tlZCAtLXdvcmtzcGFjZQoKZWNobyAiPT0+IHNlYWwgd3JhcHBlcnMiCmNhcmdvIHJ1biAtLXF1aWV0IC0tbG9ja2VkIC1wIHJ1bnNlYWwgLS0gQHRyYW5zcGlsZSAtLWlucHV0LWxhbmc9c2VhbCAtLW91dHB1dC1sYW5nPXNlYWxpciAucnVuc2VhbC93cmFwcGVycy9pbml0LnNlYWwgPi9kZXYvbnVsbApjYXJnbyBydW4gLS1xdWlldCAtLWxvY2tlZCAtcCBydW5zZWFsIC0tIEB0cmFuc3BpbGUgLS1pbnB1dC1sYW5nPXNlYWwgLS1vdXRwdXQtbGFuZz1zZWFsaXIgLnJ1bnNlYWwvd3JhcHBlcnMvY2xvdWRmbGFyZS5zZWFsID4vZGV2L251bGwKY2FyZ28gcnVuIC0tcXVpZXQgLS1sb2NrZWQgLXAgcnVuc2VhbCAtLSBAdHJhbnNwaWxlIC0taW5wdXQtbGFuZz1zZWFsIC0tb3V0cHV0LWxhbmc9c2VhbGlyIC5ydW5zZWFsL3dyYXBwZXJzL3ByLnNlYWwgPi9kZXYvbnVsbApjYXJnbyBydW4gLS1xdWlldCAtLWxvY2tlZCAtcCBydW5zZWFsIC0tIEB0cmFuc3BpbGUgLS1pbnB1dC1sYW5nPXNlYWwgLS1vdXRwdXQtbGFuZz1zZWFsaXIgLnJ1bnNlYWwvd3JhcHBlcnMvcmVsZWFzZS5zZWFsID4vZGV2L251bGwKCmVjaG8gIj09PiBmbGF2b3Igc2VsZi1jaGVjayIKZmxhdm9yIGNoZWNrIC0tcm9vdCAuIC0tY29uZmlnIGZsYXZvci50b21sCgplY2hvICI9PT4gc2hlbGwgc3ludGF4IgpzaCAtbiBtYW5hZ2Uuc2gKc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvYXNzZXRzL2NoZWNrc3Vtcy5zaApzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9hc3NldHMvcGFja2FnZS5zaApzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9hc3NldHMvdmVyaWZ5LnNoCnNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL2dpdGh1Yi9jbGVhbnVwLWFydGlmYWN0cy5zaApiYXNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL3IyL2NoZWNrLnNoCmJhc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvcjIvcHVibGlzaC5zaApiYXNoIC1uIC5naXRodWIvc2NyaXB0cy9yZWxlYXNlL3IyL3N1bW1hcnkuc2gKYmFzaCAtbiAuZ2l0aHViL3NjcmlwdHMvcmVsZWFzZS9yMi92ZXJpZnkuc2gKc2ggLW4gLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2Uvc21va2Uvc21va2Uuc2gKCmVjaG8gIj09PiBweXRob24gc3ludGF4IgpweXRob24zIC1tIHB5X2NvbXBpbGUgLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvbWV0YWRhdGEvYmV0YS5weQpweXRob24zIC1tIHB5X2NvbXBpbGUgLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvbWV0YWRhdGEvc3RhYmxlLnB5CgppZiBjb21tYW5kIC12IHB3c2ggPi9kZXYvbnVsbCAyPiYxOyB0aGVuCiAgZWNobyAiPT0+IFBvd2VyU2hlbGwgc3ludGF4IgogIHB3c2ggLU5vUHJvZmlsZSAtTm9uSW50ZXJhY3RpdmUgLUNvbW1hbmQgICAgICcKJEVycm9yQWN0aW9uUHJlZmVyZW5jZSA9ICJTdG9wIgokcGF0aHMgPSBAKAogICJtYW5hZ2UucHMxIiwKICAiLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2UvYXNzZXRzL3BhY2thZ2UucHMxIiwKICAiLmdpdGh1Yi9zY3JpcHRzL3JlbGVhc2Uvc21va2Uvc21va2UucHMxIgopCmZvcmVhY2ggKCRwYXRoIGluICRwYXRocykgewogIFtzY3JpcHRibG9ja106OkNyZWF0ZSgoR2V0LUNvbnRlbnQgLVJhdyAkcGF0aCkpIHwgT3V0LU51bGwKfQonCmVsc2UKICBlY2hvICI9PT4gUG93ZXJTaGVsbCBzeW50YXgiCiAgZWNobyAic2tpcDogcHdzaCBub3QgZm91bmQiCmZp +cp .runseal/hooks/pre-commit "$pre_commit" runseal @tool fs chmod "$pre_commit" 755 print "installed $pre_commit" prepare_hook "$commit_msg" -runseal @tool fs write-base64 "$commit_msg" IyEvdXNyL2Jpbi9lbnYgc2gKIyBydW5zZWFsIGluaXQgaG9vazogZ2VuZXJhdGVkIGJ5IC5ydW5zZWFsL3dyYXBwZXJzL2luaXQuc2VhbApzZXQgLWV1CgppZiBbICIkIyIgLW5lIDEgXSB8fCBbICEgLWYgIiQxIiBdOyB0aGVuCiAgZWNobyAiY29tbWl0IG1lc3NhZ2UgZmlsZSBub3QgZm91bmQiID4mMgogIGV4aXQgMQpmaQoKc3ViamVjdD0kKHNlZCAtbiAnCi9eW1s6c3BhY2U6XV0qIy9kCi9bXls6c3BhY2U6XV0vIHsKICBzL15bWzpzcGFjZTpdXSovLwogIHMvW1s6c3BhY2U6XV0qJC8vCiAgcAogIHEKfQonICIkMSIpCgppZiBbIC16ICIkc3ViamVjdCIgXTsgdGhlbgogIGVjaG8gImNvbW1pdCBzdWJqZWN0IGlzIGVtcHR5IiA+JjIKICBleGl0IDEKZmkKCmNhc2UgIiRzdWJqZWN0IiBpbgogICJNZXJnZSAiKnwiUmV2ZXJ0ICIqfCJmaXh1cCEiKnwic3F1YXNoISIqKQogICAgZXhpdCAwCiAgICA7Owplc2FjCgppZiAhIHByaW50ZiAnJXMKJyAiJHN1YmplY3QiIHwgZ3JlcCAtRXEgJ15bYS16XVthLXowLTktXSooL1thLXowLTktXSspKjogW15bOnNwYWNlOl1dLionOyB0aGVuCiAgZWNobyAiY29tbWl0IHN1YmplY3QgbXVzdCBsb29rIGxpa2UgJzxhcmVhPjogPGltcGVyYXRpdmUgc3VtbWFyeT4nIiA+JjIKICBlY2hvICJhY3R1YWw6ICRzdWJqZWN0IiA+JjIKICBleGl0IDEKZmkKCmlmIFsgIiR7I3N1YmplY3R9IiAtZ3QgNzIgXTsgdGhlbgogIGVjaG8gIndhcm5pbmc6IGNvbW1pdCBzdWJqZWN0IGlzICR7I3N1YmplY3R9IGNoYXJzOyBwcmVmZXIgPD0gNzIiID4mMgpmaQo= +cp .runseal/hooks/commit-msg "$commit_msg" runseal @tool fs chmod "$commit_msg" 755 print "installed $commit_msg" diff --git a/Cargo.lock b/Cargo.lock index a5514f0..818af29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,15 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -191,12 +200,41 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "6.0.0" @@ -321,6 +359,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1012,7 +1060,7 @@ dependencies = [ [[package]] name = "runseal" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "assert_cmd", @@ -1023,6 +1071,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "shellexpand", "tempfile", "toml", @@ -1165,6 +1214,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shellexpand" version = "3.1.2" @@ -1450,6 +1510,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1492,6 +1558,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/app/Cargo.toml b/app/Cargo.toml index 19c286c..bb7b4a3 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runseal" -version = "0.6.0" +version = "0.7.0" edition = "2024" [dependencies] @@ -15,6 +15,7 @@ toml = "0.8" yaml_serde = "0.10" tempfile = "3.12" base64 = "0.22" +sha2 = "0.10" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } [dev-dependencies] diff --git a/app/src/core/tool/hash/mod.rs b/app/src/core/tool/hash/mod.rs new file mode 100644 index 0000000..7831b09 --- /dev/null +++ b/app/src/core/tool/hash/mod.rs @@ -0,0 +1,73 @@ +use std::{ + fs, + io::Read, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; + +pub fn eval(command: &str, args: &[String]) -> Result> { + match command { + "tree" => tree(args), + _ => bail!("unknown tool command: hash {command}"), + } +} + +fn tree(args: &[String]) -> Result> { + if args.is_empty() { + bail!("usage: runseal @tool hash tree ..."); + } + let mut entries = Vec::new(); + for input in args { + let path = Path::new(input); + collect(path, PathBuf::from(input), &mut entries)?; + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + + let mut hasher = Sha256::new(); + for (label, file) in entries { + hasher.update(label.as_bytes()); + hasher.update([0]); + let mut handle = fs::File::open(&file) + .with_context(|| format!("failed to open file: {}", file.display()))?; + let mut buffer = Vec::new(); + handle + .read_to_end(&mut buffer) + .with_context(|| format!("failed to read file: {}", file.display()))?; + hasher.update(&buffer); + hasher.update([0]); + } + + Ok(Some(format!("{:x}", hasher.finalize()))) +} + +fn collect(path: &Path, label: PathBuf, entries: &mut Vec<(String, PathBuf)>) -> Result<()> { + let metadata = + fs::metadata(path).with_context(|| format!("path not found: {}", path.display()))?; + if metadata.is_file() { + entries.push((normalize(&label), path.to_path_buf())); + return Ok(()); + } + if metadata.is_dir() { + for entry in fs::read_dir(path) + .with_context(|| format!("failed to read directory: {}", path.display()))? + { + let entry = + entry.with_context(|| format!("failed to read entry: {}", path.display()))?; + let name = entry.file_name(); + let child_path = entry.path(); + let child_label = label.join(&name); + collect(&child_path, child_label, entries)?; + } + return Ok(()); + } + bail!("unsupported path for hash tree: {}", path.display()) +} + +fn normalize(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} diff --git a/app/src/core/tool/help/hash_version.rs b/app/src/core/tool/help/hash_version.rs new file mode 100644 index 0000000..ecac6c5 --- /dev/null +++ b/app/src/core/tool/help/hash_version.rs @@ -0,0 +1,59 @@ +use super::{Entry, Section}; + +pub const HASH: Entry = Entry { + key: "hash", + usage: "runseal @tool hash [args]", + about: None, + sections: &[Section { + title: "Hash helpers", + items: &[( + "tree ...", + "hash one or more file trees deterministically", + )], + }], + examples: &[], +}; + +pub const HASH_TREE: Entry = Entry { + key: "hash.tree", + usage: "runseal @tool hash tree ...", + about: Some("Hash one or more file trees using stable path and content ordering."), + sections: &[], + examples: &["runseal @tool hash tree app/tests .runseal/wrappers"], +}; + +pub const VERSION: Entry = Entry { + key: "version", + usage: "runseal @tool version [args]", + about: None, + sections: &[Section { + title: "Version helpers", + items: &[ + ( + "part ", + "print one stable semantic version part", + ), + ( + "compare ", + "compare two stable semantic versions", + ), + ], + }], + examples: &[], +}; + +pub const VERSION_PART: Entry = Entry { + key: "version.part", + usage: "runseal @tool version part ", + about: Some("Print one numeric part from a stable semantic version, with optional `v` prefix."), + sections: &[], + examples: &["runseal @tool version part v0.7.0 minor"], +}; + +pub const VERSION_COMPARE: Entry = Entry { + key: "version.compare", + usage: "runseal @tool version compare ", + about: Some("Compare two stable semantic versions and print `lt`, `eq`, or `gt`."), + sections: &[], + examples: &["runseal @tool version compare 0.6.1 0.6.0"], +}; diff --git a/app/src/core/tool/help/json.rs b/app/src/core/tool/help/json.rs index f0b71b4..f371918 100644 --- a/app/src/core/tool/help/json.rs +++ b/app/src/core/tool/help/json.rs @@ -8,6 +8,7 @@ pub const JSON: Entry = Entry { title: "JSON helpers", items: &[ ("get ", "print one JSON value"), + ("has ", "print true when the JSON path exists"), ("empty ", "print true when JSON length is zero"), ("len ", "print JSON array/object/string length"), ("pretty ...", "print formatted JSON"), @@ -38,6 +39,22 @@ pub const JSON_GET: Entry = Entry { examples: &["runseal @tool json get '[{\"databaseId\":123}]' '.[0].databaseId'"], }; +pub const JSON_HAS: Entry = Entry { + key: "json.has", + usage: "runseal @tool json has ", + about: Some("Print `true` when the JSON path exists, otherwise `false`."), + sections: &[Section { + title: "Arguments", + items: &[ + ("", "input JSON text"), + ("", "path expression such as `.guard.version.hash`"), + ], + }], + examples: &[ + "runseal @tool json has '{\"guard\":{\"version\":{\"hash\":\"x\"}}}' .guard.version.hash", + ], +}; + pub const JSON_EMPTY: Entry = Entry { key: "json.empty", usage: "runseal @tool json empty ", diff --git a/app/src/core/tool/help/mod.rs b/app/src/core/tool/help/mod.rs index 81c79f5..216afc8 100644 --- a/app/src/core/tool/help/mod.rs +++ b/app/src/core/tool/help/mod.rs @@ -1,5 +1,6 @@ mod basic; mod cloudflare; +mod hash_version; mod json; mod ssh; @@ -21,6 +22,7 @@ pub struct Section { const ENTRIES: &[Entry] = &[ json::JSON, json::JSON_GET, + json::JSON_HAS, json::JSON_EMPTY, json::JSON_LEN, json::JSON_PRETTY, @@ -29,6 +31,8 @@ const ENTRIES: &[Entry] = &[ json::JSON_PRETTY_FILE, json::JSON_FIND, json::JSON_FILTER, + hash_version::HASH, + hash_version::HASH_TREE, basic::STRING, basic::STRING_TRIM, basic::STRING_JOIN, @@ -52,6 +56,9 @@ const ENTRIES: &[Entry] = &[ basic::GITEE_PR_CREATE, basic::GITEE_PR_PASS_GATES, basic::GITEE_PR_MERGE, + hash_version::VERSION, + hash_version::VERSION_PART, + hash_version::VERSION_COMPARE, basic::GITHUB, basic::GITHUB_PR, basic::GITHUB_PR_CHECKS, @@ -97,12 +104,14 @@ Run an atomic runseal tool command. Tools: json ... JSON helpers + hash ... hash helpers string ... string helpers regex ... regex helpers int ... integer helpers process ... process helpers archive ... archive helpers fs ... filesystem helpers + version ... version helpers gitee ... gitee helpers ssh ... ssh helpers github ... github helpers diff --git a/app/src/core/tool/json.rs b/app/src/core/tool/json.rs index 05836b7..1d23b71 100644 --- a/app/src/core/tool/json.rs +++ b/app/src/core/tool/json.rs @@ -6,6 +6,7 @@ use serde_json::Value as JsonValue; pub fn eval(command: &str, args: &[String]) -> Result> { match command { "get" => get(args), + "has" => has(args), "empty" => empty(args), "len" => len(args), "pretty" => pretty(args), @@ -31,6 +32,14 @@ fn get(args: &[String]) -> Result> { Ok(output) } +fn has(args: &[String]) -> Result> { + let [json, path] = args else { + bail!("usage: runseal @tool json has "); + }; + let value: JsonValue = serde_json::from_str(json).context("invalid JSON input")?; + Ok(Some(path_exists(&value, path).to_string())) +} + fn empty(args: &[String]) -> Result> { let [json] = args else { bail!("usage: runseal @tool json empty "); @@ -167,6 +176,18 @@ fn value_is_empty(value: &JsonValue) -> bool { } fn select_path<'a>(value: &'a JsonValue, path: &str) -> Result<&'a JsonValue> { + select_path_impl(value, path, true) +} + +fn path_exists(value: &JsonValue, path: &str) -> bool { + select_path_impl(value, path, false).is_ok() +} + +fn select_path_impl<'a>( + value: &'a JsonValue, + path: &str, + error_on_missing: bool, +) -> Result<&'a JsonValue> { let mut input = path.strip_prefix('.').unwrap_or(path); if input.is_empty() { bail!("json path cannot be empty"); @@ -180,18 +201,26 @@ fn select_path<'a>(value: &'a JsonValue, path: &str) -> Result<&'a JsonValue> { let index = index .parse::() .with_context(|| format!("invalid json path index: {index}"))?; - current = current - .get(index) - .with_context(|| format!("json path not found: {path}"))?; + current = current.get(index).ok_or_else(|| { + if error_on_missing { + anyhow::anyhow!("json path not found: {path}") + } else { + anyhow::anyhow!("missing") + } + })?; input = rest.strip_prefix('.').unwrap_or(rest); continue; } let end = input.find(['.', '[']).unwrap_or(input.len()); let field = &input[..end]; validate_field(field)?; - current = current - .get(field) - .with_context(|| format!("json path not found: {path}"))?; + current = current.get(field).ok_or_else(|| { + if error_on_missing { + anyhow::anyhow!("json path not found: {path}") + } else { + anyhow::anyhow!("missing") + } + })?; input = input[end..].strip_prefix('.').unwrap_or(&input[end..]); } Ok(current) diff --git a/app/src/core/tool/mod.rs b/app/src/core/tool/mod.rs index 3025af3..231c95f 100644 --- a/app/src/core/tool/mod.rs +++ b/app/src/core/tool/mod.rs @@ -5,6 +5,7 @@ mod cloudflare; mod fs; mod gitee; mod github; +mod hash; mod help; mod int; mod json; @@ -12,6 +13,7 @@ mod process; mod regex; mod ssh; mod string; +mod version; pub fn help() -> &'static str { help::top() @@ -41,7 +43,9 @@ pub fn eval(args: &[String]) -> Result> { [namespace, command, rest @ ..] if namespace == "archive" => archive::eval(command, rest), [namespace, command, rest @ ..] if namespace == "fs" => fs::eval(command, rest), [namespace, command, rest @ ..] if namespace == "gitee" => gitee::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "hash" => hash::eval(command, rest), [namespace, command, rest @ ..] if namespace == "ssh" => ssh::eval(command, rest), + [namespace, command, rest @ ..] if namespace == "version" => version::eval(command, rest), [namespace, command, rest @ ..] if namespace == "github" => github::eval(command, rest), [namespace, command, rest @ ..] if namespace == "cloudflare" => { cloudflare::eval(command, rest) diff --git a/app/src/core/tool/version/mod.rs b/app/src/core/tool/version/mod.rs new file mode 100644 index 0000000..e84a5db --- /dev/null +++ b/app/src/core/tool/version/mod.rs @@ -0,0 +1,60 @@ +use anyhow::{Result, bail}; + +pub fn eval(command: &str, args: &[String]) -> Result> { + match command { + "part" => part(args), + "compare" => compare(args), + _ => bail!("unknown tool command: version {command}"), + } +} + +fn part(args: &[String]) -> Result> { + let [version, name] = args else { + bail!("usage: runseal @tool version part "); + }; + let parsed = parse(version)?; + let output = match name.as_str() { + "major" => parsed.0.to_string(), + "minor" => parsed.1.to_string(), + "patch" => parsed.2.to_string(), + _ => bail!("usage: runseal @tool version part "), + }; + Ok(Some(output)) +} + +fn compare(args: &[String]) -> Result> { + let [left, right] = args else { + bail!("usage: runseal @tool version compare "); + }; + let left = parse(left)?; + let right = parse(right)?; + let output = if left < right { + "lt" + } else if left > right { + "gt" + } else { + "eq" + }; + Ok(Some(output.to_string())) +} + +fn parse(version: &str) -> Result<(u64, u64, u64)> { + let value = version.strip_prefix('v').unwrap_or(version); + let mut parts = value.split('.'); + let major = parse_part(parts.next(), version, "major")?; + let minor = parse_part(parts.next(), version, "minor")?; + let patch = parse_part(parts.next(), version, "patch")?; + if parts.next().is_some() { + bail!("expected stable semantic version, got {version}"); + } + Ok((major, minor, patch)) +} + +fn parse_part(value: Option<&str>, version: &str, name: &str) -> Result { + let Some(value) = value else { + bail!("expected stable semantic version, got {version}"); + }; + value + .parse::() + .map_err(|_| anyhow::anyhow!("invalid {name} version part in {version}")) +} diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs index dabf063..4ff6f3d 100644 --- a/app/tests/internal_tool.rs +++ b/app/tests/internal_tool.rs @@ -3,6 +3,8 @@ mod archive; #[path = "internal_tool/gitee.rs"] mod gitee; +#[path = "internal_tool/hash_version.rs"] +mod hash_version; #[path = "internal_tool/ssh.rs"] mod ssh; #[path = "internal_tool/string.rs"] @@ -41,6 +43,16 @@ fn tool_runs_without_profile() { ], "123\n", ), + ( + vec![ + "@tool", + "json", + "has", + r#"{"guard":{"version":{"hash":"x"}}}"#, + ".guard.version.hash", + ], + "true\n", + ), (vec!["@tool", "string", "trim", " value "], "value\n"), ( vec![ @@ -100,6 +112,10 @@ fn tool_help_is_progressive() { vec!["@tool", "json", "get", "--help"], "Usage: runseal @tool json get ", ), + ( + vec!["@tool", "json", "has", "--help"], + "Usage: runseal @tool json has ", + ), ( vec!["@tool", "json", "pretty", "--help"], "Usage: runseal @tool json pretty [args]", diff --git a/app/tests/internal_tool/hash_version.rs b/app/tests/internal_tool/hash_version.rs new file mode 100644 index 0000000..335e1ee --- /dev/null +++ b/app/tests/internal_tool/hash_version.rs @@ -0,0 +1,103 @@ +use tempfile::TempDir; + +use super::bin; + +#[test] +fn version_atoms() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + + for (args, expected) in [ + (vec!["@tool", "version", "part", "v1.2.3", "minor"], "2\n"), + ( + vec!["@tool", "version", "compare", "0.6.1", "0.6.0"], + "gt\n", + ), + ] { + let output = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(args.clone()) + .output() + .expect("runseal should run"); + + assert!(output.status.success(), "{args:?} should succeed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert_eq!(stdout, expected, "{args:?} stdout should match"); + } +} + +#[test] +fn help() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + + for (args, expected) in [ + ( + vec!["@tool", "hash", "--help"], + "Usage: runseal @tool hash [args]", + ), + ( + vec!["@tool", "hash", "tree", "--help"], + "Usage: runseal @tool hash tree ...", + ), + ( + vec!["@tool", "version", "--help"], + "Usage: runseal @tool version [args]", + ), + ( + vec!["@tool", "version", "part", "--help"], + "Usage: runseal @tool version part ", + ), + ( + vec!["@tool", "version", "compare", "--help"], + "Usage: runseal @tool version compare ", + ), + ] { + let output = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(args.clone()) + .output() + .expect("runseal should run"); + + assert!(output.status.success(), "{args:?} should succeed"); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!( + stdout.contains(expected), + "{args:?} should contain {expected:?}, got {stdout:?}" + ); + } +} + +#[test] +fn hash_tree() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("hash"); + std::fs::create_dir_all(cwd.join("nested")).expect("tree should be created"); + std::fs::write(cwd.join("a.txt"), "alpha\n").expect("a.txt should be written"); + std::fs::write(cwd.join("nested/b.txt"), "beta\n").expect("b.txt should be written"); + + let first = bin() + .current_dir(temp.path()) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(["@tool", "hash", "tree", cwd.to_str().unwrap()]) + .output() + .expect("runseal should run"); + assert!(first.status.success()); + + let second = bin() + .current_dir(temp.path()) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(["@tool", "hash", "tree", cwd.to_str().unwrap()]) + .output() + .expect("runseal should run"); + assert!(second.status.success()); + + let first_stdout = String::from_utf8(first.stdout).expect("stdout should be UTF-8"); + let second_stdout = String::from_utf8(second.stdout).expect("stdout should be UTF-8"); + assert_eq!(first_stdout, second_stdout); + assert_eq!(first_stdout.trim().len(), 64); +} diff --git a/app/tests/operator.rs b/app/tests/operator.rs index 4cef94b..c46ca46 100644 --- a/app/tests/operator.rs +++ b/app/tests/operator.rs @@ -2,6 +2,8 @@ mod cloudflare; #[path = "operator/estate.rs"] mod estate; +#[path = "operator/guard.rs"] +mod guard; #[path = "operator/init.rs"] mod init; #[path = "operator/repo.rs"] diff --git a/app/tests/operator/guard.rs b/app/tests/operator/guard.rs new file mode 100644 index 0000000..9f1bd52 --- /dev/null +++ b/app/tests/operator/guard.rs @@ -0,0 +1,220 @@ +#![cfg(unix)] + +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Command, +}; + +use tempfile::TempDir; + +struct Fixture { + _temp: TempDir, + project: PathBuf, + bin: PathBuf, +} + +fn fixture() -> Fixture { + let temp = TempDir::new().expect("temp dir should be created"); + let project = temp.path().join("project"); + let bin = temp.path().join("bin"); + std::fs::create_dir_all(project.join(".runseal/wrappers")) + .expect("wrapper dir should be created"); + std::fs::create_dir_all(project.join("app/tests")).expect("app tests dir should be created"); + std::fs::create_dir_all(&bin).expect("bin dir should be created"); + + std::fs::write(project.join("runseal.toml"), "injections = []\n") + .expect("profile should be written"); + std::fs::write( + project.join(".runseal/wrappers/guard.seal"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.seal")) + .expect("repo guard seal should be readable"), + ) + .expect("guard seal should be copied"); + std::fs::write(project.join("app/tests/sample.txt"), "sample\n") + .expect("sample test file should be written"); + + write_executable( + &bin.join("git"), + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "rev-parse" ] && [ "${2:-}" = "--show-toplevel" ]; then + printf '%s\n' "${RUNSEAL_TEST_ROOT:?}" + exit 0 +fi +exit 0 +"#, + ); + write_executable( + &bin.join("cargo"), + r#"#!/usr/bin/env sh +set -eu +if [ "${1:-}" = "metadata" ]; then + if [ -n "${RUNSEAL_TEST_CARGO_METADATA:-}" ]; then + printf '%s\n' "$RUNSEAL_TEST_CARGO_METADATA" + else + printf '%s\n' '{"packages":[{"version":"0.6.1"}]}' + fi + exit 0 +fi +exit 0 +"#, + ); + write_executable( + &bin.join("curl"), + r#"#!/usr/bin/env sh +set -eu +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + -w) + shift 2 + ;; + -s|-S) + shift + ;; + *) + shift + ;; + esac +done +if [ -n "${RUNSEAL_TEST_CURL_BODY:-}" ] && [ -n "$out" ]; then + printf '%s' "$RUNSEAL_TEST_CURL_BODY" > "$out" +fi +printf '%s' "${RUNSEAL_TEST_CURL_STATUS:-404}" +"#, + ); + write_executable( + &bin.join("cat"), + r#"#!/usr/bin/env sh +set -eu +/bin/cat "$@" +"#, + ); + + Fixture { + _temp: temp, + project, + bin, + } +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app dir should have repo parent") + .to_path_buf() +} + +fn write_executable(path: &Path, content: &str) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, content).expect("stub should be written"); + let mut permissions = std::fs::metadata(path) + .expect("stub metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("stub should be executable"); +} + +fn prepend_path(first: &Path) -> OsString { + let mut paths = vec![first.to_path_buf()]; + if let Some(runseal_dir) = Path::new(env!("CARGO_BIN_EXE_runseal")).parent() { + paths.push(runseal_dir.to_path_buf()); + } + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(paths).expect("PATH should be joinable") +} + +fn run_guard(fx: &Fixture, args: &[&str], envs: &[(&str, &str)]) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); + command + .current_dir(&fx.project) + .env("PATH", prepend_path(&fx.bin)) + .env("RUNSEAL_TEST_ROOT", &fx.project) + .arg("-p") + .arg(fx.project.join("runseal.toml")) + .arg(":guard") + .args(args); + for (key, value) in envs { + command.env(key, value); + } + command.output().expect("guard should run") +} + +#[test] +fn version_hash() { + let fx = fixture(); + + let wrapper = run_guard(&fx, &["version-hash"], &[]); + assert!( + wrapper.status.success(), + "stderr: {}", + String::from_utf8_lossy(&wrapper.stderr) + ); + + let tool = Command::new(env!("CARGO_BIN_EXE_runseal")) + .current_dir(&fx.project) + .env("PATH", prepend_path(&fx.bin)) + .env("RUNSEAL_HOME", fx.project.join(".home")) + .args(["@tool", "hash", "tree", "app/tests"]) + .output() + .expect("hash tool should run"); + assert!(tool.status.success()); + + assert_eq!(wrapper.stdout, tool.stdout); +} + +#[test] +fn skip_no_stable() { + let fx = fixture(); + + let output = run_guard( + &fx, + &["version-check"], + &[("RUNSEAL_TEST_CURL_STATUS", "404")], + ); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout) + .contains("guard version policy: no stable metadata; skipping") + ); +} + +#[test] +fn reject_hash_change_patch() { + let fx = fixture(); + + let output = run_guard( + &fx, + &["version-check"], + &[ + ( + "RUNSEAL_TEST_CARGO_METADATA", + r#"{"packages":[{"version":"0.6.1"}]}"#, + ), + ("RUNSEAL_TEST_CURL_STATUS", "200"), + ( + "RUNSEAL_TEST_CURL_BODY", + r#"{"stableVersion":"0.6.0","guard":{"version":{"hash":"different"}}}"#, + ), + ], + ); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("changed guard.version.hash requires a minor-or-higher bump") + ); +} diff --git a/app/tests/operator/init.rs b/app/tests/operator/init.rs index cc61bcb..790f42b 100644 --- a/app/tests/operator/init.rs +++ b/app/tests/operator/init.rs @@ -48,7 +48,10 @@ fn write_required_files(project: &Path) { "manage.sh", "manage.ps1", "runseal.toml", + ".runseal/hooks/pre-commit", + ".runseal/hooks/commit-msg", ".runseal/wrappers/cloudflare.seal", + ".runseal/wrappers/guard.seal", ".runseal/wrappers/init.seal", ".runseal/wrappers/pr.seal", ".runseal/wrappers/release.seal", @@ -72,6 +75,24 @@ fn write_required_files(project: &Path) { .expect("repo init seal should be readable"), ) .expect("init seal should be copied"); + std::fs::write( + project.join(".runseal/wrappers/guard.seal"), + std::fs::read_to_string(repo_root().join(".runseal/wrappers/guard.seal")) + .expect("repo guard seal should be readable"), + ) + .expect("guard seal should be copied"); + std::fs::write( + project.join(".runseal/hooks/pre-commit"), + std::fs::read_to_string(repo_root().join(".runseal/hooks/pre-commit")) + .expect("repo pre-commit hook should be readable"), + ) + .expect("pre-commit hook should be copied"); + std::fs::write( + project.join(".runseal/hooks/commit-msg"), + std::fs::read_to_string(repo_root().join(".runseal/hooks/commit-msg")) + .expect("repo commit-msg hook should be readable"), + ) + .expect("commit-msg hook should be copied"); std::fs::write(project.join("runseal.toml"), "injections = []\n") .expect("profile should be written"); } @@ -133,7 +154,7 @@ fn init_installs_generated_hooks() { let pre_commit_text = std::fs::read_to_string(&pre_commit).expect("pre-commit should exist"); let commit_msg_text = std::fs::read_to_string(&commit_msg).expect("commit-msg should exist"); assert!(pre_commit_text.contains("runseal init hook")); - assert!(pre_commit_text.contains(".runseal/wrappers/init.seal")); + assert!(pre_commit_text.contains("runseal :guard")); assert!(commit_msg_text.contains("runseal init hook")); } From f7d97815b3a1b30f051550b52b4c37b0eade3376 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 16:04:37 +0800 Subject: [PATCH 02/17] transpile: add redirect writing support --- app/src/core/tool/help/basic.rs | 24 +++- app/src/core/tool/help/mod.rs | 1 + app/src/core/tool/process.rs | 95 ++++++++++++- app/src/core/transpile/ast.rs | 13 ++ app/src/core/transpile/emit/mod.rs | 41 +++++- app/src/core/transpile/emit/powershell.rs | 125 +++--------------- .../core/transpile/emit/powershell_support.rs | 111 ++++++++++++++++ app/src/core/transpile/guards.rs | 1 + app/src/core/transpile/lower.rs | 1 + app/src/core/transpile/mod.rs | 1 + app/src/core/transpile/parse.rs | 29 ++-- app/src/core/transpile/parse_command.rs | 100 ++++++++++++++ app/src/core/transpile/parse_lex.rs | 32 ++++- .../transpile/{runner.rs => runner/mod.rs} | 67 ++++------ app/src/core/transpile/runner/support.rs | 89 +++++++++++++ app/tests/internal_tool.rs | 6 + app/tests/internal_tool/process.rs | 73 ++++++++++ app/tests/internal_wrappers.rs | 9 +- app/tests/transpile.rs | 44 +++++- 19 files changed, 690 insertions(+), 172 deletions(-) create mode 100644 app/src/core/transpile/emit/powershell_support.rs create mode 100644 app/src/core/transpile/parse_command.rs rename app/src/core/transpile/{runner.rs => runner/mod.rs} (94%) create mode 100644 app/src/core/transpile/runner/support.rs create mode 100644 app/tests/internal_tool/process.rs diff --git a/app/src/core/tool/help/basic.rs b/app/src/core/tool/help/basic.rs index 294a615..4ee0c2a 100644 --- a/app/src/core/tool/help/basic.rs +++ b/app/src/core/tool/help/basic.rs @@ -89,7 +89,13 @@ pub const PROCESS: Entry = Entry { about: None, sections: &[Section { title: "Process helpers", - items: &[("exists ", "print true when command exists on PATH")], + items: &[ + ("exists ", "print true when command exists on PATH"), + ( + "write [--append] -- [args...]", + "run one command and write one stream to a file", + ), + ], }], examples: &[], }; @@ -102,6 +108,22 @@ pub const PROCESS_EXISTS: Entry = Entry { examples: &[], }; +pub const PROCESS_WRITE: Entry = Entry { + key: "process.write", + usage: "runseal @tool process write [--append] -- [args...]", + about: Some( + "Run one command, write one selected stream to a file, and pass the other stream through.", + ), + sections: &[Section { + title: "Flags", + items: &[("--append", "append instead of overwriting the target file")], + }], + examples: &[ + "runseal @tool process write stdout openapi.json -- cargo run -- export-openapi", + "runseal @tool process write stderr build.log --append -- cargo build", + ], +}; + pub const ARCHIVE: Entry = Entry { key: "archive", usage: "runseal @tool archive [args]", diff --git a/app/src/core/tool/help/mod.rs b/app/src/core/tool/help/mod.rs index 216afc8..8abe4ac 100644 --- a/app/src/core/tool/help/mod.rs +++ b/app/src/core/tool/help/mod.rs @@ -43,6 +43,7 @@ const ENTRIES: &[Entry] = &[ basic::INT_ADD, basic::PROCESS, basic::PROCESS_EXISTS, + basic::PROCESS_WRITE, basic::ARCHIVE, basic::ARCHIVE_LOCAL, basic::ARCHIVE_LOCAL_EXPORT, diff --git a/app/src/core/tool/process.rs b/app/src/core/tool/process.rs index a9d25bc..a973545 100644 --- a/app/src/core/tool/process.rs +++ b/app/src/core/tool/process.rs @@ -1,8 +1,12 @@ -use anyhow::{Result, bail}; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, bail}; pub fn eval(command: &str, args: &[String]) -> Result> { match command { "exists" => exists(args), + "write" => write(args), _ => bail!("unknown tool command: process {command}"), } } @@ -14,6 +18,95 @@ fn exists(args: &[String]) -> Result> { Ok(Some(command_exists(name).to_string())) } +fn write(args: &[String]) -> Result> { + let [stream, path, rest @ ..] = args else { + bail!( + "usage: runseal @tool process write [--append] -- [args...]" + ); + }; + let stream = parse_stream(stream)?; + let mut append = false; + let mut index = 0; + while index < rest.len() { + match rest[index].as_str() { + "--append" => { + append = true; + index += 1; + } + "--" => { + index += 1; + break; + } + other => bail!("unknown process write argument: {other}"), + } + } + let command_argv = &rest[index..]; + let Some((program, command_args)) = command_argv.split_first() else { + bail!("process write requires one command after `--`"); + }; + let output = Command::new(program) + .args(command_args) + .output() + .with_context(|| format!("failed to execute command: {program}"))?; + let captured = match stream { + Stream::Stdout => &output.stdout, + Stream::Stderr => &output.stderr, + }; + let passthrough = match stream { + Stream::Stdout => &output.stderr, + Stream::Stderr => &output.stdout, + }; + if let Some(parent) = Path::new(path).parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; + } + if append { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("failed to append file: {path}"))?; + file.write_all(captured) + .with_context(|| format!("failed to append file: {path}"))?; + } else { + std::fs::write(path, captured).with_context(|| format!("failed to write file: {path}"))?; + } + match stream { + Stream::Stdout => { + use std::io::Write; + std::io::stderr() + .write_all(passthrough) + .context("failed to write stderr")?; + } + Stream::Stderr => { + use std::io::Write; + std::io::stdout() + .write_all(passthrough) + .context("failed to write stdout")?; + } + } + if output.status.success() { + return Ok(None); + } + std::process::exit(output.status.code().unwrap_or(1)); +} + +enum Stream { + Stdout, + Stderr, +} + +fn parse_stream(value: &str) -> Result { + match value { + "stdout" => Ok(Stream::Stdout), + "stderr" => Ok(Stream::Stderr), + _ => bail!("process write stream must be stdout or stderr"), + } +} + fn command_exists(name: &str) -> bool { let Some(path) = std::env::var_os("PATH") else { return false; diff --git a/app/src/core/transpile/ast.rs b/app/src/core/transpile/ast.rs index 227d448..6cb673e 100644 --- a/app/src/core/transpile/ast.rs +++ b/app/src/core/transpile/ast.rs @@ -18,6 +18,12 @@ pub enum Statement { name: String, value: Value, }, + ExecWrite { + stream: OutputStream, + path: Value, + append: bool, + argv: Vec, + }, ExecChecked { argv: Vec, }, @@ -125,4 +131,11 @@ pub enum Predicate { FileExists { path: Value }, DirExists { path: Value }, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OutputStream { + Stdout, + Stderr, +} use serde::{Deserialize, Serialize}; diff --git a/app/src/core/transpile/emit/mod.rs b/app/src/core/transpile/emit/mod.rs index 1c94bd6..fa21bad 100644 --- a/app/src/core/transpile/emit/mod.rs +++ b/app/src/core/transpile/emit/mod.rs @@ -1,7 +1,8 @@ -use super::ast::{ArgvKind, ArgvSpec, Item, Program, Statement}; +use super::ast::{ArgvKind, ArgvSpec, Item, OutputStream, Program, Statement}; use super::guards::{bash_required_tools, emit_bash_guards}; mod powershell; +mod powershell_support; mod support; pub(crate) use powershell::emit_powershell; @@ -42,6 +43,25 @@ fn emit_seal_statement(out: &mut String, statement: &Statement, indent: usize) { out.push_str(&join_values(argv, seal_value)); out.push('\n'); } + Statement::ExecWrite { + stream, + path, + append, + argv, + } => { + out.push_str(&pad); + out.push_str(&join_values(argv, seal_value)); + out.push(' '); + out.push_str(match (stream, append) { + (OutputStream::Stdout, false) => ">", + (OutputStream::Stdout, true) => ">>", + (OutputStream::Stderr, false) => "2>", + (OutputStream::Stderr, true) => "2>>", + }); + out.push(' '); + out.push_str(&seal_value(path)); + out.push('\n'); + } Statement::EnvExecChecked { env, argv } => { out.push_str(&pad); for item in env { @@ -218,6 +238,25 @@ fn emit_bash_statement(out: &mut String, statement: &Statement, indent: usize) { Statement::Assign { name, value } => { out.push_str(&format!("{pad}{name}={}\n", bash_value(value))); } + Statement::ExecWrite { + stream, + path, + append, + argv, + } => { + out.push_str(&pad); + out.push_str(&join_values(argv, bash_value)); + out.push(' '); + out.push_str(match (stream, append) { + (OutputStream::Stdout, false) => ">", + (OutputStream::Stdout, true) => ">>", + (OutputStream::Stderr, false) => "2>", + (OutputStream::Stderr, true) => "2>>", + }); + out.push(' '); + out.push_str(&bash_value(path)); + out.push('\n'); + } Statement::ExecChecked { argv } => { out.push_str(&pad); out.push_str(&join_values(argv, bash_value)); diff --git a/app/src/core/transpile/emit/powershell.rs b/app/src/core/transpile/emit/powershell.rs index 4ee31a3..26137f3 100644 --- a/app/src/core/transpile/emit/powershell.rs +++ b/app/src/core/transpile/emit/powershell.rs @@ -1,6 +1,7 @@ +use super::powershell_support::{emit_positional_bindings, max_positional_statements}; use super::support::{generated_header, option_name}; use crate::core::transpile::ast::{ - ArgvKind, ArgvSpec, EnvAssign, Item, Predicate, Program, Statement, Value, + ArgvKind, ArgvSpec, EnvAssign, Item, OutputStream, Predicate, Program, Statement, Value, }; pub(crate) fn emit_powershell(program: &Program, source_name: Option<&str>) -> String { @@ -59,6 +60,26 @@ fn emit_statement(out: &mut String, statement: &Statement, indent: usize, positi Statement::Assign { name, value } => { out.push_str(&format!("{pad}${name} = {}\n", powershell_value(value))); } + Statement::ExecWrite { + stream, + path, + append, + argv, + } => { + out.push_str(&pad); + out.push_str("& "); + out.push_str(&join_values(argv, powershell_value)); + out.push(' '); + out.push_str(match (stream, append) { + (OutputStream::Stdout, false) => ">", + (OutputStream::Stdout, true) => ">>", + (OutputStream::Stderr, false) => "2>", + (OutputStream::Stderr, true) => "2>>", + }); + out.push(' '); + out.push_str(&powershell_value(path)); + out.push('\n'); + } Statement::ExecChecked { argv } => { out.push_str(&pad); out.push_str("& "); @@ -137,21 +158,6 @@ fn emit_statement(out: &mut String, statement: &Statement, indent: usize, positi } } -fn emit_positional_bindings(out: &mut String, indent: usize, max: usize) -> bool { - if max == 0 { - return false; - } - let pad = " ".repeat(indent); - out.push_str(&format!("{pad}$0 = $args.Count\n")); - for index in 1..=max { - let offset = index - 1; - out.push_str(&format!( - "{pad}${index} = if ($args.Count -ge {index}) {{ $args[{offset}] }} else {{ '' }}\n" - )); - } - true -} - fn emit_argv_parse(out: &mut String, specs: &[ArgvSpec], indent: usize) { let pad = " ".repeat(indent); out.push_str(&format!("{pad}$__seal_argc = $args.Count\n")); @@ -373,52 +379,6 @@ fn powershell_value(value: &Value) -> String { } } -fn max_positional_statements<'a>(statements: impl IntoIterator) -> usize { - statements - .into_iter() - .map(max_positional_statement) - .max() - .unwrap_or_default() -} - -fn max_positional_statement(statement: &Statement) -> usize { - match statement { - Statement::Assign { value, .. } => max_positional_value(value), - Statement::ExecChecked { argv } - | Statement::EnvExecChecked { argv, .. } - | Statement::CaptureChecked { argv, .. } - | Statement::CallFunction { argv, .. } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Statement::If { - predicate, - then_body, - else_body, - } => max_positional_predicate(predicate) - .max(max_positional_statements(then_body.iter())) - .max(max_positional_statements(else_body.iter())), - Statement::While { predicate, body } => { - max_positional_predicate(predicate).max(max_positional_statements(body.iter())) - } - Statement::Case { value, arms } => arms - .iter() - .map(|arm| max_positional_statements(arm.body.iter())) - .max() - .unwrap_or_default() - .max(max_positional_value(value)), - Statement::Print { value } | Statement::Error { value } | Statement::Fail { value } => { - max_positional_value(value) - } - Statement::ArgvParse { .. } - | Statement::Shift { .. } - | Statement::Exit { .. } - | Statement::Break - | Statement::Sleep { .. } => 0, - } -} - fn emit_env_exec(out: &mut String, pad: &str, env: &[EnvAssign], argv: &[Value]) { out.push_str(&format!("{pad}& {{\n")); for item in env { @@ -449,47 +409,6 @@ fn emit_env_exec(out: &mut String, pad: &str, env: &[EnvAssign], argv: &[Value]) out.push_str(&format!("{pad}}}\n")); } -fn max_positional_predicate(predicate: &Predicate) -> usize { - match predicate { - Predicate::Command { argv } => argv - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Predicate::Empty { value } - | Predicate::NotEmpty { value } - | Predicate::JsonEmpty { value } - | Predicate::JsonNotEmpty { value } => max_positional_value(value), - Predicate::Eq { left, right } - | Predicate::Neq { left, right } - | Predicate::IntLt { left, right } - | Predicate::IntLte { left, right } - | Predicate::IntGt { left, right } - | Predicate::IntGte { left, right } => { - max_positional_value(left).max(max_positional_value(right)) - } - Predicate::FileExists { path } | Predicate::DirExists { path } => { - max_positional_value(path) - } - } -} - -fn max_positional_value(value: &Value) -> usize { - match value { - Value::Var { name } => name.parse::().unwrap_or_default(), - Value::Concat { parts } => parts - .iter() - .map(max_positional_value) - .max() - .unwrap_or_default(), - Value::Literal { .. } - | Value::Argc - | Value::Args - | Value::Env { .. } - | Value::EnvDefault { .. } => 0, - } -} - fn join_values(values: &[Value], format: fn(&Value) -> String) -> String { values.iter().map(format).collect::>().join(" ") } diff --git a/app/src/core/transpile/emit/powershell_support.rs b/app/src/core/transpile/emit/powershell_support.rs new file mode 100644 index 0000000..d2b59dc --- /dev/null +++ b/app/src/core/transpile/emit/powershell_support.rs @@ -0,0 +1,111 @@ +use crate::core::transpile::ast::{Predicate, Statement, Value}; + +pub(super) fn emit_positional_bindings(out: &mut String, indent: usize, max: usize) -> bool { + if max == 0 { + return false; + } + let pad = " ".repeat(indent); + out.push_str(&format!("{pad}$0 = $args.Count\n")); + for index in 1..=max { + let offset = index - 1; + out.push_str(&format!( + "{pad}${index} = if ($args.Count -ge {index}) {{ $args[{offset}] }} else {{ '' }}\n" + )); + } + true +} + +pub(super) fn max_positional_statements<'a>( + statements: impl IntoIterator, +) -> usize { + statements + .into_iter() + .map(max_positional_statement) + .max() + .unwrap_or_default() +} + +fn max_positional_statement(statement: &Statement) -> usize { + match statement { + Statement::Assign { value, .. } => max_positional_value(value), + Statement::ExecWrite { argv, path, .. } => argv + .iter() + .map(max_positional_value) + .max() + .unwrap_or_default() + .max(max_positional_value(path)), + Statement::ExecChecked { argv } + | Statement::EnvExecChecked { argv, .. } + | Statement::CaptureChecked { argv, .. } + | Statement::CallFunction { argv, .. } => argv + .iter() + .map(max_positional_value) + .max() + .unwrap_or_default(), + Statement::If { + predicate, + then_body, + else_body, + } => max_positional_predicate(predicate) + .max(max_positional_statements(then_body.iter())) + .max(max_positional_statements(else_body.iter())), + Statement::While { predicate, body } => { + max_positional_predicate(predicate).max(max_positional_statements(body.iter())) + } + Statement::Case { value, arms } => arms + .iter() + .map(|arm| max_positional_statements(arm.body.iter())) + .max() + .unwrap_or_default() + .max(max_positional_value(value)), + Statement::Print { value } | Statement::Error { value } | Statement::Fail { value } => { + max_positional_value(value) + } + Statement::ArgvParse { .. } + | Statement::Shift { .. } + | Statement::Exit { .. } + | Statement::Break + | Statement::Sleep { .. } => 0, + } +} + +fn max_positional_predicate(predicate: &Predicate) -> usize { + match predicate { + Predicate::Command { argv } => argv + .iter() + .map(max_positional_value) + .max() + .unwrap_or_default(), + Predicate::Empty { value } + | Predicate::NotEmpty { value } + | Predicate::JsonEmpty { value } + | Predicate::JsonNotEmpty { value } => max_positional_value(value), + Predicate::Eq { left, right } + | Predicate::Neq { left, right } + | Predicate::IntLt { left, right } + | Predicate::IntLte { left, right } + | Predicate::IntGt { left, right } + | Predicate::IntGte { left, right } => { + max_positional_value(left).max(max_positional_value(right)) + } + Predicate::FileExists { path } | Predicate::DirExists { path } => { + max_positional_value(path) + } + } +} + +fn max_positional_value(value: &Value) -> usize { + match value { + Value::Var { name } => name.parse::().unwrap_or_default(), + Value::Concat { parts } => parts + .iter() + .map(max_positional_value) + .max() + .unwrap_or_default(), + Value::Literal { .. } + | Value::Argc + | Value::Args + | Value::Env { .. } + | Value::EnvDefault { .. } => 0, + } +} diff --git a/app/src/core/transpile/guards.rs b/app/src/core/transpile/guards.rs index 8f7341a..20914f8 100644 --- a/app/src/core/transpile/guards.rs +++ b/app/src/core/transpile/guards.rs @@ -47,6 +47,7 @@ fn collect_bash_tool(statement: &Statement, tools: &mut BTreeSet<&'static str>) } Statement::Assign { .. } | Statement::ArgvParse { .. } + | Statement::ExecWrite { .. } | Statement::ExecChecked { .. } | Statement::EnvExecChecked { .. } | Statement::Shift { .. } diff --git a/app/src/core/transpile/lower.rs b/app/src/core/transpile/lower.rs index 5c75598..d4d937e 100644 --- a/app/src/core/transpile/lower.rs +++ b/app/src/core/transpile/lower.rs @@ -56,6 +56,7 @@ fn lower_statement(statement: &mut Statement, functions: &BTreeSet) { } Statement::Assign { .. } | Statement::ArgvParse { .. } + | Statement::ExecWrite { .. } | Statement::EnvExecChecked { .. } | Statement::Shift { .. } | Statement::CaptureChecked { .. } diff --git a/app/src/core/transpile/mod.rs b/app/src/core/transpile/mod.rs index a569b6a..374e0e6 100644 --- a/app/src/core/transpile/mod.rs +++ b/app/src/core/transpile/mod.rs @@ -9,6 +9,7 @@ mod guards; mod lower; mod parse; mod parse_argv; +mod parse_command; mod parse_lex; mod runner; mod value; diff --git a/app/src/core/transpile/parse.rs b/app/src/core/transpile/parse.rs index 367e11a..adc8945 100644 --- a/app/src/core/transpile/parse.rs +++ b/app/src/core/transpile/parse.rs @@ -3,6 +3,7 @@ use anyhow::{Result, bail}; use super::ast::{CaseArm, EnvAssign, Item, Predicate, Program, Statement, Value}; use super::lower::lower_functions; use super::parse_argv::parse_argv_block; +use super::parse_command::{parse_exec_write, validate_external_tokens, validate_shell_command}; use super::parse_lex::{ assignment, is_safe_command_name, is_valid_name, split_test_words, split_words, strip_comment, }; @@ -231,6 +232,9 @@ fn parse_simple_statement(line: &SourceLine) -> Result { }); } let tokens = split_words(&line.text, line.number)?; + if let Some(statement) = parse_exec_write(&tokens, line.number)? { + return Ok(statement); + } if let Some(statement) = parse_env_exec(&tokens, line.number)? { return Ok(statement); } @@ -243,6 +247,7 @@ fn parse_simple_statement(line: &SourceLine) -> Result { let Some((command, args)) = tokens.split_first() else { bail!("{}: expected statement", line.number); }; + validate_shell_command(command, args, line.number)?; match command.as_str() { "printf" => parse_printf(args, line.number), "eval" => bail!("{}: unsupported statement: eval", line.number), @@ -323,6 +328,7 @@ fn parse_env_exec(tokens: &[String], line: usize) -> Result> { let Some(command) = argv_tokens.first() else { return Ok(None); }; + validate_shell_command(command, &argv_tokens[1..], line)?; if !is_safe_command_name(command) { bail!("{line}: unsupported statement: {}", tokens.join(" ")); } @@ -342,23 +348,15 @@ fn parse_printf(args: &[String], line: usize) -> Result { value: parse_value_text(value, line)?, }) } - _ => bail!("{line}: unsupported printf form"), - } -} - -fn validate_external_tokens(tokens: &[String], line: usize) -> Result<()> { - for token in tokens { - if token.starts_with('"') || token.starts_with('\'') { - continue; - } - if token - .chars() - .any(|ch| matches!(ch, '|' | '>' | '<' | '&' | ';' | '`')) + [format, value, redirect, target] + if format == "'%s\\n'" && redirect == ">" && target == "&2" => { - bail!("{line}: unsupported shell metacharacter in token: {token}"); + Ok(Statement::Error { + value: parse_value_text(value, line)?, + }) } + _ => bail!("{line}: unsupported printf form"), } - Ok(()) } pub(super) fn option_to_name(option: &str, line: usize) -> Result { @@ -406,6 +404,7 @@ fn parse_predicate(text: &str, line: usize) -> Result { let Some(command) = tokens.first() else { bail!("{line}: command predicate cannot be empty"); }; + validate_shell_command(command, &tokens[1..], line)?; if !is_safe_command_name(command) { bail!("{line}: unsupported predicate: {text}"); } @@ -415,7 +414,7 @@ fn parse_predicate(text: &str, line: usize) -> Result { }) } -fn parse_values(tokens: &[String], line: usize) -> Result> { +pub(super) fn parse_values(tokens: &[String], line: usize) -> Result> { tokens .iter() .map(|arg| parse_value_text(arg, line)) diff --git a/app/src/core/transpile/parse_command.rs b/app/src/core/transpile/parse_command.rs new file mode 100644 index 0000000..7e1d628 --- /dev/null +++ b/app/src/core/transpile/parse_command.rs @@ -0,0 +1,100 @@ +use anyhow::{Result, bail}; + +use super::ast::{OutputStream, Statement}; +use super::parse::parse_values; +use super::parse_lex::is_safe_command_name; +use super::value::parse_value_text; + +pub(super) fn parse_exec_write(tokens: &[String], line: usize) -> Result> { + let redirects = tokens + .iter() + .enumerate() + .filter(|(_, token)| matches!(token.as_str(), ">" | ">>" | "2>" | "2>>" | "|")) + .collect::>(); + if redirects.is_empty() { + return Ok(None); + } + if redirects.iter().any(|(_, token)| token.as_str() == "|") { + bail!("{line}: unsupported shell metacharacter: |"); + } + if redirects.len() != 1 { + bail!("{line}: unsupported redirect combination"); + } + let (index, token) = redirects[0]; + if index == 0 || index + 2 != tokens.len() { + bail!("{line}: redirect requires one command and one file target"); + } + let argv_tokens = &tokens[..index]; + validate_external_tokens(argv_tokens, line)?; + let Some((command, args)) = argv_tokens.split_first() else { + bail!("{line}: redirect requires one command"); + }; + if command == "printf" && token.as_str() == ">" && tokens[index + 1] == "&2" { + return Ok(None); + } + validate_shell_command(command, args, line)?; + if !is_safe_command_name(command) { + bail!("{line}: unsupported statement: {}", tokens.join(" ")); + } + if matches!(tokens[index + 1].as_str(), "&1" | "&2") { + bail!("{line}: unsupported redirect combination"); + } + let path = parse_value_text(&tokens[index + 1], line)?; + let (stream, append) = match token.as_str() { + ">" => (OutputStream::Stdout, false), + ">>" => (OutputStream::Stdout, true), + "2>" => (OutputStream::Stderr, false), + "2>>" => (OutputStream::Stderr, true), + _ => unreachable!(), + }; + Ok(Some(Statement::ExecWrite { + stream, + path, + append, + argv: parse_values(argv_tokens, line)?, + })) +} + +pub(super) fn validate_external_tokens(tokens: &[String], line: usize) -> Result<()> { + for token in tokens { + if matches!(token.as_str(), ">" | ">>" | "2>" | "2>>" | "|") { + bail!("{line}: unsupported shell metacharacter: {token}"); + } + if token.starts_with('"') || token.starts_with('\'') { + continue; + } + if token + .chars() + .any(|ch| matches!(ch, '|' | '>' | '<' | '&' | ';' | '`')) + { + bail!("{line}: unsupported shell metacharacter in token: {token}"); + } + } + Ok(()) +} + +pub(super) fn validate_shell_command(command: &str, args: &[String], line: usize) -> Result<()> { + if SHELL_ONLY_COMMANDS.contains(&command) { + bail!( + "{line}: shell-specific construct is not supported in .seal: {command}; use .sh/.ps1 or file an issue for first-class support" + ); + } + let shell_launch = matches!( + (command, args.first().map(String::as_str)), + ("sh", Some("-c")) + | ("bash", Some("-c")) + | ("pwsh", Some("-Command" | "-command")) + | ("powershell", Some("-Command" | "-command")) + ); + if shell_launch { + bail!( + "{line}: shell-specific construct is not supported in .seal: {command} {}; use .sh/.ps1 or file an issue for first-class support", + args.first().expect("checked first arg exists") + ); + } + Ok(()) +} + +const SHELL_ONLY_COMMANDS: &[&str] = &[ + ".", "alias", "exec", "export", "local", "readonly", "source", "trap", "unalias", "unset", +]; diff --git a/app/src/core/transpile/parse_lex.rs b/app/src/core/transpile/parse_lex.rs index 89ada12..bae3d7a 100644 --- a/app/src/core/transpile/parse_lex.rs +++ b/app/src/core/transpile/parse_lex.rs @@ -9,7 +9,8 @@ pub(super) fn split_words(text: &str, line: usize) -> Result> { let mut words = Vec::new(); let mut current = String::new(); let mut quote = None; - for ch in text.chars() { + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { match quote { Some(q) if ch == q => { current.push(ch); @@ -20,6 +21,35 @@ pub(super) fn split_words(text: &str, line: usize) -> Result> { current.push(ch); quote = Some(ch); } + None if ch == '2' && matches!(chars.peek(), Some('>')) => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + chars.next(); + if matches!(chars.peek(), Some('>')) { + chars.next(); + words.push("2>>".to_string()); + } else { + words.push("2>".to_string()); + } + } + None if ch == '>' => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + if matches!(chars.peek(), Some('>')) { + chars.next(); + words.push(">>".to_string()); + } else { + words.push(">".to_string()); + } + } + None if ch == '|' => { + if !current.is_empty() { + words.push(std::mem::take(&mut current)); + } + words.push("|".to_string()); + } None if ch.is_whitespace() => { if !current.is_empty() { words.push(std::mem::take(&mut current)); diff --git a/app/src/core/transpile/runner.rs b/app/src/core/transpile/runner/mod.rs similarity index 94% rename from app/src/core/transpile/runner.rs rename to app/src/core/transpile/runner/mod.rs index 72cbe64..da2333a 100644 --- a/app/src/core/transpile/runner.rs +++ b/app/src/core/transpile/runner/mod.rs @@ -1,17 +1,18 @@ -use std::{ - collections::BTreeMap, - path::Path, - process::{Command, Stdio}, - time::Duration, -}; +use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; use anyhow::{Context, Result, bail}; use crate::core::tool; +use self::support::{ + CaptureMode, CommandOutput, case_matches, find_spec, option_name, shell_words, split_words, + write_stream_file, +}; use super::ast::{ArgvKind, ArgvSpec, Item, Predicate, Program, Statement, Value}; use super::parse::parse_seal; +mod support; + pub(crate) fn run_seal_file( path: &Path, argv: &[String], @@ -99,6 +100,19 @@ impl<'a> Runner<'a> { return Ok(Flow::Exit(code)); } } + Statement::ExecWrite { + stream, + path, + append, + argv, + } => { + let output = self.run_external(argv, CaptureMode::All)?; + let path = self.value(path); + write_stream_file(stream, Path::new(&path), *append, &output)?; + if output.code != 0 { + return Ok(Flow::Exit(output.code)); + } + } Statement::EnvExecChecked { env, argv } => { let overlay = env .iter() @@ -325,16 +339,18 @@ impl<'a> Runner<'a> { return Ok(CommandOutput { code: status.code().unwrap_or(1), stdout: String::new(), + stderr: String::new(), }); } - command.stdout(Stdio::piped()); let output = command .output() .with_context(|| format!("failed to execute command: {program}"))?; let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); Ok(CommandOutput { code: output.status.code().unwrap_or(1), stdout, + stderr, }) } @@ -421,40 +437,3 @@ impl<'a> Runner<'a> { .insert("__seal_argv".to_string(), shell_words(values)); } } - -enum CaptureMode { - None, - Stdout, -} - -struct CommandOutput { - code: i32, - stdout: String, -} - -fn find_spec<'a>(specs: &'a [ArgvSpec], arg: &str) -> Option<&'a ArgvSpec> { - specs.iter().find(|spec| { - let option = option_name(&spec.name); - arg == option || arg.starts_with(&(option + "=")) - }) -} - -fn option_name(name: &str) -> String { - format!("--{}", name.replace('_', "-")) -} - -fn case_matches(pattern: &str, value: &str) -> bool { - pattern == "*" || pattern == value -} - -fn shell_words(argv: &[String]) -> String { - argv.join("\u{1f}") -} - -fn split_words(value: &str) -> Vec { - if value.is_empty() { - Vec::new() - } else { - value.split('\u{1f}').map(str::to_string).collect() - } -} diff --git a/app/src/core/transpile/runner/support.rs b/app/src/core/transpile/runner/support.rs new file mode 100644 index 0000000..0feb2a4 --- /dev/null +++ b/app/src/core/transpile/runner/support.rs @@ -0,0 +1,89 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +use super::super::ast::{ArgvSpec, OutputStream}; + +pub(super) enum CaptureMode { + None, + Stdout, + All, +} + +pub(super) struct CommandOutput { + pub(super) code: i32, + pub(super) stdout: String, + pub(super) stderr: String, +} + +pub(super) fn write_stream_file( + stream: &OutputStream, + path: &Path, + append: bool, + output: &CommandOutput, +) -> Result<()> { + use std::io::Write; + + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create parent directory: {}", parent.display()))?; + } + let captured = match stream { + OutputStream::Stdout => output.stdout.as_bytes(), + OutputStream::Stderr => output.stderr.as_bytes(), + }; + if append { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("failed to append file: {}", path.display()))?; + file.write_all(captured) + .with_context(|| format!("failed to append file: {}", path.display()))?; + } else { + std::fs::write(path, captured) + .with_context(|| format!("failed to write file: {}", path.display()))?; + } + match stream { + OutputStream::Stdout => { + std::io::stderr() + .write_all(output.stderr.as_bytes()) + .context("failed to write stderr")?; + } + OutputStream::Stderr => { + std::io::stdout() + .write_all(output.stdout.as_bytes()) + .context("failed to write stdout")?; + } + } + Ok(()) +} + +pub(super) fn find_spec<'a>(specs: &'a [ArgvSpec], arg: &str) -> Option<&'a ArgvSpec> { + specs.iter().find(|spec| { + let option = option_name(&spec.name); + arg == option || arg.starts_with(&(option + "=")) + }) +} + +pub(super) fn option_name(name: &str) -> String { + format!("--{}", name.replace('_', "-")) +} + +pub(super) fn case_matches(pattern: &str, value: &str) -> bool { + pattern == "*" || pattern == value +} + +pub(super) fn shell_words(argv: &[String]) -> String { + argv.join("\u{1f}") +} + +pub(super) fn split_words(value: &str) -> Vec { + if value.is_empty() { + Vec::new() + } else { + value.split('\u{1f}').map(str::to_string).collect() + } +} diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs index 4ff6f3d..5d0bd6a 100644 --- a/app/tests/internal_tool.rs +++ b/app/tests/internal_tool.rs @@ -5,6 +5,8 @@ mod archive; mod gitee; #[path = "internal_tool/hash_version.rs"] mod hash_version; +#[path = "internal_tool/process.rs"] +mod process; #[path = "internal_tool/ssh.rs"] mod ssh; #[path = "internal_tool/string.rs"] @@ -231,6 +233,10 @@ fn richer_help() { vec!["@tool", "archive", "local", "import", "--help"], "Decrypt one .local-style directory archive into the source directory.", ), + ( + vec!["@tool", "process", "write", "--help"], + " [--append] -- [args...]", + ), ] { let output = bin() .current_dir(&cwd) diff --git a/app/tests/internal_tool/process.rs b/app/tests/internal_tool/process.rs new file mode 100644 index 0000000..4ed8395 --- /dev/null +++ b/app/tests/internal_tool/process.rs @@ -0,0 +1,73 @@ +use tempfile::TempDir; + +use super::bin; + +#[test] +fn process_write_routes_streams() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + let output_path = cwd.join("logs").join("stderr.txt"); + + let output = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args([ + "@tool", + "process", + "write", + "stderr", + output_path.to_str().expect("path should be utf-8"), + "--", + "sh", + "-c", + "printf 'out\\n'; printf 'err\\n' >&2", + ]) + .output() + .expect("runseal should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "out\n"); + assert_eq!(std::fs::read_to_string(&output_path).unwrap(), "err\n"); +} + +#[test] +fn process_write_appends() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("empty"); + std::fs::create_dir_all(&cwd).expect("empty cwd should be created"); + let output_path = cwd.join("stdout.txt"); + + for value in ["one", "two"] { + let mut args = vec![ + "@tool".to_string(), + "process".to_string(), + "write".to_string(), + "stdout".to_string(), + output_path + .to_str() + .expect("path should be utf-8") + .to_string(), + ]; + if value == "two" { + args.push("--append".to_string()); + } + args.push("--".to_string()); + args.push("printf".to_string()); + args.push(format!("{value}\\n")); + + let output = bin() + .current_dir(&cwd) + .env("RUNSEAL_HOME", temp.path().join("home")) + .args(&args) + .output() + .expect("runseal should run"); + assert!(output.status.success(), "{args:?} should succeed"); + } + + assert_eq!(std::fs::read_to_string(&output_path).unwrap(), "one\ntwo\n"); +} diff --git a/app/tests/internal_wrappers.rs b/app/tests/internal_wrappers.rs index 18cb9c0..c220ee5 100644 --- a/app/tests/internal_wrappers.rs +++ b/app/tests/internal_wrappers.rs @@ -237,7 +237,7 @@ fn seal_wrapper_shadows() { } #[test] -fn seal_env_overlay() { +fn seal_env_overlay_fails() { let fx = fixture(); make_seal_wrapper( &fx.project_wrappers.join("env-tool.seal"), @@ -248,9 +248,10 @@ RUNSEAL_MARKER=sealed sh -c 'printf %s "$RUNSEAL_MARKER"' let output = run_in(&fx, &[":env-tool"]); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert_eq!(stdout, "sealed"); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(stderr.contains("shell-specific construct is not supported in .seal")); + assert!(stderr.contains("sh -c")); } #[test] diff --git a/app/tests/transpile.rs b/app/tests/transpile.rs index 91765a7..b3b9e73 100644 --- a/app/tests/transpile.rs +++ b/app/tests/transpile.rs @@ -124,6 +124,13 @@ print "$run_id" "# } +fn redirect_source() -> &'static str { + r#" +gh run list --json databaseId > build/openapi.json +gh run view 123 2>> build/errors.log +"# +} + fn powershell_json_get_source() -> &'static str { r#" $raw = '[{"databaseId":123}]' @@ -163,6 +170,33 @@ fn sealir_without_profile() { assert!(stdout.contains("exec_checked")); } +#[test] +fn redirect_ir_and_targets() { + let fx = fixture(redirect_source()); + + let sealir = run_transpile(&fx, "seal", "sealir"); + assert!(sealir.status.success()); + let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); + assert!(sealir.contains("exec_write")); + assert!(sealir.contains("stdout")); + assert!(sealir.contains("stderr")); + + let bash = run_transpile(&fx, "seal", "bash"); + let powershell = run_transpile(&fx, "seal", "powershell"); + assert!(bash.status.success()); + assert!(powershell.status.success()); + let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); + let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); + assert!(bash.contains("gh run list --json databaseId > build/openapi.json")); + assert!(bash.contains("gh run view 123 2>> build/errors.log")); + assert!( + powershell.contains("& 'gh' 'run' 'list' '--json' 'databaseId' > 'build/openapi.json'") + ); + assert!(powershell.contains("& 'gh' 'run' 'view' '123' 2>> 'build/errors.log'")); + syntax::assert_bash(&bash); + syntax::assert_pwsh(&powershell); +} + #[test] fn bash_frontend_sealir() { let fx = fixture(sample_source()); @@ -424,7 +458,13 @@ fn hyphen_exec() { #[test] fn metacharacters_fail() { - for source in ["printf ok | cat\n", "eval something\n"] { + for source in [ + "printf ok | cat\n", + "eval something\n", + "echo ok 2>&1\n", + "exec echo ok\n", + "sh -c 'echo ok'\n", + ] { let fx = fixture(source); let output = run_transpile(&fx, "seal", "sealir"); @@ -432,7 +472,7 @@ fn metacharacters_fail() { assert!(!output.status.success(), "{source:?} should fail"); let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); assert!( - stderr.contains("unsupported"), + stderr.contains("unsupported") || stderr.contains("shell-specific construct"), "expected unsupported error, got {stderr:?}" ); } From 03f4c42fb76fab39e2d771f06ce1e2a9775994c2 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 19:08:26 +0800 Subject: [PATCH 03/17] tool: add GitHub issue write helpers --- app/src/core/tool/github.rs | 527 ++++++++++++++++++++++++++-- app/src/core/tool/github/mod.rs | 174 +++++++++ app/src/core/tool/github/support.rs | 347 ++++++++++++++++++ app/src/core/tool/help/basic.rs | 38 -- app/src/core/tool/help/github.rs | 133 +++++++ app/src/core/tool/help/mod.rs | 11 +- app/tests/internal_tool.rs | 7 +- app/tests/internal_tool/github.rs | 379 ++++++++++++++++++++ app/tests/operator/repo.rs | 6 +- 9 files changed, 1552 insertions(+), 70 deletions(-) create mode 100644 app/src/core/tool/github/mod.rs create mode 100644 app/src/core/tool/github/support.rs create mode 100644 app/src/core/tool/help/github.rs create mode 100644 app/tests/internal_tool/github.rs diff --git a/app/src/core/tool/github.rs b/app/src/core/tool/github.rs index d41c4b9..425a13b 100644 --- a/app/src/core/tool/github.rs +++ b/app/src/core/tool/github.rs @@ -1,35 +1,512 @@ -use std::process::Command; +use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; use anyhow::{Context, Result, bail}; +use serde_json::Value as JsonValue; + +const GITHUB_API_BASE: &str = "https://api.github.com"; +const GITHUB_API_VERSION: &str = "2022-11-28"; +const CORE_REPOS: &[&str] = &[ + "PerishCode/flavor", + "PerishCode/runseal", + "PerishCode/sidecar", +]; pub fn eval(command: &str, args: &[String]) -> Result> { - match (command, args) { - ("pr", [checks, probe, number]) if checks == "checks" && probe == "probe" => { - pr_checks_probe(number) - } + match command { + "issue" => issue(args), + "pr" => pr(args), _ => bail!("unknown tool command: github {command}"), } } -fn pr_checks_probe(number: &str) -> Result> { - let output = Command::new("gh") - .args(["pr", "checks", number]) +fn issue(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github issue comment|body ..."); + }; + match command.as_str() { + "comment" => issue_comment(rest), + "body" => issue_body(rest), + _ => bail!("usage: runseal @tool github issue comment|body ..."), + } +} + +fn pr(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github pr checks probe "); + }; + match command.as_str() { + "checks" => pr_checks(rest), + _ => bail!("usage: runseal @tool github pr checks probe "), + } +} + +fn pr_checks(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github pr checks probe "); + }; + match command.as_str() { + "probe" => pr_checks_probe(rest), + _ => bail!("usage: runseal @tool github pr checks probe "), + } +} + +fn pr_checks_probe(args: &[String]) -> Result> { + let [number] = args else { + bail!("usage: runseal @tool github pr checks probe "); + }; + match pr_checks_probe_http(number, args) { + Ok(value) => Ok(Some(value)), + Err(_) => Ok(Some("true".to_string())), + } +} + +fn pr_checks_probe_http(number: &str, args: &[String]) -> Result { + let current_repo = current_github_repo_id()?; + let token = optional_token(args)?; + let pr: JsonValue = github_request( + "GET", + &format!("/repos/{current_repo}/pulls/{number}"), + token.as_deref(), + None, + )?; + let Some(sha) = pr + .get("head") + .and_then(|value| value.get("sha")) + .and_then(JsonValue::as_str) + else { + bail!("GitHub API pull request payload missing head.sha"); + }; + let checks: JsonValue = github_request( + "GET", + &format!("/repos/{current_repo}/commits/{sha}/check-runs"), + token.as_deref(), + None, + )?; + let statuses: JsonValue = github_request( + "GET", + &format!("/repos/{current_repo}/commits/{sha}/status"), + token.as_deref(), + None, + )?; + let check_runs = checks + .get("total_count") + .and_then(JsonValue::as_u64) + .unwrap_or(0); + let status_count = statuses + .get("statuses") + .and_then(JsonValue::as_array) + .map(|value| value.len()) + .unwrap_or(0); + Ok(if check_runs > 0 || status_count > 0 { + "true" + } else { + "false" + } + .to_string()) +} + +fn issue_comment(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github issue comment create ..."); + }; + match command.as_str() { + "create" => issue_comment_create(rest), + _ => bail!("usage: runseal @tool github issue comment create ..."), + } +} + +fn issue_body(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github issue body update ..."); + }; + match command.as_str() { + "update" => issue_body_update(rest), + _ => bail!("usage: runseal @tool github issue body update ..."), + } +} + +fn issue_comment_create(args: &[String]) -> Result> { + let repo = required_option(args, "--repo")?; + let number = required_option(args, "--number")?; + let token = token(args)?; + let body = prepared_body(args, &repo, 100)?; + github_request_text( + "POST", + &format!("/repos/{repo}/issues/{number}/comments"), + &token, + body, + ) +} + +fn issue_body_update(args: &[String]) -> Result> { + let repo = required_option(args, "--repo")?; + let number = required_option(args, "--number")?; + let token = token(args)?; + let body = prepared_body(args, &repo, 0)?; + github_request_text( + "PATCH", + &format!("/repos/{repo}/issues/{number}"), + &token, + body, + ) +} + +fn prepared_body(args: &[String], target_repo: &str, default_body_max: usize) -> Result { + let mut body = read_body(args)?; + validate_body_max(args, &body, default_body_max)?; + if prefix_enabled(args)? { + body = prefix_body(target_repo, &body)?; + } + Ok(body) +} + +fn prefix_body(target_repo: &str, body: &str) -> Result { + if !core_repo_contains(target_repo) { + return Ok(body.to_string()); + } + let current_repo = current_repo_id()?; + if current_repo.eq_ignore_ascii_case(target_repo) { + return Ok(body.to_string()); + } + let branch = current_branch()?; + let prefix = format!("Requested-By-Repo: {current_repo}\nRequested-By-Branch: {branch}\n\n"); + if body.starts_with(&prefix) { + return Ok(body.to_string()); + } + Ok(format!("{prefix}{body}")) +} + +fn github_request_text( + method: &str, + path: &str, + token: &str, + body: String, +) -> Result> { + github_request( + method, + path, + Some(token), + Some(serde_json::json!({ + "body": body, + })), + ) + .map(|payload| Some(serde_json::to_string(&payload).expect("GitHub payload should serialize"))) +} + +fn github_request( + method: &str, + path: &str, + token: Option<&str>, + body: Option, +) -> Result { + let base = + std::env::var("RUNSEAL_GITHUB_API_BASE").unwrap_or_else(|_| GITHUB_API_BASE.to_string()); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = format!("{base}{path}"); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let method = method + .parse::() + .with_context(|| format!("invalid HTTP method: {method}"))?; + let mut request = client + .request(method.clone(), &url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", GITHUB_API_VERSION) + .header(reqwest::header::USER_AGENT, "runseal"); + if let Some(token) = token.filter(|value| !value.is_empty()) { + request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")); + } + if let Some(body) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&body); + } + let response = request + .send() + .with_context(|| format!("GitHub API {method} {path} unreachable"))?; + let status = response.status(); + let raw = response + .text() + .with_context(|| format!("GitHub API {method} {path} returned unreadable body"))?; + if !status.is_success() { + bail!("GitHub API {method} {path} -> {}: {raw}", status.as_u16()); + } + if raw.trim().is_empty() { + return Ok(JsonValue::Object(Default::default())); + } + serde_json::from_str(&raw) + .with_context(|| format!("GitHub API returned invalid JSON for {path}")) +} + +fn read_body(args: &[String]) -> Result { + let inline = optional_option(args, "--body"); + let file = optional_option(args, "--body-file"); + match (inline, file) { + (Some(_), Some(_)) => bail!("pass exactly one of --body or --body-file"), + (None, None) => bail!("pass exactly one of --body or --body-file"), + (Some(body), None) => Ok(body), + (None, Some(path)) => std::fs::read_to_string(&path) + .with_context(|| format!("failed to read body file: {path}")), + } +} + +fn validate_body_max(args: &[String], body: &str, default_body_max: usize) -> Result<()> { + let body_max = body_max(args, default_body_max)?; + if body_max == 0 { + return Ok(()); + } + let count = body.chars().count(); + if count > body_max { + bail!("body length {count} exceeds --body-max={body_max}"); + } + Ok(()) +} + +fn body_max(args: &[String], default: usize) -> Result { + let Some(value) = optional_option(args, "--body-max") else { + return Ok(default); + }; + value + .parse::() + .with_context(|| format!("invalid --body-max: {value}")) +} + +fn token(args: &[String]) -> Result { + if let Some(token) = optional_option(args, "--token") + && !token.is_empty() + { + return Ok(token); + } + if let Some(path) = optional_option(args, "--token-file") { + let values = parse_env_file(Path::new(&path))?; + if let Some(token) = values.get("GITHUB_TOKEN").filter(|value| !value.is_empty()) { + return Ok(token.clone()); + } + bail!("GITHUB_TOKEN not set in {path}"); + } + if let Some(name) = optional_option(args, "--token-env") { + let token = std::env::var(&name) + .with_context(|| format!("environment variable not set: {name}"))?; + if token.is_empty() { + bail!("environment variable is empty: {name}"); + } + return Ok(token); + } + if let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + return Ok(token); + } + bail!("missing GitHub token: pass --token, --token-file, --token-env, or set GITHUB_TOKEN") +} + +fn optional_token(args: &[String]) -> Result> { + if let Some(token) = optional_option(args, "--token") + && !token.is_empty() + { + return Ok(Some(token)); + } + if let Some(path) = optional_option(args, "--token-file") { + let values = parse_env_file(Path::new(&path))?; + if let Some(token) = values.get("GITHUB_TOKEN").filter(|value| !value.is_empty()) { + return Ok(Some(token.clone())); + } + bail!("GITHUB_TOKEN not set in {path}"); + } + if let Some(name) = optional_option(args, "--token-env") { + let token = std::env::var(&name) + .with_context(|| format!("environment variable not set: {name}"))?; + if token.is_empty() { + bail!("environment variable is empty: {name}"); + } + return Ok(Some(token)); + } + Ok(std::env::var("GITHUB_TOKEN") + .ok() + .filter(|value| !value.is_empty())) +} + +fn prefix_enabled(args: &[String]) -> Result { + optional_bool_option(args, "--prefix-enable").map(|value| value.unwrap_or(false)) +} + +fn optional_bool_option(args: &[String], name: &str) -> Result> { + let prefix = format!("{name}="); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == name { + return Ok(Some(true)); + } + if let Some(value) = arg.strip_prefix(&prefix) { + return Ok(Some(parse_bool(name, value)?)); + } + if arg == name { + let Some(value) = args.get(index + 1) else { + return Ok(Some(true)); + }; + if !value.starts_with('-') { + return Ok(Some(parse_bool(name, value)?)); + } + return Ok(Some(true)); + } + index += 1; + } + Ok(None) +} + +fn parse_bool(name: &str, value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => bail!("{name} expects true or false"), + } +} + +fn current_repo_id() -> Result { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) .output() - .with_context(|| "failed to execute command: gh")?; - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - if combined.contains("no checks reported") { - return Ok(Some("false".to_string())); - } - if output.status.success() { - return Ok(Some("true".to_string())); - } - bail!( - "gh pr checks failed with status {}: {}", - output.status.code().unwrap_or(1), - combined.trim() - ); + .with_context(|| "failed to execute command: git")?; + if !output.status.success() { + bail!( + "git remote get-url origin failed with status {}", + output.status.code().unwrap_or(1) + ); + } + let url = String::from_utf8(output.stdout) + .with_context(|| "git remote get-url origin returned non-UTF-8 output")?; + parse_repo_id(url.trim()) +} + +fn current_github_repo_id() -> Result { + let repo = current_repo_id()?; + if repo.contains('/') { + return Ok(repo); + } + bail!("cannot parse GitHub owner/repo from current repository") +} + +fn current_branch() -> Result { + let output = Command::new("git") + .args(["branch", "--show-current"]) + .output() + .with_context(|| "failed to execute command: git")?; + if !output.status.success() { + bail!( + "git branch --show-current failed with status {}", + output.status.code().unwrap_or(1) + ); + } + let branch = String::from_utf8(output.stdout) + .with_context(|| "git branch --show-current returned non-UTF-8 output")?; + let branch = branch.trim(); + if branch.is_empty() { + bail!("git branch --show-current returned an empty branch name"); + } + Ok(branch.to_string()) +} + +fn parse_repo_id(url: &str) -> Result { + if let Ok(repo) = parse_hosted_repo_id( + url, + &[ + "git@github.com:", + "ssh://git@github.com/", + "https://github.com/", + "http://github.com/", + ], + ) { + return Ok(repo); + } + if let Ok(repo) = parse_hosted_repo_id( + url, + &[ + "git@gitee.com:", + "ssh://git@gitee.com/", + "https://gitee.com/", + "http://gitee.com/", + ], + ) { + return Ok(repo); + } + bail!("cannot parse owner/repo from origin url: {url}"); +} + +fn parse_hosted_repo_id(url: &str, prefixes: &[&str]) -> Result { + let Some(after_host) = url + .strip_prefix(prefixes[0]) + .or_else(|| url.strip_prefix(prefixes[1])) + .or_else(|| url.strip_prefix(prefixes[2])) + .or_else(|| url.strip_prefix(prefixes[3])) + else { + bail!("unmatched host"); + }; + let path = after_host.trim_end_matches(".git"); + let mut parts = path.split('/'); + let Some(owner) = parts.next().filter(|value| !value.is_empty()) else { + bail!("cannot parse owner/repo from origin url: {url}"); + }; + let Some(repo) = parts.next().filter(|value| !value.is_empty()) else { + bail!("cannot parse owner/repo from origin url: {url}"); + }; + if parts.next().is_some() { + bail!("cannot parse owner/repo from origin url: {url}"); + } + Ok(format!("{owner}/{repo}")) +} + +fn parse_env_file(path: &Path) -> Result> { + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read token file: {}", path.display()))?; + let mut values = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + bail!("invalid line in {}: {line}", path.display()); + }; + values.insert( + key.trim().to_string(), + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + ); + } + Ok(values) +} + +fn required_option(args: &[String], name: &str) -> Result { + optional_option(args, name).ok_or_else(|| anyhow::anyhow!("{name} is required")) +} + +fn optional_option(args: &[String], name: &str) -> Option { + let prefix = format!("{name}="); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == name { + return args.get(index + 1).cloned(); + } + if let Some(value) = arg.strip_prefix(&prefix) { + return Some(value.to_string()); + } + index += 1; + } + None +} + +fn core_repo_contains(repo: &str) -> bool { + CORE_REPOS + .iter() + .any(|value| value.eq_ignore_ascii_case(repo)) } diff --git a/app/src/core/tool/github/mod.rs b/app/src/core/tool/github/mod.rs new file mode 100644 index 0000000..c2a27bc --- /dev/null +++ b/app/src/core/tool/github/mod.rs @@ -0,0 +1,174 @@ +mod support; + +use anyhow::{Result, bail}; +use serde_json::Value as JsonValue; + +use self::support::{ + core_repo_contains, current_branch, current_github_repo_id, current_repo_id, github_request, + github_request_text, optional_token, prefix_enabled, read_body, token, validate_body_max, +}; + +pub fn eval(command: &str, args: &[String]) -> Result> { + match command { + "issue" => issue(args), + "pr" => pr(args), + _ => bail!("unknown tool command: github {command}"), + } +} + +fn issue(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github issue comment|body ..."); + }; + match command.as_str() { + "comment" => issue_comment(rest), + "body" => issue_body(rest), + _ => bail!("usage: runseal @tool github issue comment|body ..."), + } +} + +fn pr(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github pr checks probe "); + }; + match command.as_str() { + "checks" => pr_checks(rest), + _ => bail!("usage: runseal @tool github pr checks probe "), + } +} + +fn pr_checks(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github pr checks probe "); + }; + match command.as_str() { + "probe" => pr_checks_probe(rest), + _ => bail!("usage: runseal @tool github pr checks probe "), + } +} + +fn pr_checks_probe(args: &[String]) -> Result> { + let [number] = args else { + bail!("usage: runseal @tool github pr checks probe "); + }; + match pr_checks_probe_http(number, args) { + Ok(value) => Ok(Some(value)), + Err(_) => Ok(Some("true".to_string())), + } +} + +fn pr_checks_probe_http(number: &str, args: &[String]) -> Result { + let current_repo = current_github_repo_id()?; + let token = optional_token(args)?; + let pr: JsonValue = github_request( + "GET", + &format!("/repos/{current_repo}/pulls/{number}"), + token.as_deref(), + None, + )?; + let Some(sha) = pr + .get("head") + .and_then(|value| value.get("sha")) + .and_then(JsonValue::as_str) + else { + bail!("GitHub API pull request payload missing head.sha"); + }; + let checks: JsonValue = github_request( + "GET", + &format!("/repos/{current_repo}/commits/{sha}/check-runs"), + token.as_deref(), + None, + )?; + let statuses: JsonValue = github_request( + "GET", + &format!("/repos/{current_repo}/commits/{sha}/status"), + token.as_deref(), + None, + )?; + let check_runs = checks + .get("total_count") + .and_then(JsonValue::as_u64) + .unwrap_or(0); + let status_count = statuses + .get("statuses") + .and_then(JsonValue::as_array) + .map(|value| value.len()) + .unwrap_or(0); + Ok(if check_runs > 0 || status_count > 0 { + "true" + } else { + "false" + } + .to_string()) +} + +fn issue_comment(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github issue comment create ..."); + }; + match command.as_str() { + "create" => issue_comment_create(rest), + _ => bail!("usage: runseal @tool github issue comment create ..."), + } +} + +fn issue_body(args: &[String]) -> Result> { + let [command, rest @ ..] = args else { + bail!("usage: runseal @tool github issue body update ..."); + }; + match command.as_str() { + "update" => issue_body_update(rest), + _ => bail!("usage: runseal @tool github issue body update ..."), + } +} + +fn issue_comment_create(args: &[String]) -> Result> { + let repo = support::required_option(args, "--repo")?; + let number = support::required_option(args, "--number")?; + let token = token(args)?; + let body = prepared_body(args, &repo, 100)?; + github_request_text( + "POST", + &format!("/repos/{repo}/issues/{number}/comments"), + &token, + body, + ) +} + +fn issue_body_update(args: &[String]) -> Result> { + let repo = support::required_option(args, "--repo")?; + let number = support::required_option(args, "--number")?; + let token = token(args)?; + let body = prepared_body(args, &repo, 0)?; + github_request_text( + "PATCH", + &format!("/repos/{repo}/issues/{number}"), + &token, + body, + ) +} + +fn prepared_body(args: &[String], target_repo: &str, default_body_max: usize) -> Result { + let mut body = read_body(args)?; + validate_body_max(args, &body, default_body_max)?; + if prefix_enabled(args)? { + body = prefix_body(target_repo, &body)?; + } + Ok(body) +} + +fn prefix_body(target_repo: &str, body: &str) -> Result { + if !core_repo_contains(target_repo) { + return Ok(body.to_string()); + } + let current_repo = current_repo_id()?; + if current_repo.eq_ignore_ascii_case(target_repo) { + return Ok(body.to_string()); + } + let branch = current_branch()?; + let prefix = format!("Requested-By-Repo: {current_repo}\nRequested-By-Branch: {branch}\n\n"); + if body.starts_with(&prefix) { + return Ok(body.to_string()); + } + Ok(format!("{prefix}{body}")) +} diff --git a/app/src/core/tool/github/support.rs b/app/src/core/tool/github/support.rs new file mode 100644 index 0000000..577981b --- /dev/null +++ b/app/src/core/tool/github/support.rs @@ -0,0 +1,347 @@ +use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; + +use anyhow::{Context, Result, bail}; +use serde_json::Value as JsonValue; + +const GITHUB_API_BASE: &str = "https://api.github.com"; +const GITHUB_API_VERSION: &str = "2022-11-28"; +const CORE_REPOS: &[&str] = &[ + "PerishCode/flavor", + "PerishCode/runseal", + "PerishCode/sidecar", +]; + +pub fn github_request_text( + method: &str, + path: &str, + token: &str, + body: String, +) -> Result> { + github_request( + method, + path, + Some(token), + Some(serde_json::json!({ + "body": body, + })), + ) + .map(|payload| Some(serde_json::to_string(&payload).expect("GitHub payload should serialize"))) +} + +pub fn github_request( + method: &str, + path: &str, + token: Option<&str>, + body: Option, +) -> Result { + let base = + std::env::var("RUNSEAL_GITHUB_API_BASE").unwrap_or_else(|_| GITHUB_API_BASE.to_string()); + let path = if path.starts_with('/') { + path.to_string() + } else { + format!("/{path}") + }; + let url = format!("{base}{path}"); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let method = method + .parse::() + .with_context(|| format!("invalid HTTP method: {method}"))?; + let mut request = client + .request(method.clone(), &url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", GITHUB_API_VERSION) + .header(reqwest::header::USER_AGENT, "runseal"); + if let Some(token) = token.filter(|value| !value.is_empty()) { + request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")); + } + if let Some(body) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&body); + } + let response = request + .send() + .with_context(|| format!("GitHub API {method} {path} unreachable"))?; + let status = response.status(); + let raw = response + .text() + .with_context(|| format!("GitHub API {method} {path} returned unreadable body"))?; + if !status.is_success() { + bail!("GitHub API {method} {path} -> {}: {raw}", status.as_u16()); + } + if raw.trim().is_empty() { + return Ok(JsonValue::Object(Default::default())); + } + serde_json::from_str(&raw) + .with_context(|| format!("GitHub API returned invalid JSON for {path}")) +} + +pub fn read_body(args: &[String]) -> Result { + let inline = optional_option(args, "--body"); + let file = optional_option(args, "--body-file"); + match (inline, file) { + (Some(_), Some(_)) => bail!("pass exactly one of --body or --body-file"), + (None, None) => bail!("pass exactly one of --body or --body-file"), + (Some(body), None) => Ok(body), + (None, Some(path)) => std::fs::read_to_string(&path) + .with_context(|| format!("failed to read body file: {path}")), + } +} + +pub fn validate_body_max(args: &[String], body: &str, default_body_max: usize) -> Result<()> { + let body_max = body_max(args, default_body_max)?; + if body_max == 0 { + return Ok(()); + } + let count = body.chars().count(); + if count > body_max { + bail!("body length {count} exceeds --body-max={body_max}"); + } + Ok(()) +} + +pub fn token(args: &[String]) -> Result { + if let Some(token) = optional_option(args, "--token") + && !token.is_empty() + { + return Ok(token); + } + if let Some(path) = optional_option(args, "--token-file") { + let values = parse_env_file(Path::new(&path))?; + if let Some(token) = values.get("GITHUB_TOKEN").filter(|value| !value.is_empty()) { + return Ok(token.clone()); + } + bail!("GITHUB_TOKEN not set in {path}"); + } + if let Some(name) = optional_option(args, "--token-env") { + let token = std::env::var(&name) + .with_context(|| format!("environment variable not set: {name}"))?; + if token.is_empty() { + bail!("environment variable is empty: {name}"); + } + return Ok(token); + } + if let Ok(token) = std::env::var("GITHUB_TOKEN") + && !token.is_empty() + { + return Ok(token); + } + bail!("missing GitHub token: pass --token, --token-file, --token-env, or set GITHUB_TOKEN") +} + +pub fn optional_token(args: &[String]) -> Result> { + if let Some(token) = optional_option(args, "--token") + && !token.is_empty() + { + return Ok(Some(token)); + } + if let Some(path) = optional_option(args, "--token-file") { + let values = parse_env_file(Path::new(&path))?; + if let Some(token) = values.get("GITHUB_TOKEN").filter(|value| !value.is_empty()) { + return Ok(Some(token.clone())); + } + bail!("GITHUB_TOKEN not set in {path}"); + } + if let Some(name) = optional_option(args, "--token-env") { + let token = std::env::var(&name) + .with_context(|| format!("environment variable not set: {name}"))?; + if token.is_empty() { + bail!("environment variable is empty: {name}"); + } + return Ok(Some(token)); + } + Ok(std::env::var("GITHUB_TOKEN") + .ok() + .filter(|value| !value.is_empty())) +} + +pub fn prefix_enabled(args: &[String]) -> Result { + optional_bool_option(args, "--prefix-enable").map(|value| value.unwrap_or(false)) +} + +pub fn current_repo_id() -> Result { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .with_context(|| "failed to execute command: git")?; + if !output.status.success() { + bail!( + "git remote get-url origin failed with status {}", + output.status.code().unwrap_or(1) + ); + } + let url = String::from_utf8(output.stdout) + .with_context(|| "git remote get-url origin returned non-UTF-8 output")?; + parse_repo_id(url.trim()) +} + +pub fn current_github_repo_id() -> Result { + let repo = current_repo_id()?; + if repo.contains('/') { + return Ok(repo); + } + bail!("cannot parse GitHub owner/repo from current repository") +} + +pub fn current_branch() -> Result { + let output = Command::new("git") + .args(["branch", "--show-current"]) + .output() + .with_context(|| "failed to execute command: git")?; + if !output.status.success() { + bail!( + "git branch --show-current failed with status {}", + output.status.code().unwrap_or(1) + ); + } + let branch = String::from_utf8(output.stdout) + .with_context(|| "git branch --show-current returned non-UTF-8 output")?; + let branch = branch.trim(); + if branch.is_empty() { + bail!("git branch --show-current returned an empty branch name"); + } + Ok(branch.to_string()) +} + +pub fn required_option(args: &[String], name: &str) -> Result { + optional_option(args, name).ok_or_else(|| anyhow::anyhow!("{name} is required")) +} + +pub fn core_repo_contains(repo: &str) -> bool { + CORE_REPOS + .iter() + .any(|value| value.eq_ignore_ascii_case(repo)) +} + +fn body_max(args: &[String], default: usize) -> Result { + let Some(value) = optional_option(args, "--body-max") else { + return Ok(default); + }; + value + .parse::() + .with_context(|| format!("invalid --body-max: {value}")) +} + +fn optional_bool_option(args: &[String], name: &str) -> Result> { + let prefix = format!("{name}="); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == name { + return Ok(Some(true)); + } + if let Some(value) = arg.strip_prefix(&prefix) { + return Ok(Some(parse_bool(name, value)?)); + } + if arg == name { + let Some(value) = args.get(index + 1) else { + return Ok(Some(true)); + }; + if !value.starts_with('-') { + return Ok(Some(parse_bool(name, value)?)); + } + return Ok(Some(true)); + } + index += 1; + } + Ok(None) +} + +fn parse_bool(name: &str, value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => bail!("{name} expects true or false"), + } +} + +fn parse_repo_id(url: &str) -> Result { + if let Ok(repo) = parse_hosted_repo_id( + url, + &[ + "git@github.com:", + "ssh://git@github.com/", + "https://github.com/", + "http://github.com/", + ], + ) { + return Ok(repo); + } + if let Ok(repo) = parse_hosted_repo_id( + url, + &[ + "git@gitee.com:", + "ssh://git@gitee.com/", + "https://gitee.com/", + "http://gitee.com/", + ], + ) { + return Ok(repo); + } + bail!("cannot parse owner/repo from origin url: {url}"); +} + +fn parse_hosted_repo_id(url: &str, prefixes: &[&str]) -> Result { + let Some(after_host) = url + .strip_prefix(prefixes[0]) + .or_else(|| url.strip_prefix(prefixes[1])) + .or_else(|| url.strip_prefix(prefixes[2])) + .or_else(|| url.strip_prefix(prefixes[3])) + else { + bail!("unmatched host"); + }; + let path = after_host.trim_end_matches(".git"); + let mut parts = path.split('/'); + let Some(owner) = parts.next().filter(|value| !value.is_empty()) else { + bail!("cannot parse owner/repo from origin url: {url}"); + }; + let Some(repo) = parts.next().filter(|value| !value.is_empty()) else { + bail!("cannot parse owner/repo from origin url: {url}"); + }; + if parts.next().is_some() { + bail!("cannot parse owner/repo from origin url: {url}"); + } + Ok(format!("{owner}/{repo}")) +} + +fn parse_env_file(path: &Path) -> Result> { + let text = std::fs::read_to_string(path) + .with_context(|| format!("failed to read token file: {}", path.display()))?; + let mut values = BTreeMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + bail!("invalid line in {}: {line}", path.display()); + }; + values.insert( + key.trim().to_string(), + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + ); + } + Ok(values) +} + +fn optional_option(args: &[String], name: &str) -> Option { + let prefix = format!("{name}="); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + if arg == name { + return args.get(index + 1).cloned(); + } + if let Some(value) = arg.strip_prefix(&prefix) { + return Some(value.to_string()); + } + index += 1; + } + None +} diff --git a/app/src/core/tool/help/basic.rs b/app/src/core/tool/help/basic.rs index 4ee0c2a..e7edc48 100644 --- a/app/src/core/tool/help/basic.rs +++ b/app/src/core/tool/help/basic.rs @@ -387,41 +387,3 @@ pub const GITEE_PR_MERGE: Entry = Entry { "runseal @tool gitee pr merge --owner perishme --repo perish.top --number 123 --method squash", ], }; - -pub const GITHUB: Entry = Entry { - key: "github", - usage: "runseal @tool github [args]", - about: None, - sections: &[Section { - title: "GitHub helpers", - items: &[( - "pr checks probe ", - "print true when PR checks are reported", - )], - }], - examples: &[], -}; - -pub const GITHUB_PR: Entry = Entry { - key: "github.pr", - usage: "runseal @tool github pr checks probe ", - about: None, - sections: &[], - examples: &[], -}; - -pub const GITHUB_PR_CHECKS: Entry = Entry { - key: "github.pr.checks", - usage: "runseal @tool github pr checks probe ", - about: None, - sections: &[], - examples: &[], -}; - -pub const GITHUB_PR_CHECKS_PROBE: Entry = Entry { - key: "github.pr.checks.probe", - usage: "runseal @tool github pr checks probe ", - about: None, - sections: &[], - examples: &[], -}; diff --git a/app/src/core/tool/help/github.rs b/app/src/core/tool/help/github.rs new file mode 100644 index 0000000..4164dfa --- /dev/null +++ b/app/src/core/tool/help/github.rs @@ -0,0 +1,133 @@ +use super::{Entry, Section}; + +pub const GITHUB: Entry = Entry { + key: "github", + usage: "runseal @tool github [args]", + about: Some("GitHub helpers for collaboration rules that need more than plain gh ergonomics."), + sections: &[Section { + title: "GitHub helpers", + items: &[ + ( + "issue comment create", + "create one issue-style comment; also applies to top-level PR comments", + ), + ( + "issue body update", + "update one issue-style body; also applies to top-level PR bodies", + ), + ], + }], + examples: &[], +}; + +pub const GITHUB_ISSUE: Entry = Entry { + key: "github.issue", + usage: "runseal @tool github issue [args]", + about: Some( + "GitHub issue-style write helpers. GitHub PR top-level bodies and timeline comments also use this issue model.", + ), + sections: &[Section { + title: "GitHub issue helpers", + items: &[ + ("comment create", "create one issue-style comment"), + ("body update", "update one issue-style body"), + ], + }], + examples: &[], +}; + +pub const GITHUB_ISSUE_COMMENT: Entry = Entry { + key: "github.issue.comment", + usage: "runseal @tool github issue comment create [args]", + about: None, + sections: &[Section { + title: "GitHub issue comment helpers", + items: &[("create", "create one issue-style comment")], + }], + examples: &[], +}; + +pub const GITHUB_ISSUE_COMMENT_CREATE: Entry = Entry { + key: "github.issue.comment.create", + usage: "runseal @tool github issue comment create --repo --number (--body |--body-file ) [--body-max ] [--prefix-enable=] [--token |--token-file |--token-env ]", + about: Some( + "Create one GitHub issue-style comment and print the API response JSON. Default `--body-max` is `100`; over-limit bodies fail fast.", + ), + sections: &[Section { + title: "Flags", + items: &[ + ("--repo ", "target GitHub repository"), + ("--number ", "issue or pull request number"), + ("--body ", "inline comment body"), + ("--body-file ", "read comment body from one file"), + ( + "--body-max ", + "maximum user-body length; `0` disables the limit; default `100`", + ), + ( + "--prefix-enable=", + "prepend requested-by metadata for matching cross-repo writes", + ), + ("--token ", "explicit GitHub token"), + ( + "--token-file ", + "env-style file containing `GITHUB_TOKEN`", + ), + ( + "--token-env ", + "read the token from one named environment variable", + ), + ], + }], + examples: &[ + "runseal @tool github issue comment create --repo PerishCode/runseal --number 46 --body-file body.md --prefix-enable=true", + ], +}; + +pub const GITHUB_ISSUE_BODY: Entry = Entry { + key: "github.issue.body", + usage: "runseal @tool github issue body update [args]", + about: None, + sections: &[Section { + title: "GitHub issue body helpers", + items: &[("update", "update one issue-style body")], + }], + examples: &[], +}; + +pub const GITHUB_ISSUE_BODY_UPDATE: Entry = Entry { + key: "github.issue.body.update", + usage: "runseal @tool github issue body update --repo --number (--body |--body-file ) [--body-max ] [--prefix-enable=] [--token |--token-file |--token-env ]", + about: Some( + "Update one GitHub issue-style body and print the API response JSON. Default `--body-max` is `0`, which means unlimited.", + ), + sections: &[Section { + title: "Flags", + items: &[ + ("--repo ", "target GitHub repository"), + ("--number ", "issue or pull request number"), + ("--body ", "inline body text"), + ("--body-file ", "read body text from one file"), + ( + "--body-max ", + "maximum user-body length; `0` disables the limit; default `0`", + ), + ( + "--prefix-enable=", + "prepend requested-by metadata for matching cross-repo writes", + ), + ("--token ", "explicit GitHub token"), + ( + "--token-file ", + "env-style file containing `GITHUB_TOKEN`", + ), + ( + "--token-env ", + "read the token from one named environment variable", + ), + ], + }], + examples: &[ + "runseal @tool github issue body update --repo PerishCode/runseal --number 46 --body-file body.md --prefix-enable=true", + ], +}; diff --git a/app/src/core/tool/help/mod.rs b/app/src/core/tool/help/mod.rs index 8abe4ac..3148d9e 100644 --- a/app/src/core/tool/help/mod.rs +++ b/app/src/core/tool/help/mod.rs @@ -1,5 +1,6 @@ mod basic; mod cloudflare; +mod github; mod hash_version; mod json; mod ssh; @@ -60,10 +61,12 @@ const ENTRIES: &[Entry] = &[ hash_version::VERSION, hash_version::VERSION_PART, hash_version::VERSION_COMPARE, - basic::GITHUB, - basic::GITHUB_PR, - basic::GITHUB_PR_CHECKS, - basic::GITHUB_PR_CHECKS_PROBE, + github::GITHUB, + github::GITHUB_ISSUE, + github::GITHUB_ISSUE_COMMENT, + github::GITHUB_ISSUE_COMMENT_CREATE, + github::GITHUB_ISSUE_BODY, + github::GITHUB_ISSUE_BODY_UPDATE, ssh::SSH, ssh::SSH_CONFIG, ssh::SSH_CONFIG_HOST, diff --git a/app/tests/internal_tool.rs b/app/tests/internal_tool.rs index 5d0bd6a..38e4855 100644 --- a/app/tests/internal_tool.rs +++ b/app/tests/internal_tool.rs @@ -3,6 +3,9 @@ mod archive; #[path = "internal_tool/gitee.rs"] mod gitee; +#[path = "internal_tool/github.rs"] +#[cfg(unix)] +mod github; #[path = "internal_tool/hash_version.rs"] mod hash_version; #[path = "internal_tool/process.rs"] @@ -222,8 +225,8 @@ fn richer_help() { "Build one exact-match redirect rule payload as JSON.", ), ( - vec!["@tool", "github", "pr", "checks", "probe", "--help"], - "usage: runseal @tool github pr checks probe ", + vec!["@tool", "github", "issue", "comment", "create", "--help"], + "--prefix-enable=", ), ( vec!["@tool", "gitee", "pr", "merge", "--help"], diff --git a/app/tests/internal_tool/github.rs b/app/tests/internal_tool/github.rs new file mode 100644 index 0000000..237aa84 --- /dev/null +++ b/app/tests/internal_tool/github.rs @@ -0,0 +1,379 @@ +#![cfg(unix)] + +use std::{ + ffi::OsString, + io::{Read, Write}, + net::TcpListener, + path::{Path, PathBuf}, + process::Command, + thread, +}; + +use tempfile::TempDir; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_runseal")) +} + +#[test] +fn issue_comment_write() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("repo"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&cwd).expect("cwd should be created"); + std::fs::create_dir_all(&bin_dir).expect("bin dir should be created"); + write_git_stub( + &bin_dir.join("git"), + "git@gitee.com:perishme/perish.top.git", + "feat/prefix", + ); + + let body_file = cwd.join("body.md"); + std::fs::write(&body_file, "Hello from file\n").expect("body file should be written"); + let token_file = cwd.join("github.env"); + std::fs::write(&token_file, "GITHUB_TOKEN=file-token\n").expect("token file should be written"); + + let (api_base, handle) = mock_github( + |request| { + assert!(request.starts_with("POST /repos/PerishCode/runseal/issues/46/comments ")); + assert!(request.contains("authorization: Bearer file-token")); + assert!(request.contains( + r#"Requested-By-Repo: perishme/perish.top\nRequested-By-Branch: feat/prefix\n\nHello from file\n"# + )); + }, + r#"{"id":46,"html_url":"https://github.test/comment/46"}"#, + ); + let output = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("RUNSEAL_GITHUB_API_BASE", api_base) + .args([ + "@tool", + "github", + "issue", + "comment", + "create", + "--repo", + "PerishCode/runseal", + "--number", + "46", + "--body-file", + body_file.to_str().unwrap(), + "--prefix-enable=true", + "--token-file", + token_file.to_str().unwrap(), + ]) + .output() + .expect("runseal should run"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + handle.join().expect("mock server should finish"); + + let payload: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be JSON"); + assert_eq!(payload["id"], 46); + + let (api_base, handle) = mock_github( + |request| { + assert!(request.starts_with("PATCH /repos/perishme/perish.top/issues/77 ")); + assert!(request.contains("authorization: Bearer explicit-token")); + assert!(request.contains(r#""body":"Already prefixed""#)); + }, + r#"{"number":77,"html_url":"https://github.test/issues/77"}"#, + ); + let output = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("RUNSEAL_GITHUB_API_BASE", api_base) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "body", + "update", + "--repo", + "perishme/perish.top", + "--number", + "77", + "--body", + "Already prefixed", + "--prefix-enable=true", + "--token", + "explicit-token", + ]) + .output() + .expect("runseal should run"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + handle.join().expect("mock server should finish"); +} + +#[test] +fn issue_prefix_rules() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("repo"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&cwd).expect("cwd should be created"); + std::fs::create_dir_all(&bin_dir).expect("bin dir should be created"); + write_git_stub(&bin_dir.join("git"), "", "feat/prefix"); + + let failed = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "comment", + "create", + "--repo", + "PerishCode/runseal", + "--number", + "46", + "--body", + "Hello", + "--prefix-enable=true", + ]) + .output() + .expect("runseal should run"); + assert!(!failed.status.success()); + assert!( + String::from_utf8_lossy(&failed.stderr).contains("cannot parse owner/repo from origin url"), + "stderr: {}", + String::from_utf8_lossy(&failed.stderr) + ); + + write_git_stub( + &bin_dir.join("git"), + "git@gitee.com:perishme/perish.top.git", + "feat/prefix", + ); + let (api_base, handle) = mock_github( + |request| { + assert!(request.starts_with("POST /repos/example/demo/issues/12/comments ")); + assert!(request.contains(r#""body":"Hello""#)); + assert!(!request.contains("Requested-By-Repo:")); + }, + r#"{"id":12}"#, + ); + let output = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("RUNSEAL_GITHUB_API_BASE", api_base) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "comment", + "create", + "--repo", + "example/demo", + "--number", + "12", + "--body", + "Hello", + "--prefix-enable=true", + ]) + .output() + .expect("runseal should run"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + handle.join().expect("mock server should finish"); +} + +#[test] +fn issue_body_max() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("repo"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&cwd).expect("cwd should be created"); + std::fs::create_dir_all(&bin_dir).expect("bin dir should be created"); + write_git_stub( + &bin_dir.join("git"), + "git@gitee.com:perishme/perish.top.git", + "feat/prefix", + ); + + let too_long = "x".repeat(101); + let failed = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "comment", + "create", + "--repo", + "example/demo", + "--number", + "12", + "--body", + &too_long, + ]) + .output() + .expect("runseal should run"); + assert!(!failed.status.success()); + assert!( + String::from_utf8_lossy(&failed.stderr).contains("body length 101 exceeds --body-max=100"), + "stderr: {}", + String::from_utf8_lossy(&failed.stderr) + ); + + let (api_base, handle) = mock_github( + |request| { + assert!(request.starts_with("POST /repos/example/demo/issues/12/comments ")); + assert!(request.contains(r#""body":"#)); + }, + r#"{"id":12}"#, + ); + let output = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("RUNSEAL_GITHUB_API_BASE", api_base) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "comment", + "create", + "--repo", + "example/demo", + "--number", + "12", + "--body", + &too_long, + "--body-max=0", + ]) + .output() + .expect("runseal should run"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + handle.join().expect("mock server should finish"); + + let body_update = "y".repeat(150); + let (api_base, handle) = mock_github( + |request| { + assert!(request.starts_with("PATCH /repos/example/demo/issues/12 ")); + assert!(request.contains(r#""body":"#)); + }, + r#"{"number":12}"#, + ); + let output = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("RUNSEAL_GITHUB_API_BASE", api_base) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "body", + "update", + "--repo", + "example/demo", + "--number", + "12", + "--body", + &body_update, + ]) + .output() + .expect("runseal should run"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + handle.join().expect("mock server should finish"); +} + +fn write_git_stub(path: &Path, origin: &str, branch: &str) { + write_executable( + path, + &format!( + r#"#!/usr/bin/env sh +set -eu +if [ "$1" = "remote" ] && [ "${{2:-}}" = "get-url" ] && [ "${{3:-}}" = "origin" ]; then + printf '%s\n' '{}' + exit 0 +fi +if [ "$1" = "branch" ] && [ "${{2:-}}" = "--show-current" ]; then + printf '%s\n' '{}' + exit 0 +fi +exit 1 +"#, + origin, branch + ), + ); +} + +fn write_executable(path: &Path, content: &str) { + use std::os::unix::fs::PermissionsExt; + + std::fs::write(path, content).expect("stub should be written"); + let mut permissions = std::fs::metadata(path) + .expect("stub metadata should be readable") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions).expect("stub should be executable"); +} + +fn prepend_path_for_test(bin_dir: &Path) -> OsString { + let mut paths = vec![PathBuf::from(bin_dir)]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(paths).expect("PATH should be joinable") +} + +fn mock_github(assert_request: F, body: &'static str) -> (String, thread::JoinHandle<()>) +where + F: FnOnce(&str) + Send + 'static, +{ + let server = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); + let address = server + .local_addr() + .expect("mock server address should exist"); + let handle = thread::spawn(move || { + let (mut stream, _) = server.accept().expect("mock request should arrive"); + let mut request = [0_u8; 8192]; + let read = stream + .read(&mut request) + .expect("request should be readable"); + let request = String::from_utf8_lossy(&request[..read]); + assert_request(&request); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ) + .expect("response should be written"); + }); + (format!("http://{address}"), handle) +} diff --git a/app/tests/operator/repo.rs b/app/tests/operator/repo.rs index a829945..de07754 100644 --- a/app/tests/operator/repo.rs +++ b/app/tests/operator/repo.rs @@ -34,6 +34,11 @@ case "${1:-}" in [ "${2:-}" = "--show-current" ] || exit 9 printf '%s\n' "${RUNSEAL_TEST_BRANCH:-feat/seal}" ;; + remote) + [ "${2:-}" = "get-url" ] || exit 9 + [ "${3:-}" = "origin" ] || exit 9 + printf '%s\n' "${RUNSEAL_TEST_REMOTE_ORIGIN:-git@github.com:PerishCode/runseal.git}" + ;; rev-parse) printf '%s\n' "${RUNSEAL_TEST_REF_SHA:-abc123}" ;; @@ -326,7 +331,6 @@ git push -u origin feat/seal gh pr list --head feat/seal --json number,title,state,url,isDraft gh pr create --base develop --head feat/seal --title Seal migration --body-file body.md gh pr list --head feat/seal --json number,title,state,url,isDraft -gh pr checks 77 gh pr checks 77 --watch --interval 10 gh pr merge 77 --squash --delete-branch " From 76f7b5917e1c07cc8d83506cf7cef56f89f1a7f7 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 19:09:24 +0800 Subject: [PATCH 04/17] tool: remove legacy github module file --- app/src/core/tool/github.rs | 512 ------------------------------------ 1 file changed, 512 deletions(-) delete mode 100644 app/src/core/tool/github.rs diff --git a/app/src/core/tool/github.rs b/app/src/core/tool/github.rs deleted file mode 100644 index 425a13b..0000000 --- a/app/src/core/tool/github.rs +++ /dev/null @@ -1,512 +0,0 @@ -use std::{collections::BTreeMap, path::Path, process::Command, time::Duration}; - -use anyhow::{Context, Result, bail}; -use serde_json::Value as JsonValue; - -const GITHUB_API_BASE: &str = "https://api.github.com"; -const GITHUB_API_VERSION: &str = "2022-11-28"; -const CORE_REPOS: &[&str] = &[ - "PerishCode/flavor", - "PerishCode/runseal", - "PerishCode/sidecar", -]; - -pub fn eval(command: &str, args: &[String]) -> Result> { - match command { - "issue" => issue(args), - "pr" => pr(args), - _ => bail!("unknown tool command: github {command}"), - } -} - -fn issue(args: &[String]) -> Result> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool github issue comment|body ..."); - }; - match command.as_str() { - "comment" => issue_comment(rest), - "body" => issue_body(rest), - _ => bail!("usage: runseal @tool github issue comment|body ..."), - } -} - -fn pr(args: &[String]) -> Result> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool github pr checks probe "); - }; - match command.as_str() { - "checks" => pr_checks(rest), - _ => bail!("usage: runseal @tool github pr checks probe "), - } -} - -fn pr_checks(args: &[String]) -> Result> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool github pr checks probe "); - }; - match command.as_str() { - "probe" => pr_checks_probe(rest), - _ => bail!("usage: runseal @tool github pr checks probe "), - } -} - -fn pr_checks_probe(args: &[String]) -> Result> { - let [number] = args else { - bail!("usage: runseal @tool github pr checks probe "); - }; - match pr_checks_probe_http(number, args) { - Ok(value) => Ok(Some(value)), - Err(_) => Ok(Some("true".to_string())), - } -} - -fn pr_checks_probe_http(number: &str, args: &[String]) -> Result { - let current_repo = current_github_repo_id()?; - let token = optional_token(args)?; - let pr: JsonValue = github_request( - "GET", - &format!("/repos/{current_repo}/pulls/{number}"), - token.as_deref(), - None, - )?; - let Some(sha) = pr - .get("head") - .and_then(|value| value.get("sha")) - .and_then(JsonValue::as_str) - else { - bail!("GitHub API pull request payload missing head.sha"); - }; - let checks: JsonValue = github_request( - "GET", - &format!("/repos/{current_repo}/commits/{sha}/check-runs"), - token.as_deref(), - None, - )?; - let statuses: JsonValue = github_request( - "GET", - &format!("/repos/{current_repo}/commits/{sha}/status"), - token.as_deref(), - None, - )?; - let check_runs = checks - .get("total_count") - .and_then(JsonValue::as_u64) - .unwrap_or(0); - let status_count = statuses - .get("statuses") - .and_then(JsonValue::as_array) - .map(|value| value.len()) - .unwrap_or(0); - Ok(if check_runs > 0 || status_count > 0 { - "true" - } else { - "false" - } - .to_string()) -} - -fn issue_comment(args: &[String]) -> Result> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool github issue comment create ..."); - }; - match command.as_str() { - "create" => issue_comment_create(rest), - _ => bail!("usage: runseal @tool github issue comment create ..."), - } -} - -fn issue_body(args: &[String]) -> Result> { - let [command, rest @ ..] = args else { - bail!("usage: runseal @tool github issue body update ..."); - }; - match command.as_str() { - "update" => issue_body_update(rest), - _ => bail!("usage: runseal @tool github issue body update ..."), - } -} - -fn issue_comment_create(args: &[String]) -> Result> { - let repo = required_option(args, "--repo")?; - let number = required_option(args, "--number")?; - let token = token(args)?; - let body = prepared_body(args, &repo, 100)?; - github_request_text( - "POST", - &format!("/repos/{repo}/issues/{number}/comments"), - &token, - body, - ) -} - -fn issue_body_update(args: &[String]) -> Result> { - let repo = required_option(args, "--repo")?; - let number = required_option(args, "--number")?; - let token = token(args)?; - let body = prepared_body(args, &repo, 0)?; - github_request_text( - "PATCH", - &format!("/repos/{repo}/issues/{number}"), - &token, - body, - ) -} - -fn prepared_body(args: &[String], target_repo: &str, default_body_max: usize) -> Result { - let mut body = read_body(args)?; - validate_body_max(args, &body, default_body_max)?; - if prefix_enabled(args)? { - body = prefix_body(target_repo, &body)?; - } - Ok(body) -} - -fn prefix_body(target_repo: &str, body: &str) -> Result { - if !core_repo_contains(target_repo) { - return Ok(body.to_string()); - } - let current_repo = current_repo_id()?; - if current_repo.eq_ignore_ascii_case(target_repo) { - return Ok(body.to_string()); - } - let branch = current_branch()?; - let prefix = format!("Requested-By-Repo: {current_repo}\nRequested-By-Branch: {branch}\n\n"); - if body.starts_with(&prefix) { - return Ok(body.to_string()); - } - Ok(format!("{prefix}{body}")) -} - -fn github_request_text( - method: &str, - path: &str, - token: &str, - body: String, -) -> Result> { - github_request( - method, - path, - Some(token), - Some(serde_json::json!({ - "body": body, - })), - ) - .map(|payload| Some(serde_json::to_string(&payload).expect("GitHub payload should serialize"))) -} - -fn github_request( - method: &str, - path: &str, - token: Option<&str>, - body: Option, -) -> Result { - let base = - std::env::var("RUNSEAL_GITHUB_API_BASE").unwrap_or_else(|_| GITHUB_API_BASE.to_string()); - let path = if path.starts_with('/') { - path.to_string() - } else { - format!("/{path}") - }; - let url = format!("{base}{path}"); - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - let method = method - .parse::() - .with_context(|| format!("invalid HTTP method: {method}"))?; - let mut request = client - .request(method.clone(), &url) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") - .header("X-GitHub-Api-Version", GITHUB_API_VERSION) - .header(reqwest::header::USER_AGENT, "runseal"); - if let Some(token) = token.filter(|value| !value.is_empty()) { - request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")); - } - if let Some(body) = body { - request = request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&body); - } - let response = request - .send() - .with_context(|| format!("GitHub API {method} {path} unreachable"))?; - let status = response.status(); - let raw = response - .text() - .with_context(|| format!("GitHub API {method} {path} returned unreadable body"))?; - if !status.is_success() { - bail!("GitHub API {method} {path} -> {}: {raw}", status.as_u16()); - } - if raw.trim().is_empty() { - return Ok(JsonValue::Object(Default::default())); - } - serde_json::from_str(&raw) - .with_context(|| format!("GitHub API returned invalid JSON for {path}")) -} - -fn read_body(args: &[String]) -> Result { - let inline = optional_option(args, "--body"); - let file = optional_option(args, "--body-file"); - match (inline, file) { - (Some(_), Some(_)) => bail!("pass exactly one of --body or --body-file"), - (None, None) => bail!("pass exactly one of --body or --body-file"), - (Some(body), None) => Ok(body), - (None, Some(path)) => std::fs::read_to_string(&path) - .with_context(|| format!("failed to read body file: {path}")), - } -} - -fn validate_body_max(args: &[String], body: &str, default_body_max: usize) -> Result<()> { - let body_max = body_max(args, default_body_max)?; - if body_max == 0 { - return Ok(()); - } - let count = body.chars().count(); - if count > body_max { - bail!("body length {count} exceeds --body-max={body_max}"); - } - Ok(()) -} - -fn body_max(args: &[String], default: usize) -> Result { - let Some(value) = optional_option(args, "--body-max") else { - return Ok(default); - }; - value - .parse::() - .with_context(|| format!("invalid --body-max: {value}")) -} - -fn token(args: &[String]) -> Result { - if let Some(token) = optional_option(args, "--token") - && !token.is_empty() - { - return Ok(token); - } - if let Some(path) = optional_option(args, "--token-file") { - let values = parse_env_file(Path::new(&path))?; - if let Some(token) = values.get("GITHUB_TOKEN").filter(|value| !value.is_empty()) { - return Ok(token.clone()); - } - bail!("GITHUB_TOKEN not set in {path}"); - } - if let Some(name) = optional_option(args, "--token-env") { - let token = std::env::var(&name) - .with_context(|| format!("environment variable not set: {name}"))?; - if token.is_empty() { - bail!("environment variable is empty: {name}"); - } - return Ok(token); - } - if let Ok(token) = std::env::var("GITHUB_TOKEN") - && !token.is_empty() - { - return Ok(token); - } - bail!("missing GitHub token: pass --token, --token-file, --token-env, or set GITHUB_TOKEN") -} - -fn optional_token(args: &[String]) -> Result> { - if let Some(token) = optional_option(args, "--token") - && !token.is_empty() - { - return Ok(Some(token)); - } - if let Some(path) = optional_option(args, "--token-file") { - let values = parse_env_file(Path::new(&path))?; - if let Some(token) = values.get("GITHUB_TOKEN").filter(|value| !value.is_empty()) { - return Ok(Some(token.clone())); - } - bail!("GITHUB_TOKEN not set in {path}"); - } - if let Some(name) = optional_option(args, "--token-env") { - let token = std::env::var(&name) - .with_context(|| format!("environment variable not set: {name}"))?; - if token.is_empty() { - bail!("environment variable is empty: {name}"); - } - return Ok(Some(token)); - } - Ok(std::env::var("GITHUB_TOKEN") - .ok() - .filter(|value| !value.is_empty())) -} - -fn prefix_enabled(args: &[String]) -> Result { - optional_bool_option(args, "--prefix-enable").map(|value| value.unwrap_or(false)) -} - -fn optional_bool_option(args: &[String], name: &str) -> Result> { - let prefix = format!("{name}="); - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == name { - return Ok(Some(true)); - } - if let Some(value) = arg.strip_prefix(&prefix) { - return Ok(Some(parse_bool(name, value)?)); - } - if arg == name { - let Some(value) = args.get(index + 1) else { - return Ok(Some(true)); - }; - if !value.starts_with('-') { - return Ok(Some(parse_bool(name, value)?)); - } - return Ok(Some(true)); - } - index += 1; - } - Ok(None) -} - -fn parse_bool(name: &str, value: &str) -> Result { - match value { - "true" => Ok(true), - "false" => Ok(false), - _ => bail!("{name} expects true or false"), - } -} - -fn current_repo_id() -> Result { - let output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output() - .with_context(|| "failed to execute command: git")?; - if !output.status.success() { - bail!( - "git remote get-url origin failed with status {}", - output.status.code().unwrap_or(1) - ); - } - let url = String::from_utf8(output.stdout) - .with_context(|| "git remote get-url origin returned non-UTF-8 output")?; - parse_repo_id(url.trim()) -} - -fn current_github_repo_id() -> Result { - let repo = current_repo_id()?; - if repo.contains('/') { - return Ok(repo); - } - bail!("cannot parse GitHub owner/repo from current repository") -} - -fn current_branch() -> Result { - let output = Command::new("git") - .args(["branch", "--show-current"]) - .output() - .with_context(|| "failed to execute command: git")?; - if !output.status.success() { - bail!( - "git branch --show-current failed with status {}", - output.status.code().unwrap_or(1) - ); - } - let branch = String::from_utf8(output.stdout) - .with_context(|| "git branch --show-current returned non-UTF-8 output")?; - let branch = branch.trim(); - if branch.is_empty() { - bail!("git branch --show-current returned an empty branch name"); - } - Ok(branch.to_string()) -} - -fn parse_repo_id(url: &str) -> Result { - if let Ok(repo) = parse_hosted_repo_id( - url, - &[ - "git@github.com:", - "ssh://git@github.com/", - "https://github.com/", - "http://github.com/", - ], - ) { - return Ok(repo); - } - if let Ok(repo) = parse_hosted_repo_id( - url, - &[ - "git@gitee.com:", - "ssh://git@gitee.com/", - "https://gitee.com/", - "http://gitee.com/", - ], - ) { - return Ok(repo); - } - bail!("cannot parse owner/repo from origin url: {url}"); -} - -fn parse_hosted_repo_id(url: &str, prefixes: &[&str]) -> Result { - let Some(after_host) = url - .strip_prefix(prefixes[0]) - .or_else(|| url.strip_prefix(prefixes[1])) - .or_else(|| url.strip_prefix(prefixes[2])) - .or_else(|| url.strip_prefix(prefixes[3])) - else { - bail!("unmatched host"); - }; - let path = after_host.trim_end_matches(".git"); - let mut parts = path.split('/'); - let Some(owner) = parts.next().filter(|value| !value.is_empty()) else { - bail!("cannot parse owner/repo from origin url: {url}"); - }; - let Some(repo) = parts.next().filter(|value| !value.is_empty()) else { - bail!("cannot parse owner/repo from origin url: {url}"); - }; - if parts.next().is_some() { - bail!("cannot parse owner/repo from origin url: {url}"); - } - Ok(format!("{owner}/{repo}")) -} - -fn parse_env_file(path: &Path) -> Result> { - let text = std::fs::read_to_string(path) - .with_context(|| format!("failed to read token file: {}", path.display()))?; - let mut values = BTreeMap::new(); - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - bail!("invalid line in {}: {line}", path.display()); - }; - values.insert( - key.trim().to_string(), - value - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(), - ); - } - Ok(values) -} - -fn required_option(args: &[String], name: &str) -> Result { - optional_option(args, name).ok_or_else(|| anyhow::anyhow!("{name} is required")) -} - -fn optional_option(args: &[String], name: &str) -> Option { - let prefix = format!("{name}="); - let mut index = 0; - while index < args.len() { - let arg = &args[index]; - if arg == name { - return args.get(index + 1).cloned(); - } - if let Some(value) = arg.strip_prefix(&prefix) { - return Some(value.to_string()); - } - index += 1; - } - None -} - -fn core_repo_contains(repo: &str) -> bool { - CORE_REPOS - .iter() - .any(|value| value.eq_ignore_ascii_case(repo)) -} From 099da7ee838042901d07d027da8d8e383739722e Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 19:47:57 +0800 Subject: [PATCH 05/17] tool: add GitHub issue create --- app/src/core/tool/github/mod.rs | 41 +++++++++++++++++++-- app/src/core/tool/help/github.rs | 42 ++++++++++++++++++++++ app/src/core/tool/help/mod.rs | 1 + app/tests/internal_tool/github.rs | 59 +++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 2 deletions(-) diff --git a/app/src/core/tool/github/mod.rs b/app/src/core/tool/github/mod.rs index c2a27bc..bdd7e71 100644 --- a/app/src/core/tool/github/mod.rs +++ b/app/src/core/tool/github/mod.rs @@ -18,12 +18,13 @@ pub fn eval(command: &str, args: &[String]) -> Result> { fn issue(args: &[String]) -> Result> { let [command, rest @ ..] = args else { - bail!("usage: runseal @tool github issue comment|body ..."); + bail!("usage: runseal @tool github issue create|comment|body ..."); }; match command.as_str() { + "create" => issue_create(rest), "comment" => issue_comment(rest), "body" => issue_body(rest), - _ => bail!("usage: runseal @tool github issue comment|body ..."), + _ => bail!("usage: runseal @tool github issue create|comment|body ..."), } } @@ -112,6 +113,25 @@ fn issue_comment(args: &[String]) -> Result> { } } +fn issue_create(args: &[String]) -> Result> { + let repo = support::required_option(args, "--repo")?; + let title = support::required_option(args, "--title")?; + let token = token(args)?; + let body = optional_prepared_body(args, &repo, 0)?; + let mut payload = serde_json::Map::new(); + payload.insert("title".to_string(), serde_json::Value::String(title)); + if let Some(body) = body { + payload.insert("body".to_string(), serde_json::Value::String(body)); + } + github_request( + "POST", + &format!("/repos/{repo}/issues"), + Some(&token), + Some(serde_json::Value::Object(payload)), + ) + .map(|payload| Some(serde_json::to_string(&payload).expect("GitHub payload should serialize"))) +} + fn issue_body(args: &[String]) -> Result> { let [command, rest @ ..] = args else { bail!("usage: runseal @tool github issue body update ..."); @@ -157,6 +177,23 @@ fn prepared_body(args: &[String], target_repo: &str, default_body_max: usize) -> Ok(body) } +fn optional_prepared_body( + args: &[String], + target_repo: &str, + default_body_max: usize, +) -> Result> { + let has_body = args + .iter() + .any(|arg| arg == "--body" || arg.starts_with("--body=")); + let has_body_file = args + .iter() + .any(|arg| arg == "--body-file" || arg.starts_with("--body-file=")); + match (has_body, has_body_file) { + (false, false) => Ok(None), + _ => prepared_body(args, target_repo, default_body_max).map(Some), + } +} + fn prefix_body(target_repo: &str, body: &str) -> Result { if !core_repo_contains(target_repo) { return Ok(body.to_string()); diff --git a/app/src/core/tool/help/github.rs b/app/src/core/tool/help/github.rs index 4164dfa..62e0236 100644 --- a/app/src/core/tool/help/github.rs +++ b/app/src/core/tool/help/github.rs @@ -7,6 +7,10 @@ pub const GITHUB: Entry = Entry { sections: &[Section { title: "GitHub helpers", items: &[ + ( + "issue create", + "create one issue with optional body shaping", + ), ( "issue comment create", "create one issue-style comment; also applies to top-level PR comments", @@ -29,6 +33,7 @@ pub const GITHUB_ISSUE: Entry = Entry { sections: &[Section { title: "GitHub issue helpers", items: &[ + ("create", "create one issue"), ("comment create", "create one issue-style comment"), ("body update", "update one issue-style body"), ], @@ -36,6 +41,43 @@ pub const GITHUB_ISSUE: Entry = Entry { examples: &[], }; +pub const GITHUB_ISSUE_CREATE: Entry = Entry { + key: "github.issue.create", + usage: "runseal @tool github issue create --repo --title [--body |--body-file ] [--body-max ] [--prefix-enable=] [--token |--token-file |--token-env ]", + about: Some( + "Create one GitHub issue and print the API response JSON. Body is optional. Default `--body-max` is `0`, which means unlimited.", + ), + sections: &[Section { + title: "Flags", + items: &[ + ("--repo ", "target GitHub repository"), + ("--title ", "issue title"), + ("--body ", "inline body text"), + ("--body-file ", "read body text from one file"), + ( + "--body-max ", + "maximum user-body length; `0` disables the limit; default `0`", + ), + ( + "--prefix-enable=", + "prepend requested-by metadata for matching cross-repo writes", + ), + ("--token ", "explicit GitHub token"), + ( + "--token-file ", + "env-style file containing `GITHUB_TOKEN`", + ), + ( + "--token-env ", + "read the token from one named environment variable", + ), + ], + }], + examples: &[ + "runseal @tool github issue create --repo PerishCode/runseal --title demo --body-file body.md --prefix-enable=true", + ], +}; + pub const GITHUB_ISSUE_COMMENT: Entry = Entry { key: "github.issue.comment", usage: "runseal @tool github issue comment create [args]", diff --git a/app/src/core/tool/help/mod.rs b/app/src/core/tool/help/mod.rs index 3148d9e..46c6727 100644 --- a/app/src/core/tool/help/mod.rs +++ b/app/src/core/tool/help/mod.rs @@ -63,6 +63,7 @@ const ENTRIES: &[Entry] = &[ hash_version::VERSION_COMPARE, github::GITHUB, github::GITHUB_ISSUE, + github::GITHUB_ISSUE_CREATE, github::GITHUB_ISSUE_COMMENT, github::GITHUB_ISSUE_COMMENT_CREATE, github::GITHUB_ISSUE_BODY, diff --git a/app/tests/internal_tool/github.rs b/app/tests/internal_tool/github.rs index 237aa84..42000a6 100644 --- a/app/tests/internal_tool/github.rs +++ b/app/tests/internal_tool/github.rs @@ -117,6 +117,65 @@ fn issue_comment_write() { handle.join().expect("mock server should finish"); } +#[test] +fn issue_create() { + let temp = TempDir::new().expect("temp dir should be created"); + let cwd = temp.path().join("repo"); + let bin_dir = temp.path().join("bin"); + std::fs::create_dir_all(&cwd).expect("cwd should be created"); + std::fs::create_dir_all(&bin_dir).expect("bin dir should be created"); + write_git_stub( + &bin_dir.join("git"), + "git@gitee.com:perishme/perish.top.git", + "feat/prefix", + ); + + let body_file = cwd.join("body.md"); + std::fs::write(&body_file, "Issue body\n").expect("body file should be written"); + let (api_base, handle) = mock_github( + |request| { + assert!(request.starts_with("POST /repos/PerishCode/runseal/issues ")); + assert!(request.contains("authorization: Bearer env-token")); + assert!(request.contains(r#""title":"Open one issue""#)); + assert!(request.contains( + r#"Requested-By-Repo: perishme/perish.top\nRequested-By-Branch: feat/prefix\n\nIssue body\n"# + )); + }, + r#"{"number":88,"html_url":"https://github.test/issues/88"}"#, + ); + let output = bin() + .current_dir(&cwd) + .env("PATH", prepend_path_for_test(&bin_dir)) + .env("RUNSEAL_HOME", temp.path().join("home")) + .env("RUNSEAL_GITHUB_API_BASE", api_base) + .env("GITHUB_TOKEN", "env-token") + .args([ + "@tool", + "github", + "issue", + "create", + "--repo", + "PerishCode/runseal", + "--title", + "Open one issue", + "--body-file", + body_file.to_str().unwrap(), + "--prefix-enable=true", + ]) + .output() + .expect("runseal should run"); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + handle.join().expect("mock server should finish"); + + let payload: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("stdout should be JSON"); + assert_eq!(payload["number"], 88); +} + #[test] fn issue_prefix_rules() { let temp = TempDir::new().expect("temp dir should be created"); From 56378031ac5a5e01d5d8ac51311842a3810010b6 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 21:11:32 +0800 Subject: [PATCH 06/17] seal: add basic required and fallback expansion --- AGENTS.md | 53 ++- app/src/core/transpile/ast.rs | 28 +- app/src/core/transpile/emit/powershell.rs | 54 ++- .../core/transpile/emit/powershell_support.rs | 22 +- app/src/core/transpile/emit/support.rs | 36 +- app/src/core/transpile/frontend/powershell.rs | 191 +---------- .../transpile/frontend/powershell_value.rs | 315 ++++++++++++++++++ app/src/core/transpile/parse_lex.rs | 32 +- app/src/core/transpile/runner/mod.rs | 115 +++++-- app/src/core/transpile/value.rs | 89 +++-- app/tests/transpile.rs | 8 +- app/tests/transpile_cases.rs | 2 + app/tests/transpile_cases/expansion.rs | 183 ++++++++++ 13 files changed, 836 insertions(+), 292 deletions(-) create mode 100644 app/src/core/transpile/frontend/powershell_value.rs create mode 100644 app/tests/transpile_cases/expansion.rs diff --git a/AGENTS.md b/AGENTS.md index 4b47e32..5667f78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,18 @@ This top-level `AGENTS.md` is the repository navigation and policy layer. Core product stance: -- Small CLI. Explicit profile. No hidden orchestration. +- `runseal` exists to reduce environment-dependency complexity in real + cross-platform operations work: too many environment variables, too many + machine-specific assumptions, and too much operational glue falling back to + Python or JavaScript even when the underlying workflow is clear and finite. +- Explicit profile. No hidden orchestration. +- Prefer a stable shared subset of `bash` and PowerShell for `.seal`, plus + explicit atomic `@tool` capabilities for needs that do not fit that shared + shell surface cleanly. +- Preserve cross-shell semantic equivalence as the hard constraint. Do not + require local syntax symmetry or IR-level elegance when a `.seal` behavior is + still clear, finite, and can be translated reliably into a more awkward + PowerShell form. - Keep the Rust core thin and concrete. - Support only `env`, `symlink`, fixed-prefix `argv`, explicit `:wrapper` resolution, direct `.seal` execution, and read-only `@internal` @@ -142,11 +153,39 @@ Successful profile and wrapper paths are normalized absolute paths. ## 5. FAQ -### Why keep the CLI surface small? +### What defines the CLI surface? -Because this repository is building explicit runtime glue, not a hidden -orchestrator. New behavior should be added only when it fits the existing -surface cleanly. +This repository is building explicit runtime glue, not a hidden orchestrator. +New behavior should be added only when it fits one of two shapes cleanly: + +- shared `bash` and PowerShell semantics that are worth making first-class in + `.seal` +- an explicit atomic `@tool` + +This boundary comes from the actual problem `runseal` is trying to solve: +clear operational workflows should not need to depend on heavyweight language +runtimes or repository-local script stacks just to survive environment drift, +cross-platform differences, and routine operator setup friction. + +The goal is not "be as small as possible". The goal is to absorb the +right kind of complexity: + +- clear, finite, cross-platform operational flow control should fit in + `runseal` +- shell-specific cleverness, open-ended scripting power, and accidental runtime + dependency sprawl should not + +That is why the product boundary is a shared shell subset plus explicit atomic +tools, rather than a general scripting platform or a partial shell clone. + +The hard promise is behavioral equivalence across `bash` and PowerShell, not +surface-level symmetry in generated code. Some worthwhile `.seal` semantics may +compile into elegant `bash` and relatively ugly PowerShell. That tradeoff is +acceptable when: + +- the `.seal` behavior is clear and finite +- the translation is reliable and testable +- the result still serves explicit cross-platform operations flow control ### When should behavior become Seal syntax? @@ -156,12 +195,12 @@ worth making first-class. ### When should behavior become `@tool`? When native CLI coverage is insufficient for an atomic, reusable operation and -the result still fits the small explicit model. +the result still fits the explicit atomic-tool model. ### When should logic stay outside runseal? When the behavior cannot be described cleanly as shared shell-shape syntax or a -small atomic tool, keep it in Python, Ruby, JavaScript, or another external +clear atomic tool, keep it in Python, Ruby, JavaScript, or another external script. ### Should `.seal` wrappers be treated as first-class runtime entrypoints? diff --git a/app/src/core/transpile/ast.rs b/app/src/core/transpile/ast.rs index 6cb673e..02d874b 100644 --- a/app/src/core/transpile/ast.rs +++ b/app/src/core/transpile/ast.rs @@ -105,13 +105,33 @@ pub enum ArgvKind { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Value { - Literal { text: String }, + Literal { + text: String, + }, Argc, - Var { name: String }, Args, + Expand { + source: ValueSource, + op: ExpansionOp, + }, + Concat { + parts: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ValueSource { + Var { name: String }, Env { name: String }, - EnvDefault { name: String, default: String }, - Concat { parts: Vec }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExpansionOp { + Plain, + DefaultIfUnsetOrEmpty { fallback: String }, + RequireNonEmpty { message: String }, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/app/src/core/transpile/emit/powershell.rs b/app/src/core/transpile/emit/powershell.rs index 26137f3..825222d 100644 --- a/app/src/core/transpile/emit/powershell.rs +++ b/app/src/core/transpile/emit/powershell.rs @@ -1,7 +1,8 @@ use super::powershell_support::{emit_positional_bindings, max_positional_statements}; use super::support::{generated_header, option_name}; use crate::core::transpile::ast::{ - ArgvKind, ArgvSpec, EnvAssign, Item, OutputStream, Predicate, Program, Statement, Value, + ArgvKind, ArgvSpec, EnvAssign, ExpansionOp, Item, OutputStream, Predicate, Program, Statement, + Value, ValueSource, }; pub(crate) fn emit_powershell(program: &Program, source_name: Option<&str>) -> String { @@ -356,15 +357,8 @@ fn powershell_value(value: &Value) -> String { match value { Value::Literal { text } => powershell_quote(text), Value::Argc => "$args.Count".to_string(), - Value::Var { name } => format!("${name}"), Value::Args => "@args".to_string(), - Value::Env { name } => format!("$env:{name}"), - Value::EnvDefault { name, default } => { - format!( - "$(if ($env:{name}) {{ $env:{name} }} else {{ {} }})", - powershell_quote(default) - ) - } + Value::Expand { source, op } => powershell_expand(source, op), Value::Concat { parts } => { if parts.is_empty() { return "''".to_string(); @@ -379,6 +373,48 @@ fn powershell_value(value: &Value) -> String { } } +fn powershell_expand(source: &ValueSource, op: &ExpansionOp) -> String { + match op { + ExpansionOp::Plain => powershell_source_value(source), + ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => { + powershell_guarded_expand(source, fallback, false) + } + ExpansionOp::RequireNonEmpty { message } => { + powershell_guarded_expand(source, message, true) + } + } +} + +fn powershell_source_value(source: &ValueSource) -> String { + match source { + ValueSource::Var { name } => format!("${name}"), + ValueSource::Env { name } => format!("$env:{name}"), + } +} + +fn powershell_guarded_expand(source: &ValueSource, text: &str, require: bool) -> String { + let fallback_or_message = powershell_quote(text); + let action = if require { + format!("throw {fallback_or_message}") + } else { + fallback_or_message + }; + match source { + ValueSource::Env { name } => format!( + "$(if ([string]::IsNullOrEmpty($env:{name})) {{ {action} }} else {{ $env:{name} }})" + ), + ValueSource::Var { name } if name.bytes().all(|byte| byte.is_ascii_digit()) => { + let index = name.parse::().unwrap_or_default(); + format!( + "$(if (($args.Count -lt {index}) -or [string]::IsNullOrEmpty(${name})) {{ {action} }} else {{ ${name} }})" + ) + } + ValueSource::Var { name } => { + format!("$(if ([string]::IsNullOrEmpty(${name})) {{ {action} }} else {{ ${name} }})") + } + } +} + fn emit_env_exec(out: &mut String, pad: &str, env: &[EnvAssign], argv: &[Value]) { out.push_str(&format!("{pad}& {{\n")); for item in env { diff --git a/app/src/core/transpile/emit/powershell_support.rs b/app/src/core/transpile/emit/powershell_support.rs index d2b59dc..d9aeef7 100644 --- a/app/src/core/transpile/emit/powershell_support.rs +++ b/app/src/core/transpile/emit/powershell_support.rs @@ -1,4 +1,4 @@ -use crate::core::transpile::ast::{Predicate, Statement, Value}; +use crate::core::transpile::ast::{ExpansionOp, Predicate, Statement, Value, ValueSource}; pub(super) fn emit_positional_bindings(out: &mut String, indent: usize, max: usize) -> bool { if max == 0 { @@ -96,16 +96,24 @@ fn max_positional_predicate(predicate: &Predicate) -> usize { fn max_positional_value(value: &Value) -> usize { match value { - Value::Var { name } => name.parse::().unwrap_or_default(), + Value::Expand { source, op } => max_positional_expand(source, op), Value::Concat { parts } => parts .iter() .map(max_positional_value) .max() .unwrap_or_default(), - Value::Literal { .. } - | Value::Argc - | Value::Args - | Value::Env { .. } - | Value::EnvDefault { .. } => 0, + Value::Literal { .. } | Value::Argc | Value::Args => 0, + } +} + +fn max_positional_expand(source: &ValueSource, op: &ExpansionOp) -> usize { + let source_max = match source { + ValueSource::Var { name } => name.parse::().unwrap_or_default(), + ValueSource::Env { .. } => 0, + }; + match op { + ExpansionOp::Plain + | ExpansionOp::DefaultIfUnsetOrEmpty { .. } + | ExpansionOp::RequireNonEmpty { .. } => source_max, } } diff --git a/app/src/core/transpile/emit/support.rs b/app/src/core/transpile/emit/support.rs index e5a0335..458f91c 100644 --- a/app/src/core/transpile/emit/support.rs +++ b/app/src/core/transpile/emit/support.rs @@ -1,4 +1,4 @@ -use crate::core::transpile::ast::{Predicate, Value}; +use crate::core::transpile::ast::{ExpansionOp, Predicate, Value, ValueSource}; pub(super) fn option_name(name: &str) -> String { format!("--{}", name.replace('_', "-")) @@ -57,10 +57,8 @@ pub(super) fn seal_value(value: &Value) -> String { match value { Value::Literal { text } => sh_quote(text), Value::Argc => "$#".to_string(), - Value::Var { name } => format!("${name}"), Value::Args => "\"$@\"".to_string(), - Value::Env { name } => format!("${{{name}}}"), - Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), + Value::Expand { source, op } => seal_expand(source, op), Value::Concat { parts } => { let inner = parts .iter() @@ -80,20 +78,16 @@ pub(super) fn bash_value(value: &Value) -> String { match value { Value::Literal { text } => sh_quote(text), Value::Argc => "\"$#\"".to_string(), - Value::Var { name } => format!("\"${name}\""), Value::Args => "\"$@\"".to_string(), - Value::Env { name } => format!("\"${{{name}}}\""), - Value::EnvDefault { name, default } => format!("\"${{{name}:-{default}}}\""), + Value::Expand { source, op } => format!("\"{}\"", seal_expand(source, op)), Value::Concat { parts } => double_quote( &parts .iter() .map(|part| match part { Value::Literal { text } => text.clone(), Value::Argc => "$#".to_string(), - Value::Var { name } => format!("${name}"), Value::Args => "$@".to_string(), - Value::Env { name } => format!("${{{name}}}"), - Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), + Value::Expand { source, op } => seal_expand(source, op), Value::Concat { .. } => bash_value(part), }) .collect::(), @@ -104,15 +98,31 @@ pub(super) fn bash_value(value: &Value) -> String { pub(super) fn bash_int_value(value: &Value) -> String { match value { Value::Argc => "$#".to_string(), - Value::Var { name } => format!("${name}"), Value::Args => "\"$@\"".to_string(), - Value::Env { name } => format!("${{{name}}}"), - Value::EnvDefault { name, default } => format!("${{{name}:-{default}}}"), + Value::Expand { source, op } => seal_expand(source, op), Value::Literal { text } => sh_quote(text), Value::Concat { .. } => bash_value(value), } } +fn seal_expand(source: &ValueSource, op: &ExpansionOp) -> String { + let name = match source { + ValueSource::Var { name } | ValueSource::Env { name } => name, + }; + match op { + ExpansionOp::Plain => match source { + ValueSource::Var { .. } => format!("${name}"), + ValueSource::Env { .. } => format!("${{{name}}}"), + }, + ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => { + format!("${{{name}:-{fallback}}}") + } + ExpansionOp::RequireNonEmpty { message } => { + format!("${{{name}:?{message}}}") + } + } +} + pub(super) fn sh_quote(value: &str) -> String { if value.is_empty() { return "''".to_string(); diff --git a/app/src/core/transpile/frontend/powershell.rs b/app/src/core/transpile/frontend/powershell.rs index 09988ea..4f67d91 100644 --- a/app/src/core/transpile/frontend/powershell.rs +++ b/app/src/core/transpile/frontend/powershell.rs @@ -1,8 +1,17 @@ use anyhow::{Result, bail}; +#[path = "powershell_value.rs"] +mod powershell_value; + +pub(crate) use self::powershell_value::parse_value; +use self::powershell_value::{ + assignment, is_generated_positional_binding, parse_argv, parse_pattern, strip_comment, + validate_name, +}; use super::predicate::parse_powershell_predicate; use crate::core::transpile::ast::{CaseArm, Item, Program, Statement, Value}; use crate::core::transpile::lower::lower_functions; +use crate::core::transpile::parse_lex::is_valid_name; #[derive(Debug, Clone)] struct Line { @@ -29,6 +38,7 @@ impl Parser { if text.is_empty() || text.starts_with("# Generated by ") || text == "$ErrorActionPreference = 'Stop'" + || is_generated_positional_binding(&text) { return None; } @@ -234,10 +244,7 @@ fn parse_simple_statement(line: &Line) -> Result { } if let Some(argv) = line.text.strip_prefix("& ") { return Ok(Statement::ExecChecked { - argv: split_exprs(argv, line.number)? - .iter() - .map(|arg| parse_value(arg, line.number)) - .collect::>>()?, + argv: parse_argv(argv, line.number)?, }); } if is_valid_name(&line.text) { @@ -253,179 +260,3 @@ fn parse_simple_statement(line: &Line) -> Result { line.text ) } - -fn parse_argv(text: &str, line: usize) -> Result> { - split_exprs(text, line)? - .iter() - .map(|arg| parse_argv_value(arg, line)) - .collect::>>() -} - -fn parse_argv_value(text: &str, line: usize) -> Result { - parse_value(text, line).or_else(|_| { - if is_valid_name(text) { - Ok(Value::Literal { - text: text.to_string(), - }) - } else { - bail!("{line}: unsupported PowerShell argv value: {text}") - } - }) -} - -fn assignment(text: &str) -> Option<(String, &str)> { - let (name, value) = text.split_once(" = ")?; - let name = name.strip_prefix('$')?; - is_valid_name(name).then_some((name.to_string(), value)) -} - -pub(super) fn parse_value(text: &str, line: usize) -> Result { - let text = text.trim(); - if let Some(value) = text - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - { - return Ok(Value::Literal { - text: value.replace("''", "'"), - }); - } - if let Some((name, default)) = env_default(text) { - validate_name(name, line)?; - return Ok(Value::EnvDefault { - name: name.to_string(), - default: default.to_string(), - }); - } - if let Some(name) = text.strip_prefix("$env:") { - validate_name(name, line)?; - return Ok(Value::Env { - name: name.to_string(), - }); - } - if let Some(name) = text.strip_prefix('$') { - validate_name(name, line)?; - return Ok(Value::Var { - name: name.to_string(), - }); - } - if text.bytes().all(|byte| byte.is_ascii_digit()) { - return Ok(Value::Literal { - text: text.to_string(), - }); - } - if let Some(inner) = text - .strip_prefix('(') - .and_then(|value| value.strip_suffix(')')) - && inner.contains(" + ") - { - return Ok(Value::Concat { - parts: split_concat(inner, line)? - .iter() - .map(|part| parse_value(part, line)) - .collect::>>()?, - }); - } - bail!("{line}: unsupported PowerShell value: {text}") -} - -fn env_default(text: &str) -> Option<(&str, &str)> { - let inner = text.strip_prefix("$(if ($env:")?; - let (name, rest) = inner.split_once(") { $env:")?; - let (name2, rest) = rest.split_once(" } else { ")?; - if name != name2 { - return None; - } - let default = rest.strip_suffix(" })")?; - let default = default.strip_prefix('\'')?.strip_suffix('\'')?; - Some((name, default)) -} - -fn split_exprs(text: &str, line: usize) -> Result> { - split_top_level(text, line, ' ') -} - -fn split_concat(text: &str, line: usize) -> Result> { - split_top_level(text, line, '+').map(|items| { - items - .into_iter() - .map(|item| item.trim().to_string()) - .collect() - }) -} - -fn split_top_level(text: &str, line: usize, delimiter: char) -> Result> { - let mut items = Vec::new(); - let mut current = String::new(); - let mut quote = false; - let mut depth = 0usize; - for ch in text.chars() { - match ch { - '\'' => { - quote = !quote; - current.push(ch); - } - '(' if !quote => { - depth += 1; - current.push(ch); - } - ')' if !quote => { - depth = depth.saturating_sub(1); - current.push(ch); - } - ch if ch == delimiter && !quote && depth == 0 => { - if !current.trim().is_empty() { - items.push(current.trim().to_string()); - current.clear(); - } - } - _ => current.push(ch), - } - } - if quote { - bail!("{line}: unterminated PowerShell string"); - } - if !current.trim().is_empty() { - items.push(current.trim().to_string()); - } - Ok(items) -} - -fn strip_comment(line: &str) -> String { - let mut output = String::new(); - let mut quote = false; - for ch in line.chars() { - match ch { - '\'' => { - quote = !quote; - output.push(ch); - } - '#' if !quote => break, - _ => output.push(ch), - } - } - output -} - -fn parse_pattern(pattern: &str) -> String { - if pattern == "Default" { - return "*".to_string(); - } - pattern - .strip_prefix('\'') - .and_then(|value| value.strip_suffix('\'')) - .unwrap_or(pattern) - .to_string() -} - -fn validate_name(name: &str, line: usize) -> Result<()> { - if !is_valid_name(name) { - bail!("{line}: invalid PowerShell name: {name}"); - } - Ok(()) -} - -fn is_valid_name(name: &str) -> bool { - let mut bytes = name.bytes(); - matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') - && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') -} diff --git a/app/src/core/transpile/frontend/powershell_value.rs b/app/src/core/transpile/frontend/powershell_value.rs new file mode 100644 index 0000000..976ae60 --- /dev/null +++ b/app/src/core/transpile/frontend/powershell_value.rs @@ -0,0 +1,315 @@ +use anyhow::{Result, bail}; + +use crate::core::transpile::ast::{ExpansionOp, Value, ValueSource}; + +pub(super) fn parse_argv(text: &str, line: usize) -> Result> { + split_exprs(text, line)? + .iter() + .map(|arg| parse_argv_value(arg, line)) + .collect::>>() +} + +pub(crate) fn parse_value(text: &str, line: usize) -> Result { + let text = text.trim(); + if let Some(value) = text + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + return Ok(Value::Literal { + text: value.replace("''", "'"), + }); + } + if let Some((source, op)) = guarded_expand(text, line)? { + return Ok(Value::Expand { source, op }); + } + if let Some(name) = text.strip_prefix("$env:") { + validate_name(name, line)?; + return Ok(Value::Expand { + source: ValueSource::Env { + name: name.to_string(), + }, + op: ExpansionOp::Plain, + }); + } + if let Some(name) = text.strip_prefix('$') { + if is_valid_positional(name) { + return Ok(Value::Expand { + source: ValueSource::Var { + name: name.to_string(), + }, + op: ExpansionOp::Plain, + }); + } + validate_name(name, line)?; + return Ok(Value::Expand { + source: ValueSource::Var { + name: name.to_string(), + }, + op: ExpansionOp::Plain, + }); + } + if text.bytes().all(|byte| byte.is_ascii_digit()) { + return Ok(Value::Literal { + text: text.to_string(), + }); + } + if let Some(inner) = text + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + && inner.contains(" + ") + { + return Ok(Value::Concat { + parts: split_concat(inner, line)? + .iter() + .map(|part| parse_value(part, line)) + .collect::>>()?, + }); + } + bail!("{line}: unsupported PowerShell value: {text}") +} + +pub(super) fn assignment(text: &str) -> Option<(String, &str)> { + let (name, value) = text.split_once(" = ")?; + let name = name.strip_prefix('$')?; + is_valid_var_name(name).then_some((name.to_string(), value)) +} + +pub(super) fn parse_pattern(pattern: &str) -> String { + if pattern == "Default" { + return "*".to_string(); + } + pattern + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + .unwrap_or(pattern) + .to_string() +} + +pub(super) fn strip_comment(line: &str) -> String { + let mut output = String::new(); + let mut quote = false; + for ch in line.chars() { + match ch { + '\'' => { + quote = !quote; + output.push(ch); + } + '#' if !quote => break, + _ => output.push(ch), + } + } + output +} + +pub(super) fn is_generated_positional_binding(text: &str) -> bool { + if text == "$0 = $args.Count" { + return true; + } + let Some(rest) = text.strip_prefix('$') else { + return false; + }; + let Some((index, rest)) = rest.split_once(" = if ($args.Count -ge ") else { + return false; + }; + let Some((index2, rest)) = rest.split_once(") { $args[") else { + return false; + }; + let Some((offset, _rest)) = rest.split_once("] } else { '' }") else { + return false; + }; + if index != index2 { + return false; + } + let Ok(index) = index.parse::() else { + return false; + }; + let Ok(offset) = offset.parse::() else { + return false; + }; + index.checked_sub(1) == Some(offset) +} + +pub(super) fn validate_name(name: &str, line: usize) -> Result<()> { + if !is_valid_name(name) { + bail!("{line}: invalid PowerShell name: {name}"); + } + Ok(()) +} + +fn parse_argv_value(text: &str, line: usize) -> Result { + parse_value(text, line).or_else(|_| { + if is_valid_name(text) { + Ok(Value::Literal { + text: text.to_string(), + }) + } else { + bail!("{line}: unsupported PowerShell argv value: {text}") + } + }) +} + +fn guarded_expand(text: &str, line: usize) -> Result> { + let Some(inner) = text + .strip_prefix("$(if (") + .and_then(|value| value.strip_suffix(" })")) + else { + return Ok(None); + }; + if let Some(parsed) = env_guarded_expand(inner, line)? { + return Ok(Some(parsed)); + } + if let Some(parsed) = positional_guarded_expand(inner, line)? { + return Ok(Some(parsed)); + } + if let Some(parsed) = var_guarded_expand(inner, line)? { + return Ok(Some(parsed)); + } + Ok(None) +} + +fn env_guarded_expand(text: &str, line: usize) -> Result> { + let Some(rest) = text.strip_prefix("[string]::IsNullOrEmpty($env:") else { + return Ok(None); + }; + let (name, rest) = rest + .split_once(")) { ") + .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell env expansion"))?; + validate_name(name, line)?; + let source = ValueSource::Env { + name: name.to_string(), + }; + let plain = format!("$env:{name}"); + parse_guarded_action(source, &plain, rest, line).map(Some) +} + +fn positional_guarded_expand( + text: &str, + line: usize, +) -> Result> { + let Some(rest) = text.strip_prefix("($args.Count -lt ") else { + return Ok(None); + }; + let (index, rest) = rest + .split_once(") -or [string]::IsNullOrEmpty($") + .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell positional expansion"))?; + let (name, rest) = rest + .split_once(")) { ") + .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell positional expansion"))?; + if index != name { + bail!("{line}: unsupported PowerShell positional expansion"); + } + let source = ValueSource::Var { + name: name.to_string(), + }; + let plain = format!("${name}"); + parse_guarded_action(source, &plain, rest, line).map(Some) +} + +fn var_guarded_expand(text: &str, line: usize) -> Result> { + let Some(rest) = text.strip_prefix("[string]::IsNullOrEmpty($") else { + return Ok(None); + }; + let (name, rest) = rest + .split_once(")) { ") + .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell variable expansion"))?; + validate_name(name, line)?; + let source = ValueSource::Var { + name: name.to_string(), + }; + let plain = format!("${name}"); + parse_guarded_action(source, &plain, rest, line).map(Some) +} + +fn parse_guarded_action( + source: ValueSource, + plain: &str, + rest: &str, + line: usize, +) -> Result<(ValueSource, ExpansionOp)> { + let (action, expected_plain) = rest + .split_once(" } else { ") + .ok_or_else(|| anyhow::anyhow!("{line}: unsupported PowerShell guarded expansion"))?; + if expected_plain != plain { + bail!("{line}: unsupported PowerShell guarded expansion"); + } + if let Some(message) = action.strip_prefix("throw ") { + let message = parse_single_quoted(message, line)?; + return Ok((source, ExpansionOp::RequireNonEmpty { message })); + } + let fallback = parse_single_quoted(action, line)?; + Ok((source, ExpansionOp::DefaultIfUnsetOrEmpty { fallback })) +} + +fn parse_single_quoted(text: &str, line: usize) -> Result { + let Some(value) = text + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + else { + bail!("{line}: unsupported PowerShell quoted literal: {text}"); + }; + Ok(value.replace("''", "'")) +} + +fn split_exprs(text: &str, line: usize) -> Result> { + split_top_level(text, line, ' ') +} + +fn split_concat(text: &str, line: usize) -> Result> { + split_top_level(text, line, '+').map(|items| { + items + .into_iter() + .map(|item| item.trim().to_string()) + .collect() + }) +} + +fn split_top_level(text: &str, line: usize, delimiter: char) -> Result> { + let mut items = Vec::new(); + let mut current = String::new(); + let mut quote = false; + let mut depth = 0usize; + for ch in text.chars() { + match ch { + '\'' => { + quote = !quote; + current.push(ch); + } + '(' if !quote => { + depth += 1; + current.push(ch); + } + ')' if !quote => { + depth = depth.saturating_sub(1); + current.push(ch); + } + ch if ch == delimiter && !quote && depth == 0 => { + if !current.trim().is_empty() { + items.push(current.trim().to_string()); + current.clear(); + } + } + _ => current.push(ch), + } + } + if quote { + bail!("{line}: unterminated PowerShell string"); + } + if !current.trim().is_empty() { + items.push(current.trim().to_string()); + } + Ok(items) +} + +fn is_valid_var_name(name: &str) -> bool { + is_valid_name(name) || is_valid_positional(name) +} + +fn is_valid_name(name: &str) -> bool { + let mut bytes = name.bytes(); + matches!(bytes.next(), Some(byte) if byte.is_ascii_alphabetic() || byte == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn is_valid_positional(name: &str) -> bool { + !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()) +} diff --git a/app/src/core/transpile/parse_lex.rs b/app/src/core/transpile/parse_lex.rs index bae3d7a..988b091 100644 --- a/app/src/core/transpile/parse_lex.rs +++ b/app/src/core/transpile/parse_lex.rs @@ -9,8 +9,15 @@ pub(super) fn split_words(text: &str, line: usize) -> Result> { let mut words = Vec::new(); let mut current = String::new(); let mut quote = None; + let mut parameter_depth = 0_usize; let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { + if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { + current.push(ch); + current.push(chars.next().expect("peeked char should exist")); + parameter_depth += 1; + continue; + } match quote { Some(q) if ch == q => { current.push(ch); @@ -21,6 +28,10 @@ pub(super) fn split_words(text: &str, line: usize) -> Result> { current.push(ch); quote = Some(ch); } + None if ch == '}' && parameter_depth > 0 => { + current.push(ch); + parameter_depth -= 1; + } None if ch == '2' && matches!(chars.peek(), Some('>')) => { if !current.is_empty() { words.push(std::mem::take(&mut current)); @@ -50,7 +61,7 @@ pub(super) fn split_words(text: &str, line: usize) -> Result> { } words.push("|".to_string()); } - None if ch.is_whitespace() => { + None if ch.is_whitespace() && parameter_depth == 0 => { if !current.is_empty() { words.push(std::mem::take(&mut current)); } @@ -61,6 +72,9 @@ pub(super) fn split_words(text: &str, line: usize) -> Result> { if let Some(q) = quote { bail!("{line}: unterminated {q} quote"); } + if parameter_depth != 0 { + bail!("{line}: unterminated parameter expansion"); + } if !current.is_empty() { words.push(current); } @@ -72,8 +86,15 @@ pub(super) fn split_test_words(text: &str, line: usize) -> Result> { let mut current = String::new(); let mut quote = None; let mut command_depth = 0_usize; + let mut parameter_depth = 0_usize; let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { + if quote.is_none() && ch == '$' && chars.peek() == Some(&'{') { + current.push(ch); + current.push(chars.next().expect("peeked char should exist")); + parameter_depth += 1; + continue; + } if ch == '$' && chars.peek() == Some(&'(') { current.push(ch); current.push(chars.next().expect("peeked char should exist")); @@ -96,7 +117,11 @@ pub(super) fn split_test_words(text: &str, line: usize) -> Result> { current.push(ch); quote = Some(ch); } - None if ch.is_whitespace() => { + None if ch == '}' && parameter_depth > 0 => { + current.push(ch); + parameter_depth -= 1; + } + None if ch.is_whitespace() && parameter_depth == 0 => { if !current.is_empty() { words.push(std::mem::take(&mut current)); } @@ -110,6 +135,9 @@ pub(super) fn split_test_words(text: &str, line: usize) -> Result> { if command_depth != 0 { bail!("{line}: unterminated command substitution"); } + if parameter_depth != 0 { + bail!("{line}: unterminated parameter expansion"); + } if !current.is_empty() { words.push(current); } diff --git a/app/src/core/transpile/runner/mod.rs b/app/src/core/transpile/runner/mod.rs index da2333a..9f8df7f 100644 --- a/app/src/core/transpile/runner/mod.rs +++ b/app/src/core/transpile/runner/mod.rs @@ -8,7 +8,9 @@ use self::support::{ CaptureMode, CommandOutput, case_matches, find_spec, option_name, shell_words, split_words, write_stream_file, }; -use super::ast::{ArgvKind, ArgvSpec, Item, Predicate, Program, Statement, Value}; +use super::ast::{ + ArgvKind, ArgvSpec, ExpansionOp, Item, Predicate, Program, Statement, Value, ValueSource, +}; use super::parse::parse_seal; mod support; @@ -42,6 +44,12 @@ struct ArgSnapshot { values: Vec>, } +enum SourceState { + Unset, + Empty, + Present(String), +} + impl<'a> Runner<'a> { fn new(program: &'a Program, argv: &[String], env_overlay: &[(String, String)]) -> Self { let mut env = std::env::vars().collect::>(); @@ -89,7 +97,7 @@ impl<'a> Runner<'a> { fn run_statement(&mut self, statement: &Statement) -> Result { match statement { Statement::Assign { name, value } => { - let value = self.value(value); + let value = self.try_value(value)?; self.vars.insert(name.clone(), value); } Statement::ArgvParse { specs } => self.parse_argv(specs)?, @@ -107,7 +115,7 @@ impl<'a> Runner<'a> { argv, } => { let output = self.run_external(argv, CaptureMode::All)?; - let path = self.value(path); + let path = self.try_value(path)?; write_stream_file(stream, Path::new(&path), *append, &output)?; if output.code != 0 { return Ok(Flow::Exit(output.code)); @@ -116,8 +124,8 @@ impl<'a> Runner<'a> { Statement::EnvExecChecked { env, argv } => { let overlay = env .iter() - .map(|item| (item.name.clone(), self.value(&item.value))) - .collect::>(); + .map(|item| Ok((item.name.clone(), self.try_value(&item.value)?))) + .collect::>>()?; let code = self .run_external_with_env(argv, CaptureMode::None, &overlay)? .code; @@ -157,7 +165,7 @@ impl<'a> Runner<'a> { } } Statement::Case { value, arms } => { - let value = self.value(value); + let value = self.try_value(value)?; for arm in arms { if arm .patterns @@ -173,17 +181,17 @@ impl<'a> Runner<'a> { } } Statement::CallFunction { name, argv } => { - let old_args = self.set_function_args(argv); + let old_args = self.set_function_args(argv)?; let flow = self.run_function(name)?; self.restore_function_args(old_args); if !matches!(flow, Flow::Continue) { return Ok(flow); } } - Statement::Print { value } => println!("{}", self.value(value)), - Statement::Error { value } => eprintln!("{}", self.value(value)), + Statement::Print { value } => println!("{}", self.try_value(value)?), + Statement::Error { value } => eprintln!("{}", self.try_value(value)?), Statement::Fail { value } => { - eprintln!("{}", self.value(value)); + eprintln!("{}", self.try_value(value)?); return Ok(Flow::Exit(1)); } Statement::Exit { code } => return Ok(Flow::Exit(*code)), @@ -263,30 +271,29 @@ impl<'a> Runner<'a> { Ok(()) } - fn value(&self, value: &Value) -> String { - match value { + fn try_value(&self, value: &Value) -> Result { + Ok(match value { Value::Literal { text } => text.clone(), Value::Argc => self.argc().to_string(), - Value::Var { name } => self.vars.get(name).cloned().unwrap_or_default(), Value::Args => shell_words(&self.current_args()), - Value::Env { name } => self.env.get(name).cloned().unwrap_or_default(), - Value::EnvDefault { name, default } => self - .env - .get(name) - .filter(|value| !value.is_empty()) - .cloned() - .unwrap_or_else(|| default.clone()), - Value::Concat { parts } => parts.iter().map(|part| self.value(part)).collect(), - } + Value::Expand { source, op } => self.expand_value(source, op)?, + Value::Concat { parts } => { + let mut combined = String::new(); + for part in parts { + combined.push_str(&self.try_value(part)?); + } + combined + } + }) } fn predicate(&self, predicate: &Predicate) -> Result { Ok(match predicate { Predicate::Command { argv } => self.run_external(argv, CaptureMode::None)?.code == 0, - Predicate::Empty { value } => self.value(value).is_empty(), - Predicate::NotEmpty { value } => !self.value(value).is_empty(), - Predicate::Eq { left, right } => self.value(left) == self.value(right), - Predicate::Neq { left, right } => self.value(left) != self.value(right), + Predicate::Empty { value } => self.try_value(value)?.is_empty(), + Predicate::NotEmpty { value } => !self.try_value(value)?.is_empty(), + Predicate::Eq { left, right } => self.try_value(left)? == self.try_value(right)?, + Predicate::Neq { left, right } => self.try_value(left)? != self.try_value(right)?, Predicate::IntLt { left, right } => self.int_value(left)? < self.int_value(right)?, Predicate::IntLte { left, right } => self.int_value(left)? <= self.int_value(right)?, Predicate::IntGt { left, right } => self.int_value(left)? > self.int_value(right)?, @@ -297,21 +304,55 @@ impl<'a> Runner<'a> { Predicate::JsonNotEmpty { value } => { self.tool_path(&["json", "empty"], std::slice::from_ref(value))? == "false" } - Predicate::FileExists { path } => Path::new(&self.value(path)).is_file(), - Predicate::DirExists { path } => Path::new(&self.value(path)).is_dir(), + Predicate::FileExists { path } => Path::new(&self.try_value(path)?).is_file(), + Predicate::DirExists { path } => Path::new(&self.try_value(path)?).is_dir(), }) } fn int_value(&self, value: &Value) -> Result { - let value = self.value(value); + let value = self.try_value(value)?; value .parse::() .with_context(|| format!("invalid integer: {value}")) } + fn expand_value(&self, source: &ValueSource, op: &ExpansionOp) -> Result { + let state = self.source_state(source); + match op { + ExpansionOp::Plain => Ok(match state { + SourceState::Unset => String::new(), + SourceState::Empty => String::new(), + SourceState::Present(value) => value, + }), + ExpansionOp::DefaultIfUnsetOrEmpty { fallback } => Ok(match state { + SourceState::Present(value) => value, + SourceState::Unset | SourceState::Empty => fallback.clone(), + }), + ExpansionOp::RequireNonEmpty { message } => match state { + SourceState::Present(value) => Ok(value), + SourceState::Unset | SourceState::Empty => bail!("{message}"), + }, + } + } + + fn source_state(&self, source: &ValueSource) -> SourceState { + match source { + ValueSource::Env { name } => self.map_source_state(self.env.get(name).cloned()), + ValueSource::Var { name } => self.map_source_state(self.vars.get(name).cloned()), + } + } + + fn map_source_state(&self, value: Option) -> SourceState { + match value { + None => SourceState::Unset, + Some(value) if value.is_empty() => SourceState::Empty, + Some(value) => SourceState::Present(value), + } + } + fn tool_path(&self, path: &[&str], argv: &[Value]) -> Result { let mut args = path.iter().map(|part| part.to_string()).collect::>(); - args.extend(self.expanded_values(argv)); + args.extend(self.expanded_values(argv)?); Ok(tool::eval(&args)?.unwrap_or_default()) } @@ -325,7 +366,7 @@ impl<'a> Runner<'a> { capture: CaptureMode, env_overlay: &[(String, String)], ) -> Result { - let argv = self.expanded_values(argv); + let argv = self.expanded_values(argv)?; let Some((program, args)) = argv.split_first() else { bail!("external command cannot be empty"); }; @@ -354,8 +395,8 @@ impl<'a> Runner<'a> { }) } - fn set_function_args(&mut self, argv: &[Value]) -> ArgSnapshot { - let values = self.expanded_values(argv); + fn set_function_args(&mut self, argv: &[Value]) -> Result { + let values = self.expanded_values(argv)?; let old_len = self.argc(); let old = (0..=old_len) .map(|index| self.vars.remove(&index.to_string())) @@ -365,7 +406,7 @@ impl<'a> Runner<'a> { values: old, }; self.set_positional_args(&values); - snapshot + Ok(snapshot) } fn restore_function_args(&mut self, old: ArgSnapshot) { @@ -393,16 +434,16 @@ impl<'a> Runner<'a> { } } - fn expanded_values(&self, values: &[Value]) -> Vec { + fn expanded_values(&self, values: &[Value]) -> Result> { let mut expanded = Vec::new(); for value in values { match value { Value::Args => expanded.extend(self.current_args()), Value::Argc => expanded.push(self.argc().to_string()), - _ => expanded.push(self.value(value)), + _ => expanded.push(self.try_value(value)?), } } - expanded + Ok(expanded) } fn argc(&self) -> usize { diff --git a/app/src/core/transpile/value.rs b/app/src/core/transpile/value.rs index 2897a01..e7d5e1b 100644 --- a/app/src/core/transpile/value.rs +++ b/app/src/core/transpile/value.rs @@ -1,6 +1,6 @@ use anyhow::{Result, bail}; -use super::ast::Value; +use super::ast::{ExpansionOp, Value, ValueSource}; pub(crate) fn parse_value_text(text: &str, line: usize) -> Result { if text == "$@" || text == "\"$@\"" { @@ -25,29 +25,25 @@ pub(crate) fn parse_value_text(text: &str, line: usize) -> Result { } if let Some(name) = text.strip_prefix('$') { if is_positional_name(name) { - return Ok(Value::Var { - name: name.to_string(), + return Ok(Value::Expand { + source: ValueSource::Var { + name: name.to_string(), + }, + op: ExpansionOp::Plain, }); } if let Some(name) = name .strip_prefix('{') .and_then(|name| name.strip_suffix('}')) { - if let Some((name, default)) = name.split_once(":-") { - validate_name(name, line)?; - return Ok(Value::EnvDefault { - name: name.to_string(), - default: default.to_string(), - }); - } - validate_name(name, line)?; - return Ok(Value::Env { - name: name.to_string(), - }); + return parse_braced_expansion(name, line); } validate_name(name, line)?; - return Ok(Value::Var { - name: name.to_string(), + return Ok(Value::Expand { + source: ValueSource::Var { + name: name.to_string(), + }, + op: ExpansionOp::Plain, }); } if text.contains('$') { @@ -81,16 +77,7 @@ fn parse_template(text: &str, line: usize) -> Result { } inner.push(next); } - if let Some((name, default)) = inner.split_once(":-") { - validate_name(name, line)?; - parts.push(Value::EnvDefault { - name: name.to_string(), - default: default.to_string(), - }); - } else { - validate_name(&inner, line)?; - parts.push(Value::Env { name: inner }); - } + parts.push(parse_braced_expansion(&inner, line)?); continue; } let mut name = String::new(); @@ -104,7 +91,10 @@ fn parse_template(text: &str, line: usize) -> Result { { name.push(next); chars.next(); - parts.push(Value::Var { name }); + parts.push(Value::Expand { + source: ValueSource::Var { name }, + op: ExpansionOp::Plain, + }); continue; } while let Some(next) = chars.peek().copied() { @@ -116,7 +106,10 @@ fn parse_template(text: &str, line: usize) -> Result { } } validate_name(&name, line)?; - parts.push(Value::Var { name }); + parts.push(Value::Expand { + source: ValueSource::Var { name }, + op: ExpansionOp::Plain, + }); } if !literal.is_empty() { parts.push(Value::Literal { text: literal }); @@ -128,7 +121,45 @@ fn parse_template(text: &str, line: usize) -> Result { } fn is_positional_name(name: &str) -> bool { - name.len() == 1 && name.bytes().all(|byte| byte.is_ascii_digit()) + !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn parse_braced_expansion(text: &str, line: usize) -> Result { + if let Some((name, message)) = text.split_once(":?") { + let source = parse_braced_source(name, line)?; + return Ok(Value::Expand { + source, + op: ExpansionOp::RequireNonEmpty { + message: message.to_string(), + }, + }); + } + if let Some((name, fallback)) = text.split_once(":-") { + let source = parse_braced_source(name, line)?; + return Ok(Value::Expand { + source, + op: ExpansionOp::DefaultIfUnsetOrEmpty { + fallback: fallback.to_string(), + }, + }); + } + let source = parse_braced_source(text, line)?; + Ok(Value::Expand { + source, + op: ExpansionOp::Plain, + }) +} + +fn parse_braced_source(name: &str, line: usize) -> Result { + if is_positional_name(name) { + return Ok(ValueSource::Var { + name: name.to_string(), + }); + } + validate_name(name, line)?; + Ok(ValueSource::Env { + name: name.to_string(), + }) } fn validate_name(name: &str, line: usize) -> Result<()> { diff --git a/app/tests/transpile.rs b/app/tests/transpile.rs index b3b9e73..ecfdbb6 100644 --- a/app/tests/transpile.rs +++ b/app/tests/transpile.rs @@ -61,7 +61,7 @@ esac fn powershell_source() -> &'static str { r#" -$channel = $(if ($env:RUNSEAL_CHANNEL) { $env:RUNSEAL_CHANNEL } else { 'stable' }) +$channel = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_CHANNEL)) { 'stable' } else { $env:RUNSEAL_CHANNEL }) function release_run { if ([string]::IsNullOrEmpty($channel)) { throw 'channel missing' @@ -166,7 +166,7 @@ fn sealir_without_profile() { let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); let payload: serde_json::Value = serde_json::from_str(&stdout).expect("stdout should be JSON"); assert_eq!(payload["version"], 1); - assert!(stdout.contains("env_default")); + assert!(stdout.contains("default_if_unset_or_empty")); assert!(stdout.contains("exec_checked")); } @@ -205,7 +205,7 @@ fn bash_frontend_sealir() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("env_default")); + assert!(stdout.contains("default_if_unset_or_empty")); assert!(stdout.contains("exec_checked")); } @@ -217,7 +217,7 @@ fn powershell_frontend_sealir() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("env_default")); + assert!(stdout.contains("default_if_unset_or_empty")); assert!(stdout.contains("call_function")); assert!(stdout.contains("exec_checked")); } diff --git a/app/tests/transpile_cases.rs b/app/tests/transpile_cases.rs index 1980a21..3dc76cf 100644 --- a/app/tests/transpile_cases.rs +++ b/app/tests/transpile_cases.rs @@ -4,6 +4,8 @@ mod argv; mod command_predicate; #[path = "transpile_cases/env.rs"] mod env; +#[path = "transpile_cases/expansion.rs"] +mod expansion; #[path = "transpile_cases/regex.rs"] mod regex; #[path = "transpile_cases/release.rs"] diff --git a/app/tests/transpile_cases/expansion.rs b/app/tests/transpile_cases/expansion.rs new file mode 100644 index 0000000..14dd915 --- /dev/null +++ b/app/tests/transpile_cases/expansion.rs @@ -0,0 +1,183 @@ +use std::process::Command; + +use tempfile::TempDir; + +use super::syntax; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_runseal")) +} + +struct Fixture { + _temp: TempDir, + dir: std::path::PathBuf, + source: std::path::PathBuf, +} + +fn fixture(source: &str) -> Fixture { + let temp = TempDir::new().expect("temp dir should be created"); + let dir = temp.path().join("project-without-profile"); + std::fs::create_dir_all(&dir).expect("project dir should be created"); + let source_path = dir.join("operator.seal"); + std::fs::write(&source_path, source).expect("source should be written"); + Fixture { + _temp: temp, + dir, + source: source_path, + } +} + +fn run_transpile(fx: &Fixture, input_lang: &str, output_lang: &str) -> std::process::Output { + bin() + .current_dir(&fx.dir) + .arg("@transpile") + .arg("--input-lang") + .arg(input_lang) + .arg("--output-lang") + .arg(output_lang) + .arg(&fx.source) + .output() + .expect("runseal should run") +} + +fn expansion_source() -> &'static str { + r#" +channel=${RUNSEAL_CHANNEL:-stable} +required=${RUNSEAL_TOKEN:?missing token} +target=${1:-origin} +branch=${2:?missing branch} +print "$channel $required $target $branch" +"# +} + +#[test] +fn forms_roundtrip() { + let fx = fixture(expansion_source()); + + let sealir = run_transpile(&fx, "seal", "sealir"); + assert!(sealir.status.success()); + let sealir = String::from_utf8(sealir.stdout).expect("stdout should be UTF-8"); + assert!(sealir.contains("require_non_empty")); + assert!(sealir.contains("default_if_unset_or_empty")); + + let bash = run_transpile(&fx, "seal", "bash"); + assert!(bash.status.success()); + let bash = String::from_utf8(bash.stdout).expect("stdout should be UTF-8"); + assert!(bash.contains("channel=\"${RUNSEAL_CHANNEL:-stable}\"")); + assert!(bash.contains("required=\"${RUNSEAL_TOKEN:?missing token}\"")); + assert!(bash.contains("target=\"${1:-origin}\"")); + assert!(bash.contains("branch=\"${2:?missing branch}\"")); + syntax::assert_bash(&bash); + + let powershell = run_transpile(&fx, "seal", "powershell"); + assert!(powershell.status.success()); + let powershell = String::from_utf8(powershell.stdout).expect("stdout should be UTF-8"); + assert!(powershell.contains( + "$channel = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_CHANNEL)) { 'stable' } else { $env:RUNSEAL_CHANNEL })" + )); + assert!(powershell.contains( + "$required = $(if ([string]::IsNullOrEmpty($env:RUNSEAL_TOKEN)) { throw 'missing token' } else { $env:RUNSEAL_TOKEN })" + )); + assert!(powershell.contains( + "$target = $(if (($args.Count -lt 1) -or [string]::IsNullOrEmpty($1)) { 'origin' } else { $1 })" + )); + assert!(powershell.contains( + "$branch = $(if (($args.Count -lt 2) -or [string]::IsNullOrEmpty($2)) { throw 'missing branch' } else { $2 })" + )); + syntax::assert_pwsh(&powershell); + + let powershell_fx = fixture(&powershell); + let roundtrip = run_transpile(&powershell_fx, "powershell", "sealir"); + assert!(roundtrip.status.success()); + let roundtrip = String::from_utf8(roundtrip.stdout).expect("stdout should be UTF-8"); + assert!(roundtrip.contains("require_non_empty")); + assert!(roundtrip.contains("default_if_unset_or_empty")); +} + +fn run_wrapper(source: &str, args: &[&str], env: &[(&str, &str)]) -> std::process::Output { + let temp = TempDir::new().expect("temp dir should be created"); + let project = temp.path().join("project"); + let wrappers = project.join(".runseal").join("wrappers"); + std::fs::create_dir_all(&wrappers).expect("wrappers should be created"); + std::fs::write(project.join("runseal.toml"), "injections = []\n") + .expect("profile should be written"); + std::fs::write(wrappers.join("expansion.seal"), source).expect("wrapper should be written"); + let mut command = bin(); + command.current_dir(&project).arg(":expansion").args(args); + for (key, value) in env { + command.env(key, value); + } + command.output().expect("runseal should run") +} + +#[test] +fn env_default_runtime() { + let source = "print \"${RUNSEAL_CHANNEL:-stable}\"\n"; + let missing = run_wrapper(source, &[], &[]); + assert!(missing.status.success()); + assert_eq!( + String::from_utf8(missing.stdout).expect("stdout should be UTF-8"), + "stable\n" + ); + let empty = run_wrapper(source, &[], &[("RUNSEAL_CHANNEL", "")]); + assert!(empty.status.success()); + assert_eq!( + String::from_utf8(empty.stdout).expect("stdout should be UTF-8"), + "stable\n" + ); +} + +#[test] +fn positional_default_runtime() { + let source = "print \"${1:-origin}\"\n"; + let missing = run_wrapper(source, &[], &[]); + assert!(missing.status.success()); + assert_eq!( + String::from_utf8(missing.stdout).expect("stdout should be UTF-8"), + "origin\n" + ); + let empty = run_wrapper(source, &[""], &[]); + assert!(empty.status.success()); + assert_eq!( + String::from_utf8(empty.stdout).expect("stdout should be UTF-8"), + "origin\n" + ); +} + +#[test] +fn env_require_runtime() { + let source = "print \"${RUNSEAL_TOKEN:?missing token}\"\n"; + let missing = run_wrapper(source, &[], &[]); + assert!(!missing.status.success()); + assert!( + String::from_utf8(missing.stderr) + .expect("stderr should be UTF-8") + .contains("missing token") + ); + let empty = run_wrapper(source, &[], &[("RUNSEAL_TOKEN", "")]); + assert!(!empty.status.success()); + assert!( + String::from_utf8(empty.stderr) + .expect("stderr should be UTF-8") + .contains("missing token") + ); +} + +#[test] +fn positional_require_runtime() { + let source = "print \"${1:?missing branch}\"\n"; + let missing = run_wrapper(source, &[], &[]); + assert!(!missing.status.success()); + assert!( + String::from_utf8(missing.stderr) + .expect("stderr should be UTF-8") + .contains("missing branch") + ); + let empty = run_wrapper(source, &[""], &[]); + assert!(!empty.status.success()); + assert!( + String::from_utf8(empty.stderr) + .expect("stderr should be UTF-8") + .contains("missing branch") + ); +} From 3d1d80e6cc64cdb42bf328e685d6718b1b3bc4bc Mon Sep 17 00:00:00 2001 From: PerishCode Date: Wed, 10 Jun 2026 22:52:43 +0800 Subject: [PATCH 07/17] docs: codify canonical seal kube wrapper --- AGENTS.md | 28 +++++++++++++++++++++++++++ README.md | 23 +++++++++++----------- app/tests/fixtures/estate/kube.seal | 11 ++++++++++- app/tests/operator/estate.rs | 30 +++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5667f78..2330df2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,34 @@ When the behavior cannot be described cleanly as shared shell-shape syntax or a clear atomic tool, keep it in Python, Ruby, JavaScript, or another external script. +### Should `.seal` wrappers build multi-line config or payload text inline? + +Usually no. + +For operations work, persistent or semi-persistent structured text should +normally live as explicit repo material under `.runseal/` or `.local/`, not as +inline heredoc-style wrapper content. That includes things like: + +- config templates +- YAML or JSON fragments +- kube-related files +- long request bodies +- other operator-facing text payloads + +The wrapper should usually do the smaller, clearer job: + +- validate preconditions +- choose the right file or template +- assemble paths and arguments +- set environment for the invoked command +- execute the operational flow + +This is an intentional product boundary. `runseal` is meant to reduce +environment and runtime dependency complexity in operations workflows, not to +turn `.seal` into a general inline text-construction language. If a multi-line +artifact is important enough to exist, prefer making it a visible repo or local +artifact first. + ### Should `.seal` wrappers be treated as first-class runtime entrypoints? Yes. Treat `.runseal/wrappers/*.seal` as first-class wrappers executed directly diff --git a/README.md b/README.md index f0f7a26..0f47cb3 100644 --- a/README.md +++ b/README.md @@ -272,22 +272,21 @@ round trips. For example, a wrapper can expose `:ssh --run