From 17eb499f91d94972dc514e6d6bbe873ea5c44e6d Mon Sep 17 00:00:00 2001 From: zsumz Date: Mon, 24 Aug 2026 14:18:37 -0500 Subject: [PATCH 1/4] fix: make rustup setup deterministic --- .github/workflows/ci.yml | 101 ++++++ CONTRIBUTING.md | 1 + README.md | 30 +- SECURITY.md | 12 +- action.yml | 6 +- dist/action.js | 84 ++--- dist/contracts.js | 14 + dist/rustup.js | 125 ++++++++ dist/toolchain-file.js | 293 ++++++++++++++++++ dist/workspace.js | 10 +- package.json | 4 +- src/action.ts | 91 +++--- src/contracts.ts | 17 + src/rustup.ts | 159 ++++++++++ src/toolchain-file.ts | 288 +++++++++++++++++ src/workspace.ts | 10 +- tests/action.test.js | 228 ++++++++++---- tests/contracts.test.js | 11 + .../file-toolchain/rust-toolchain.toml | 2 - tests/toolchain-file.test.js | 77 +++++ tests/workspace.test.js | 11 + 21 files changed, 1385 insertions(+), 189 deletions(-) create mode 100644 dist/rustup.js create mode 100644 dist/toolchain-file.js create mode 100644 src/rustup.ts create mode 100644 src/toolchain-file.ts create mode 100644 tests/toolchain-file.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfbfb76..3cb3f9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,8 @@ jobs: id: rust uses: ./ with: + components: rustfmt,clippy + targets: wasm32-unknown-unknown working-directory: tests/fixtures/file-toolchain - name: Verify setup contract @@ -138,6 +140,105 @@ jobs: INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.rust.outputs.toolchain-source }} run: node tests/setup-contract.js + file-toolchain-platforms: + name: file / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: + - macos-15 + - windows-2025 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Rust from a toolchain file + id: rust + uses: ./ + with: + components: rustfmt,clippy + targets: wasm32-unknown-unknown + working-directory: tests/fixtures/file-toolchain + + - name: Verify setup contract + env: + EXPECTED_RUST_VERSION: "1.88.0" + EXPECTED_TOOLCHAIN_SOURCE: tests/fixtures/file-toolchain/rust-toolchain.toml + INSTALLED_CARGO_VERSION: ${{ steps.rust.outputs.cargo-version }} + INSTALLED_HOST: ${{ steps.rust.outputs.host }} + INSTALLED_RUSTC_VERSION: ${{ steps.rust.outputs.rustc-version }} + INSTALLED_RUSTUP_VERSION: ${{ steps.rust.outputs.rustup-version }} + INSTALLED_TOOLCHAIN: ${{ steps.rust.outputs.toolchain }} + INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.rust.outputs.toolchain-source }} + run: node tests/setup-contract.js + + runner-state: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Seed a conflicting rustup directory override + run: rustup override set stable --path tests/fixtures/file-toolchain + + - name: Set up file-selected Rust above the override + id: file-state + uses: ./ + with: + components: rustfmt,clippy + targets: wasm32-unknown-unknown + working-directory: tests/fixtures/file-toolchain + + - name: Verify repository selection won + env: + EXPECTED_RUST_VERSION: "1.88.0" + EXPECTED_TOOLCHAIN_SOURCE: tests/fixtures/file-toolchain/rust-toolchain.toml + INSTALLED_CARGO_VERSION: ${{ steps.file-state.outputs.cargo-version }} + INSTALLED_HOST: ${{ steps.file-state.outputs.host }} + INSTALLED_RUSTC_VERSION: ${{ steps.file-state.outputs.rustc-version }} + INSTALLED_RUSTUP_VERSION: ${{ steps.file-state.outputs.rustup-version }} + INSTALLED_TOOLCHAIN: ${{ steps.file-state.outputs.toolchain }} + INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.file-state.outputs.toolchain-source }} + run: node tests/setup-contract.js + + - name: Use an isolated rustup home with no default + run: echo "RUSTUP_HOME=$RUNNER_TEMP/setup-rust-home" >> "$GITHUB_ENV" + + - name: Set up without updating or creating a default + id: isolated-state + uses: ./ + with: + toolchain: "1.88.0" + components: rustfmt,clippy + targets: wasm32-unknown-unknown + update: "false" + + - name: Verify no-update additions + env: + EXPECTED_RUST_VERSION: "1.88.0" + EXPECTED_TOOLCHAIN_SOURCE: input + INSTALLED_CARGO_VERSION: ${{ steps.isolated-state.outputs.cargo-version }} + INSTALLED_HOST: ${{ steps.isolated-state.outputs.host }} + INSTALLED_RUSTC_VERSION: ${{ steps.isolated-state.outputs.rustc-version }} + INSTALLED_RUSTUP_VERSION: ${{ steps.isolated-state.outputs.rustup-version }} + INSTALLED_TOOLCHAIN: ${{ steps.isolated-state.outputs.toolchain }} + INSTALLED_TOOLCHAIN_SOURCE: ${{ steps.isolated-state.outputs.toolchain-source }} + run: node tests/setup-contract.js + + - name: Verify the global default remains absent + run: | + if rustup toolchain list | grep -E '\((active, )?default\)$'; then + echo "setup-rust created a global default" >&2 + exit 1 + fi + fail-closed: runs-on: ubuntu-24.04 timeout-minutes: 5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44b726d..34b0d20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ git diff --check Requirements: - preserve the fail-closed toolchain selection contract; +- test rustup semantics against isolated persistent runner state, not only fake argv; - pass external values to child processes as arguments, never shell text; - add a failure-path test for every new input or process boundary; - keep Cargo tools, native packages, caching, and compiler flags out of scope; diff --git a/README.md b/README.md index f49932b..bd965dd 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ Install and select one reviewed Rust toolchain in GitHub Actions without hidden cache, compiler-flag, or native-package policy. -The action requires `rustup` on `PATH`, installs the selected toolchain with -argument-safe process execution, exports the fully resolved toolchain through -`RUSTUP_TOOLCHAIN`, and reports the resolved Rust, Cargo, rustup, commit, and -host identities as outputs. +The action requires rustup 1.28.0 or newer on `PATH`, installs the selected +toolchain with argument-safe process execution, exports the fully resolved +toolchain through `RUSTUP_TOOLCHAIN`, and reports the resolved Rust, Cargo, +rustup, commit, and host identities as outputs. Rustup self-update is always +disabled and an initially absent global default remains absent. ## Usage @@ -45,15 +46,25 @@ also explicit moving behavior; use an exact Rust version for reproducible jobs. | Input | Default | Contract | | --- | --- | --- | | `toolchain` | repository file | One rustup toolchain name. Without it, `rust-toolchain` or `rust-toolchain.toml` must exist. | -| `profile` | file setting or `minimal` | `minimal`, `default`, or `complete`. Explicit toolchain installs default to `minimal`. | -| `components` | none | Comma- or whitespace-separated additional rustup components. | -| `targets` | none | Comma- or whitespace-separated additional compilation targets. | +| `profile` | file setting or `minimal` | `minimal`, `default`, or `complete`. An action input overrides the file setting. | +| `components` | file settings | Comma- or whitespace-separated additional rustup components. | +| `targets` | file settings | Comma- or whitespace-separated additional compilation targets. | | `working-directory` | `.` | Repository-relative directory from which rustup discovers the nearest toolchain file. | -| `update` | `true` | Update an installed moving toolchain. Set `false` to pass rustup's `--no-update`. | -| `allow-downgrade` | `false` | Allow rustup to select an older release when requested components are unavailable. | +| `update` | `true` | Update an installed moving toolchain. When `false`, keep the installed release while still adding missing components and targets. | +| `allow-downgrade` | `false` | Allow rustup to select an older release when requested components are unavailable. Requires `update: true`. | List inputs are deduplicated without reordering. The action invokes rustup directly with an argument array; input text is never evaluated by a shell. +Repository-file components and targets are installed before action-provided +additions, and the selected channel is forced above any persistent rustup +directory override. + +### Toolchain file contract + +`rust-toolchain.toml` supports the rustup `channel`, `profile`, `components`, +and `targets` fields. The legacy single-line `rust-toolchain` format is also +supported. Local `path` toolchains and linked custom toolchains are outside this +action's distribution-install boundary and fail before rustup is invoked. ## Outputs @@ -76,6 +87,7 @@ runner's global default. This action does not: - install rustup; +- update the rustup executable; - set `RUSTFLAGS` or other compiler policy; - cache Cargo registries, Git repositories, or build outputs; - install Cargo binaries such as `cargo-deny`, `zcheck`, or `zrail`; diff --git a/SECURITY.md b/SECURITY.md index b46de68..c9ee09b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,9 +17,15 @@ pin a reviewed full commit SHA and update that pin after a fix is released. ## Security boundary Inputs and repository paths are treated as untrusted data. The action validates -rustup names, rejects working directories outside `GITHUB_WORKSPACE`, invokes -commands without a shell, and stops when no explicit input or repository -toolchain file selects Rust. +rustup names and its supported toolchain-file subset, rejects working +directories outside `GITHUB_WORKSPACE`, invokes commands without a shell, and +stops when no explicit input or repository toolchain file selects Rust. The +selected channel is forced through `RUSTUP_TOOLCHAIN`, so persistent rustup +directory overrides cannot replace repository policy. + +Local path and linked custom toolchains are deliberately unsupported. The +action disables rustup self-update and restores an initially absent global +default after installation. The action trusts the selected action commit, the runner, the installed rustup executable, and rustup's configured distribution server. It does not verify the diff --git a/action.yml b/action.yml index e1dd9dc..c87f939 100644 --- a/action.yml +++ b/action.yml @@ -4,7 +4,7 @@ author: zactionsz inputs: toolchain: - description: Rustup toolchain name; when omitted, a repository toolchain file is required + description: Installable rustup channel; when omitted, a supported repository toolchain file is required required: false profile: description: Rustup profile (minimal, default, or complete); explicit installs default to minimal @@ -20,11 +20,11 @@ inputs: required: false default: "." update: - description: Update an already installed moving toolchain such as stable + description: Update an installed moving toolchain; false still adds missing components and targets required: false default: "true" allow-downgrade: - description: Allow rustup to select an older toolchain when requested components are unavailable + description: With updates enabled, allow an older toolchain when requested components are unavailable required: false default: "false" diff --git a/dist/action.js b/dist/action.js index 97a4fa6..a3c5466 100644 --- a/dist/action.js +++ b/dist/action.js @@ -37,54 +37,48 @@ exports.runAction = runAction; const contracts_1 = require("./contracts"); const github = __importStar(require("./github")); const process_1 = require("./process"); +const rustup_1 = require("./rustup"); +const toolchain_file_1 = require("./toolchain-file"); const workspace_1 = require("./workspace"); async function runAction(environment = process.env, overrides = {}) { const dependencies = { runCommand: process_1.runCommand, ...overrides }; const inputs = readInputs(environment); + requireCompatibleInputs(inputs); const workspaceValue = environment.GITHUB_WORKSPACE || process.cwd(); const paths = await (0, workspace_1.resolveWorkspacePaths)(workspaceValue, inputs.workingDirectory); if (!inputs.toolchain && !paths.toolchainFile) { throw new Error('No Rust toolchain selected; provide the toolchain input or commit rust-toolchain.toml'); } + const file = inputs.toolchain + ? undefined + : await (0, toolchain_file_1.readToolchainFile)(requireValue(paths.toolchainFile)); const toolchainSource = inputs.toolchain ? 'input' : (0, workspace_1.relativeSource)(paths.workspace, requireValue(paths.toolchainFile)); - const commandEnvironment = { ...environment }; - delete commandEnvironment.RUSTUP_TOOLCHAIN; - if (inputs.toolchain) - commandEnvironment.RUSTUP_TOOLCHAIN = inputs.toolchain; - const installArguments = buildInstallArguments(inputs); + const selection = selectToolchain(inputs, file); github.startGroup(`Install Rust toolchain from ${toolchainSource}`); + let installed; try { - await dependencies.runCommand('rustup', installArguments, { + installed = await (0, rustup_1.installRustupToolchain)({ + ...selection, + allowDowngrade: inputs.allowDowngrade, cwd: paths.workingDirectory, - environment: commandEnvironment - }); + environment, + update: inputs.update + }, dependencies.runCommand); } finally { github.endGroup(); } - const selected = await dependencies.runCommand('rustup', ['show', 'active-toolchain'], { - cwd: paths.workingDirectory, - environment: commandEnvironment, - quiet: true - }); - const toolchain = parseActiveToolchain(selected.stdout); - const selectedEnvironment = { ...commandEnvironment, RUSTUP_TOOLCHAIN: toolchain }; - const [rustc, cargo, rustup] = await Promise.all([ + const [rustc, cargo] = await Promise.all([ dependencies.runCommand('rustc', ['--version', '--verbose'], { cwd: paths.workingDirectory, - environment: selectedEnvironment, + environment: installed.environment, quiet: true }), dependencies.runCommand('cargo', ['--version'], { cwd: paths.workingDirectory, - environment: selectedEnvironment, - quiet: true - }), - dependencies.runCommand('rustup', ['--version'], { - cwd: paths.workingDirectory, - environment: selectedEnvironment, + environment: installed.environment, quiet: true }) ]); @@ -94,51 +88,37 @@ async function runAction(environment = process.env, overrides = {}) { host: rustcDetails.host, rustcCommit: rustcDetails.commit, rustcVersion: rustcDetails.release, - rustupVersion: firstVersionLine(rustup, 'rustup'), - toolchain, + rustupVersion: installed.rustupVersion, + toolchain: installed.toolchain, toolchainSource }; await publishResult(result, environment); github.info(`Selected Rust ${result.rustcVersion} (${result.host}) from ${result.toolchainSource}`); return result; } +function requireCompatibleInputs(inputs) { + if (!inputs.update && inputs.allowDowngrade) { + throw new Error('allow-downgrade requires update to be true'); + } +} function readInputs(environment) { return { allowDowngrade: (0, contracts_1.booleanInput)(github.input('allow-downgrade', environment) || 'false', 'allow-downgrade'), components: (0, contracts_1.rustupList)(github.input('components', environment), 'components'), profile: (0, contracts_1.optionalProfile)(github.input('profile', environment)), targets: (0, contracts_1.rustupList)(github.input('targets', environment), 'targets'), - toolchain: (0, contracts_1.optionalToolchain)(github.input('toolchain', environment)), + toolchain: (0, contracts_1.optionalInstallableToolchain)(github.input('toolchain', environment)), update: (0, contracts_1.booleanInput)(github.input('update', environment) || 'true', 'update'), workingDirectory: (0, contracts_1.nonEmptyDirectory)(github.input('working-directory', environment) || '.') }; } -function buildInstallArguments(inputs) { - const arguments_ = ['toolchain', 'install']; - if (inputs.toolchain) - arguments_.push(inputs.toolchain); - const profile = inputs.profile ?? (inputs.toolchain ? 'minimal' : undefined); - if (profile) - arguments_.push('--profile', profile); - if (inputs.components.length > 0) { - arguments_.push('--component', inputs.components.join(',')); - } - if (inputs.targets.length > 0) - arguments_.push('--target', inputs.targets.join(',')); - if (!inputs.update) - arguments_.push('--no-update'); - if (inputs.allowDowngrade) - arguments_.push('--allow-downgrade'); - return arguments_; -} -function parseActiveToolchain(stdout) { - const candidate = stdout.trim().split(/\s+/u)[0]; - if (!candidate) - throw new Error('rustup did not report an active toolchain'); - const toolchain = (0, contracts_1.optionalToolchain)(candidate); - if (!toolchain) - throw new Error('rustup reported an empty active toolchain'); - return toolchain; +function selectToolchain(inputs, file) { + return { + components: (0, contracts_1.rustupItems)([...(file?.components ?? []), ...inputs.components], 'components'), + profile: inputs.profile ?? file?.profile ?? 'minimal', + targets: (0, contracts_1.rustupItems)([...(file?.targets ?? []), ...inputs.targets], 'targets'), + toolchain: inputs.toolchain ?? requireValue(file).toolchain + }; } function parseRustc(stdout) { const values = new Map(); diff --git a/dist/contracts.js b/dist/contracts.js index 9282159..d4c7b7d 100644 --- a/dist/contracts.js +++ b/dist/contracts.js @@ -1,11 +1,14 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.optionalToolchain = optionalToolchain; +exports.optionalInstallableToolchain = optionalInstallableToolchain; exports.optionalProfile = optionalProfile; exports.rustupList = rustupList; +exports.rustupItems = rustupItems; exports.booleanInput = booleanInput; exports.nonEmptyDirectory = nonEmptyDirectory; const RUSTUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; +const INSTALLABLE_TOOLCHAIN_PATTERN = /^(?:stable|beta|nightly|\d+\.\d+(?:\.\d+)?(?:-beta(?:\.\d+)?)?)(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/u; const PROFILES = new Set(['minimal', 'default', 'complete']); function optionalToolchain(value) { const toolchain = value.trim(); @@ -14,6 +17,14 @@ function optionalToolchain(value) { requireRustupName(toolchain, 'toolchain'); return toolchain; } +function optionalInstallableToolchain(value) { + const toolchain = optionalToolchain(value); + if (toolchain && !INSTALLABLE_TOOLCHAIN_PATTERN.test(toolchain)) { + throw new Error(`Invalid toolchain ${JSON.stringify(value)}; expected an installable stable, beta, nightly, ` + + 'or versioned rustup channel'); + } + return toolchain; +} function optionalProfile(value) { const profile = value.trim(); if (profile.length === 0) @@ -28,6 +39,9 @@ function rustupList(value, inputName) { .split(/[\s,]+/u) .map((item) => item.trim()) .filter((item) => item.length > 0); + return rustupItems(items, inputName); +} +function rustupItems(items, inputName) { const unique = []; const seen = new Set(); for (const item of items) { diff --git a/dist/rustup.js b/dist/rustup.js new file mode 100644 index 0000000..abbd8d2 --- /dev/null +++ b/dist/rustup.js @@ -0,0 +1,125 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.installRustupToolchain = installRustupToolchain; +const contracts_1 = require("./contracts"); +const MINIMUM_RUSTUP = Object.freeze([1, 28, 0]); +const MINIMUM_RUSTUP_TEXT = MINIMUM_RUSTUP.join('.'); +async function installRustupToolchain(request, runCommand) { + const neutralEnvironment = { ...request.environment }; + delete neutralEnvironment.RUSTUP_TOOLCHAIN; + const rustupVersionResult = await runCommand('rustup', ['--version'], { + cwd: request.cwd, + environment: neutralEnvironment, + quiet: true + }); + const rustupVersion = firstVersionLine(rustupVersionResult, 'rustup'); + requireSupportedRustup(rustupVersion); + const hadDefault = await hasDefaultToolchain(runCommand, request.cwd, neutralEnvironment); + const installEnvironment = { + ...neutralEnvironment, + RUSTUP_AUTO_INSTALL: '0', + RUSTUP_TOOLCHAIN: request.toolchain + }; + try { + await runCommand('rustup', buildInstallArguments(request), { + cwd: request.cwd, + environment: installEnvironment + }); + } + finally { + if (!hadDefault) { + await runCommand('rustup', ['default', 'none'], { + cwd: request.cwd, + environment: neutralEnvironment, + quiet: true + }); + } + } + const active = await runCommand('rustup', ['show', 'active-toolchain'], { + cwd: request.cwd, + environment: installEnvironment, + quiet: true + }); + const toolchain = parseActiveToolchain(active.stdout); + const selectedEnvironment = { ...installEnvironment, RUSTUP_TOOLCHAIN: toolchain }; + if (!request.update) { + await addRequestedItems(request, toolchain, selectedEnvironment, runCommand); + } + return { environment: selectedEnvironment, rustupVersion, toolchain }; +} +function buildInstallArguments(request) { + const arguments_ = [ + 'toolchain', + 'install', + request.toolchain, + '--profile', + request.profile, + '--no-self-update' + ]; + if (request.update) { + if (request.components.length > 0) { + arguments_.push('--component', request.components.join(',')); + } + if (request.targets.length > 0) + arguments_.push('--target', request.targets.join(',')); + if (request.allowDowngrade) + arguments_.push('--allow-downgrade'); + } + else { + arguments_.push('--no-update'); + } + return arguments_; +} +async function addRequestedItems(request, toolchain, environment, runCommand) { + if (request.components.length > 0) { + await runCommand('rustup', ['component', 'add', ...request.components, '--toolchain', toolchain], { cwd: request.cwd, environment }); + } + if (request.targets.length > 0) { + await runCommand('rustup', ['target', 'add', ...request.targets, '--toolchain', toolchain], { + cwd: request.cwd, + environment + }); + } +} +async function hasDefaultToolchain(runCommand, cwd, environment) { + const result = await runCommand('rustup', ['toolchain', 'list'], { + cwd, + environment, + quiet: true + }); + return result.stdout.split(/\r?\n/u).some((line) => /\((?:active, )?default\)$/u.test(line.trim())); +} +function requireSupportedRustup(versionLine) { + const match = /^rustup (\d+)\.(\d+)\.(\d+)(?:\s|$)/u.exec(versionLine); + if (!match) + throw new Error(`rustup reported an unsupported version line: ${versionLine}`); + const actual = match.slice(1, 4).map(Number); + for (let index = 0; index < MINIMUM_RUSTUP.length; index += 1) { + const difference = (actual[index] ?? 0) - (MINIMUM_RUSTUP[index] ?? 0); + if (difference > 0) + return; + if (difference < 0) { + throw new Error(`setup-rust requires rustup ${MINIMUM_RUSTUP_TEXT} or newer; found ${versionLine}`); + } + } +} +function parseActiveToolchain(stdout) { + const candidate = stdout.trim().split(/\s+/u)[0]; + if (!candidate) + throw new Error('rustup did not report an active toolchain'); + try { + const toolchain = (0, contracts_1.optionalInstallableToolchain)(candidate); + if (toolchain) + return toolchain; + } + catch { + throw new Error(`rustup reported an unsupported active toolchain ${JSON.stringify(candidate)}`); + } + throw new Error('rustup reported an empty active toolchain'); +} +function firstVersionLine(result, command) { + const line = result.stdout.split(/\r?\n/u).find((candidate) => candidate.trim().length > 0); + if (!line) + throw new Error(`${command} did not report a version`); + return line.trim(); +} diff --git a/dist/toolchain-file.js b/dist/toolchain-file.js new file mode 100644 index 0000000..966b678 --- /dev/null +++ b/dist/toolchain-file.js @@ -0,0 +1,293 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.readToolchainFile = readToolchainFile; +exports.parseToolchainFile = parseToolchainFile; +const promises_1 = require("node:fs/promises"); +const path = __importStar(require("node:path")); +const contracts_1 = require("./contracts"); +const MAX_TOOLCHAIN_FILE_BYTES = 64 * 1024; +async function readToolchainFile(file) { + const details = await (0, promises_1.stat)(file); + if (details.size > MAX_TOOLCHAIN_FILE_BYTES) { + throw new Error(`Rust toolchain file exceeds ${String(MAX_TOOLCHAIN_FILE_BYTES)} bytes`); + } + const contents = await (0, promises_1.readFile)(file, 'utf8'); + return parseToolchainFile(contents, path.basename(file)); +} +function parseToolchainFile(contents, filename) { + if (contents.includes('\0')) + throw new Error(`${filename} cannot contain NUL bytes`); + const normalized = contents.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + if (filename === 'rust-toolchain' && isLegacyFile(normalized)) { + const toolchain = (0, contracts_1.optionalInstallableToolchain)(normalized); + if (!toolchain) + throw new Error('rust-toolchain cannot be empty'); + return { components: [], profile: undefined, targets: [], toolchain }; + } + const values = parseToolchainTable(normalized, filename); + if (values.has('path')) { + throw new Error(`${filename} uses a path toolchain, which setup-rust does not support; ` + + 'use an installable rustup channel'); + } + const channel = stringValue(values, 'channel', filename); + if (!channel) + throw new Error(`${filename} must define toolchain.channel`); + const toolchain = (0, contracts_1.optionalInstallableToolchain)(channel); + if (!toolchain) + throw new Error(`${filename} must define a non-empty toolchain.channel`); + const profileValue = stringValue(values, 'profile', filename); + const components = arrayValue(values, 'components', filename); + const targets = arrayValue(values, 'targets', filename); + return { + components: (0, contracts_1.rustupItems)(components, 'components'), + profile: (0, contracts_1.optionalProfile)(profileValue ?? ''), + targets: (0, contracts_1.rustupItems)(targets, 'targets'), + toolchain + }; +} +function isLegacyFile(contents) { + return !contents.trimStart().startsWith('['); +} +function parseToolchainTable(contents, filename) { + const values = new Map(); + const lines = contents.split('\n'); + let inToolchain = false; + let sawToolchain = false; + let pending; + for (const rawLine of lines) { + const line = stripComment(rawLine); + if (pending) { + pending.value += `\n${line}`; + if (isCompleteValue(pending.value)) { + setValue(values, pending.key, parseValue(pending.value, filename), filename); + pending = undefined; + } + continue; + } + const trimmed = line.trim(); + if (trimmed.length === 0) + continue; + const header = /^\[([^\]]+)\]$/u.exec(trimmed); + if (header) { + const table = header[1]?.trim(); + if (table !== 'toolchain') { + throw new Error(`Unsupported table ${JSON.stringify(table)} in ${filename}`); + } + if (sawToolchain) + throw new Error(`Duplicate [toolchain] table in ${filename}`); + sawToolchain = true; + inToolchain = true; + continue; + } + if (!inToolchain) + throw new Error(`Content outside [toolchain] in ${filename}`); + const assignment = splitAssignment(line, filename); + requireKnownKey(assignment.key, filename); + if (isCompleteValue(assignment.value)) { + setValue(values, assignment.key, parseValue(assignment.value, filename), filename); + } + else { + pending = assignment; + } + } + if (pending) + throw new Error(`Unterminated ${pending.key} value in ${filename}`); + if (values.size === 0) + throw new Error(`${filename} must contain a [toolchain] table`); + return values; +} +function splitAssignment(line, filename) { + const separator = findOutsideString(line, '='); + if (separator < 0) + throw new Error(`Invalid assignment in ${filename}: ${line.trim()}`); + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) || value.length === 0) { + throw new Error(`Invalid assignment in ${filename}: ${line.trim()}`); + } + return { key, value }; +} +function requireKnownKey(key, filename) { + if (!['channel', 'components', 'path', 'profile', 'targets'].includes(key)) { + throw new Error(`Unsupported toolchain key ${JSON.stringify(key)} in ${filename}`); + } +} +function setValue(values, key, value, filename) { + if (values.has(key)) + throw new Error(`Duplicate toolchain key ${JSON.stringify(key)} in ${filename}`); + values.set(key, value); +} +function parseValue(value, filename) { + const trimmed = value.trim(); + if (trimmed.startsWith('[')) + return parseStringArray(trimmed, filename); + return parseString(trimmed, filename); +} +function parseStringArray(value, filename) { + if (!value.endsWith(']')) + throw new Error(`Unterminated array in ${filename}`); + const items = []; + let index = 1; + while (index < value.length - 1) { + index = skipWhitespace(value, index); + if (value[index] === ']') + break; + const parsed = parseStringAt(value, index, filename); + items.push(parsed.value); + index = skipWhitespace(value, parsed.next); + if (value[index] === ',') { + index += 1; + continue; + } + if (value[index] !== ']') + throw new Error(`Expected a comma in an array in ${filename}`); + } + index = skipWhitespace(value, index); + if (value[index] !== ']' || value.slice(index + 1).trim().length > 0) { + throw new Error(`Invalid array in ${filename}`); + } + return items; +} +function parseString(value, filename) { + const parsed = parseStringAt(value, 0, filename); + if (value.slice(parsed.next).trim().length > 0) { + throw new Error(`Invalid string value in ${filename}`); + } + return parsed.value; +} +function parseStringAt(value, start, filename) { + const quote = value[start]; + if (quote !== '"' && quote !== "'") { + throw new Error(`Expected a quoted string in ${filename}`); + } + let escaped = false; + for (let index = start + 1; index < value.length; index += 1) { + const character = value[index]; + if (quote === '"' && character === '\\' && !escaped) { + escaped = true; + continue; + } + if (character === quote && !escaped) { + const raw = value.slice(start, index + 1); + return { + next: index + 1, + value: quote === '"' ? parseBasicString(raw, filename) : raw.slice(1, -1) + }; + } + escaped = false; + } + throw new Error(`Unterminated string in ${filename}`); +} +function parseBasicString(value, filename) { + try { + return JSON.parse(value); + } + catch { + throw new Error(`Unsupported string escape in ${filename}`); + } +} +function isCompleteValue(value) { + let quote; + let escaped = false; + let depth = 0; + for (const character of value) { + if (quote) { + if (quote === '"' && character === '\\' && !escaped) { + escaped = true; + continue; + } + if (character === quote && !escaped) + quote = undefined; + escaped = false; + continue; + } + if (character === '"' || character === "'") + quote = character; + else if (character === '[') + depth += 1; + else if (character === ']') + depth -= 1; + } + return quote === undefined && depth === 0; +} +function stripComment(line) { + const comment = findOutsideString(line, '#'); + return comment < 0 ? line : line.slice(0, comment); +} +function findOutsideString(value, sought) { + let quote; + let escaped = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (quote === '"' && character === '\\' && !escaped) { + escaped = true; + continue; + } + if (character === quote && !escaped) + quote = undefined; + escaped = false; + continue; + } + if (character === '"' || character === "'") + quote = character; + else if (character === sought) + return index; + } + return -1; +} +function skipWhitespace(value, start) { + let index = start; + while (/\s/u.test(value[index] ?? '')) + index += 1; + return index; +} +function stringValue(values, key, filename) { + const value = values.get(key); + if (value === undefined) + return undefined; + if (typeof value !== 'string') + throw new Error(`${key} must be a string in ${filename}`); + return value; +} +function arrayValue(values, key, filename) { + const value = values.get(key); + if (value === undefined) + return []; + if (typeof value === 'string') + throw new Error(`${key} must be an array in ${filename}`); + return value; +} diff --git a/dist/workspace.js b/dist/workspace.js index 2e5df48..0ef1973 100644 --- a/dist/workspace.js +++ b/dist/workspace.js @@ -64,8 +64,16 @@ async function findToolchainFile(workspace, workingDirectory) { while (isWithin(workspace, current)) { for (const filename of TOOLCHAIN_FILES) { const candidate = path.join(current, filename); - if (await exists(candidate)) + if (await exists(candidate)) { + const resolved = await (0, promises_1.realpath)(candidate); + if (!isWithin(workspace, resolved)) { + throw new Error('Rust toolchain file must remain inside GITHUB_WORKSPACE'); + } + const details = await (0, promises_1.stat)(resolved); + if (!details.isFile()) + throw new Error(`${filename} is not a file`); return candidate; + } } if (current === workspace) break; diff --git a/package.json b/package.json index 454e368..7acf30c 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "build": "tsc -p tsconfig.json", "check": "scripts/check", "check:dist": "git diff --exit-code -- dist", - "lint": "node --check dist/action.js && node --check dist/contracts.js && node --check dist/github.js && node --check dist/index.js && node --check dist/process.js && node --check dist/workspace.js && node --check tests/action.test.js && node --check tests/contracts.test.js && node --check tests/process.test.js && node --check tests/setup-contract.js && node --check tests/workspace.test.js", - "test": "node --test" + "lint": "node --check dist/action.js && node --check dist/contracts.js && node --check dist/github.js && node --check dist/index.js && node --check dist/process.js && node --check dist/rustup.js && node --check dist/toolchain-file.js && node --check dist/workspace.js && node --check tests/action.test.js && node --check tests/contracts.test.js && node --check tests/process.test.js && node --check tests/setup-contract.js && node --check tests/toolchain-file.test.js && node --check tests/workspace.test.js", + "test": "node --test --experimental-test-coverage --test-coverage-include=dist/*.js --test-coverage-lines=90 --test-coverage-branches=75 --test-coverage-functions=80" }, "engines": { "node": ">=24" diff --git a/src/action.ts b/src/action.ts index a80a0a0..faee1e0 100644 --- a/src/action.ts +++ b/src/action.ts @@ -1,13 +1,16 @@ import { booleanInput, nonEmptyDirectory, + optionalInstallableToolchain, optionalProfile, - optionalToolchain, rustupList, + rustupItems, type ActionInputs } from './contracts' import * as github from './github' import { runCommand, type CommandResult, type RunCommand } from './process' +import { installRustupToolchain } from './rustup' +import { readToolchainFile, type ToolchainFileConfig } from './toolchain-file' import { relativeSource, resolveWorkspacePaths } from './workspace' interface Dependencies { @@ -30,6 +33,7 @@ export async function runAction( ): Promise { const dependencies: Dependencies = { runCommand, ...overrides } const inputs = readInputs(environment) + requireCompatibleInputs(inputs) const workspaceValue = environment.GITHUB_WORKSPACE || process.cwd() const paths = await resolveWorkspacePaths(workspaceValue, inputs.workingDirectory) @@ -39,46 +43,40 @@ export async function runAction( ) } + const file = inputs.toolchain + ? undefined + : await readToolchainFile(requireValue(paths.toolchainFile)) const toolchainSource = inputs.toolchain ? 'input' : relativeSource(paths.workspace, requireValue(paths.toolchainFile)) - const commandEnvironment = { ...environment } - delete commandEnvironment.RUSTUP_TOOLCHAIN - if (inputs.toolchain) commandEnvironment.RUSTUP_TOOLCHAIN = inputs.toolchain + const selection = selectToolchain(inputs, file) - const installArguments = buildInstallArguments(inputs) github.startGroup(`Install Rust toolchain from ${toolchainSource}`) + let installed: Awaited> try { - await dependencies.runCommand('rustup', installArguments, { - cwd: paths.workingDirectory, - environment: commandEnvironment - }) + installed = await installRustupToolchain( + { + ...selection, + allowDowngrade: inputs.allowDowngrade, + cwd: paths.workingDirectory, + environment, + update: inputs.update + }, + dependencies.runCommand + ) } finally { github.endGroup() } - const selected = await dependencies.runCommand('rustup', ['show', 'active-toolchain'], { - cwd: paths.workingDirectory, - environment: commandEnvironment, - quiet: true - }) - const toolchain = parseActiveToolchain(selected.stdout) - const selectedEnvironment = { ...commandEnvironment, RUSTUP_TOOLCHAIN: toolchain } - - const [rustc, cargo, rustup] = await Promise.all([ + const [rustc, cargo] = await Promise.all([ dependencies.runCommand('rustc', ['--version', '--verbose'], { cwd: paths.workingDirectory, - environment: selectedEnvironment, + environment: installed.environment, quiet: true }), dependencies.runCommand('cargo', ['--version'], { cwd: paths.workingDirectory, - environment: selectedEnvironment, - quiet: true - }), - dependencies.runCommand('rustup', ['--version'], { - cwd: paths.workingDirectory, - environment: selectedEnvironment, + environment: installed.environment, quiet: true }) ]) @@ -89,8 +87,8 @@ export async function runAction( host: rustcDetails.host, rustcCommit: rustcDetails.commit, rustcVersion: rustcDetails.release, - rustupVersion: firstVersionLine(rustup, 'rustup'), - toolchain, + rustupVersion: installed.rustupVersion, + toolchain: installed.toolchain, toolchainSource } @@ -101,6 +99,12 @@ export async function runAction( return result } +function requireCompatibleInputs(inputs: ActionInputs): void { + if (!inputs.update && inputs.allowDowngrade) { + throw new Error('allow-downgrade requires update to be true') + } +} + function readInputs(environment: NodeJS.ProcessEnv): ActionInputs { return { allowDowngrade: booleanInput( @@ -110,7 +114,7 @@ function readInputs(environment: NodeJS.ProcessEnv): ActionInputs { components: rustupList(github.input('components', environment), 'components'), profile: optionalProfile(github.input('profile', environment)), targets: rustupList(github.input('targets', environment), 'targets'), - toolchain: optionalToolchain(github.input('toolchain', environment)), + toolchain: optionalInstallableToolchain(github.input('toolchain', environment)), update: booleanInput(github.input('update', environment) || 'true', 'update'), workingDirectory: nonEmptyDirectory( github.input('working-directory', environment) || '.' @@ -118,27 +122,18 @@ function readInputs(environment: NodeJS.ProcessEnv): ActionInputs { } } -function buildInstallArguments(inputs: ActionInputs): readonly string[] { - const arguments_ = ['toolchain', 'install'] - if (inputs.toolchain) arguments_.push(inputs.toolchain) - - const profile = inputs.profile ?? (inputs.toolchain ? 'minimal' : undefined) - if (profile) arguments_.push('--profile', profile) - if (inputs.components.length > 0) { - arguments_.push('--component', inputs.components.join(',')) +function selectToolchain( + inputs: ActionInputs, + file: ToolchainFileConfig | undefined +): Pick & { + profile: NonNullable +} { + return { + components: rustupItems([...(file?.components ?? []), ...inputs.components], 'components'), + profile: inputs.profile ?? file?.profile ?? 'minimal', + targets: rustupItems([...(file?.targets ?? []), ...inputs.targets], 'targets'), + toolchain: inputs.toolchain ?? requireValue(file).toolchain } - if (inputs.targets.length > 0) arguments_.push('--target', inputs.targets.join(',')) - if (!inputs.update) arguments_.push('--no-update') - if (inputs.allowDowngrade) arguments_.push('--allow-downgrade') - return arguments_ -} - -function parseActiveToolchain(stdout: string): string { - const candidate = stdout.trim().split(/\s+/u)[0] - if (!candidate) throw new Error('rustup did not report an active toolchain') - const toolchain = optionalToolchain(candidate) - if (!toolchain) throw new Error('rustup reported an empty active toolchain') - return toolchain } function parseRustc(stdout: string): { commit: string; host: string; release: string } { diff --git a/src/contracts.ts b/src/contracts.ts index d643b56..40a7e9e 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -11,6 +11,7 @@ export interface ActionInputs { } const RUSTUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u +const INSTALLABLE_TOOLCHAIN_PATTERN = /^(?:stable|beta|nightly|\d+\.\d+(?:\.\d+)?(?:-beta(?:\.\d+)?)?)(?:-[A-Za-z0-9][A-Za-z0-9._-]*)?$/u const PROFILES = new Set(['minimal', 'default', 'complete']) export function optionalToolchain(value: string): string | undefined { @@ -20,6 +21,17 @@ export function optionalToolchain(value: string): string | undefined { return toolchain } +export function optionalInstallableToolchain(value: string): string | undefined { + const toolchain = optionalToolchain(value) + if (toolchain && !INSTALLABLE_TOOLCHAIN_PATTERN.test(toolchain)) { + throw new Error( + `Invalid toolchain ${JSON.stringify(value)}; expected an installable stable, beta, nightly, ` + + 'or versioned rustup channel' + ) + } + return toolchain +} + export function optionalProfile(value: string): RustupProfile | undefined { const profile = value.trim() if (profile.length === 0) return undefined @@ -37,8 +49,13 @@ export function rustupList(value: string, inputName: string): readonly string[] .map((item) => item.trim()) .filter((item) => item.length > 0) + return rustupItems(items, inputName) +} + +export function rustupItems(items: readonly string[], inputName: string): readonly string[] { const unique: string[] = [] const seen = new Set() + for (const item of items) { requireRustupName(item, inputName) if (!seen.has(item)) { diff --git a/src/rustup.ts b/src/rustup.ts new file mode 100644 index 0000000..5a2ed4c --- /dev/null +++ b/src/rustup.ts @@ -0,0 +1,159 @@ +import { optionalInstallableToolchain, type RustupProfile } from './contracts' +import type { CommandResult, RunCommand } from './process' + +const MINIMUM_RUSTUP = Object.freeze([1, 28, 0] as const) +const MINIMUM_RUSTUP_TEXT = MINIMUM_RUSTUP.join('.') + +export interface RustupRequest { + allowDowngrade: boolean + components: readonly string[] + cwd: string + environment: NodeJS.ProcessEnv + profile: RustupProfile + targets: readonly string[] + toolchain: string + update: boolean +} + +export interface RustupResult { + environment: NodeJS.ProcessEnv + rustupVersion: string + toolchain: string +} + +export async function installRustupToolchain( + request: RustupRequest, + runCommand: RunCommand +): Promise { + const neutralEnvironment = { ...request.environment } + delete neutralEnvironment.RUSTUP_TOOLCHAIN + + const rustupVersionResult = await runCommand('rustup', ['--version'], { + cwd: request.cwd, + environment: neutralEnvironment, + quiet: true + }) + const rustupVersion = firstVersionLine(rustupVersionResult, 'rustup') + requireSupportedRustup(rustupVersion) + const hadDefault = await hasDefaultToolchain(runCommand, request.cwd, neutralEnvironment) + + const installEnvironment = { + ...neutralEnvironment, + RUSTUP_AUTO_INSTALL: '0', + RUSTUP_TOOLCHAIN: request.toolchain + } + try { + await runCommand('rustup', buildInstallArguments(request), { + cwd: request.cwd, + environment: installEnvironment + }) + } finally { + if (!hadDefault) { + await runCommand('rustup', ['default', 'none'], { + cwd: request.cwd, + environment: neutralEnvironment, + quiet: true + }) + } + } + + const active = await runCommand('rustup', ['show', 'active-toolchain'], { + cwd: request.cwd, + environment: installEnvironment, + quiet: true + }) + const toolchain = parseActiveToolchain(active.stdout) + const selectedEnvironment = { ...installEnvironment, RUSTUP_TOOLCHAIN: toolchain } + + if (!request.update) { + await addRequestedItems(request, toolchain, selectedEnvironment, runCommand) + } + + return { environment: selectedEnvironment, rustupVersion, toolchain } +} + +function buildInstallArguments(request: RustupRequest): readonly string[] { + const arguments_ = [ + 'toolchain', + 'install', + request.toolchain, + '--profile', + request.profile, + '--no-self-update' + ] + if (request.update) { + if (request.components.length > 0) { + arguments_.push('--component', request.components.join(',')) + } + if (request.targets.length > 0) arguments_.push('--target', request.targets.join(',')) + if (request.allowDowngrade) arguments_.push('--allow-downgrade') + } else { + arguments_.push('--no-update') + } + return arguments_ +} + +async function addRequestedItems( + request: RustupRequest, + toolchain: string, + environment: NodeJS.ProcessEnv, + runCommand: RunCommand +): Promise { + if (request.components.length > 0) { + await runCommand( + 'rustup', + ['component', 'add', ...request.components, '--toolchain', toolchain], + { cwd: request.cwd, environment } + ) + } + if (request.targets.length > 0) { + await runCommand('rustup', ['target', 'add', ...request.targets, '--toolchain', toolchain], { + cwd: request.cwd, + environment + }) + } +} + +async function hasDefaultToolchain( + runCommand: RunCommand, + cwd: string, + environment: NodeJS.ProcessEnv +): Promise { + const result = await runCommand('rustup', ['toolchain', 'list'], { + cwd, + environment, + quiet: true + }) + return result.stdout.split(/\r?\n/u).some((line) => /\((?:active, )?default\)$/u.test(line.trim())) +} + +function requireSupportedRustup(versionLine: string): void { + const match = /^rustup (\d+)\.(\d+)\.(\d+)(?:\s|$)/u.exec(versionLine) + if (!match) throw new Error(`rustup reported an unsupported version line: ${versionLine}`) + const actual = match.slice(1, 4).map(Number) + for (let index = 0; index < MINIMUM_RUSTUP.length; index += 1) { + const difference = (actual[index] ?? 0) - (MINIMUM_RUSTUP[index] ?? 0) + if (difference > 0) return + if (difference < 0) { + throw new Error(`setup-rust requires rustup ${MINIMUM_RUSTUP_TEXT} or newer; found ${versionLine}`) + } + } +} + +function parseActiveToolchain(stdout: string): string { + const candidate = stdout.trim().split(/\s+/u)[0] + if (!candidate) throw new Error('rustup did not report an active toolchain') + try { + const toolchain = optionalInstallableToolchain(candidate) + if (toolchain) return toolchain + } catch { + throw new Error(`rustup reported an unsupported active toolchain ${JSON.stringify(candidate)}`) + } + throw new Error('rustup reported an empty active toolchain') +} + +function firstVersionLine(result: CommandResult, command: string): string { + const line = result.stdout.split(/\r?\n/u).find((candidate) => candidate.trim().length > 0) + if (!line) throw new Error(`${command} did not report a version`) + return line.trim() +} diff --git a/src/toolchain-file.ts b/src/toolchain-file.ts new file mode 100644 index 0000000..203b491 --- /dev/null +++ b/src/toolchain-file.ts @@ -0,0 +1,288 @@ +import { readFile, stat } from 'node:fs/promises' +import * as path from 'node:path' +import { + optionalInstallableToolchain, + optionalProfile, + rustupItems, + type RustupProfile +} from './contracts' + +export interface ToolchainFileConfig { + components: readonly string[] + profile: RustupProfile | undefined + targets: readonly string[] + toolchain: string +} + +type ParsedValue = string | readonly string[] +const MAX_TOOLCHAIN_FILE_BYTES = 64 * 1024 + +export async function readToolchainFile(file: string): Promise { + const details = await stat(file) + if (details.size > MAX_TOOLCHAIN_FILE_BYTES) { + throw new Error(`Rust toolchain file exceeds ${String(MAX_TOOLCHAIN_FILE_BYTES)} bytes`) + } + const contents = await readFile(file, 'utf8') + return parseToolchainFile(contents, path.basename(file)) +} + +export function parseToolchainFile(contents: string, filename: string): ToolchainFileConfig { + if (contents.includes('\0')) throw new Error(`${filename} cannot contain NUL bytes`) + const normalized = contents.replaceAll('\r\n', '\n').replaceAll('\r', '\n') + + if (filename === 'rust-toolchain' && isLegacyFile(normalized)) { + const toolchain = optionalInstallableToolchain(normalized) + if (!toolchain) throw new Error('rust-toolchain cannot be empty') + return { components: [], profile: undefined, targets: [], toolchain } + } + + const values = parseToolchainTable(normalized, filename) + if (values.has('path')) { + throw new Error( + `${filename} uses a path toolchain, which setup-rust does not support; ` + + 'use an installable rustup channel' + ) + } + + const channel = stringValue(values, 'channel', filename) + if (!channel) throw new Error(`${filename} must define toolchain.channel`) + const toolchain = optionalInstallableToolchain(channel) + if (!toolchain) throw new Error(`${filename} must define a non-empty toolchain.channel`) + + const profileValue = stringValue(values, 'profile', filename) + const components = arrayValue(values, 'components', filename) + const targets = arrayValue(values, 'targets', filename) + return { + components: rustupItems(components, 'components'), + profile: optionalProfile(profileValue ?? ''), + targets: rustupItems(targets, 'targets'), + toolchain + } +} + +function isLegacyFile(contents: string): boolean { + return !contents.trimStart().startsWith('[') +} + +function parseToolchainTable(contents: string, filename: string): ReadonlyMap { + const values = new Map() + const lines = contents.split('\n') + let inToolchain = false + let sawToolchain = false + let pending: { key: string; value: string } | undefined + + for (const rawLine of lines) { + const line = stripComment(rawLine) + if (pending) { + pending.value += `\n${line}` + if (isCompleteValue(pending.value)) { + setValue(values, pending.key, parseValue(pending.value, filename), filename) + pending = undefined + } + continue + } + + const trimmed = line.trim() + if (trimmed.length === 0) continue + const header = /^\[([^\]]+)\]$/u.exec(trimmed) + if (header) { + const table = header[1]?.trim() + if (table !== 'toolchain') { + throw new Error(`Unsupported table ${JSON.stringify(table)} in ${filename}`) + } + if (sawToolchain) throw new Error(`Duplicate [toolchain] table in ${filename}`) + sawToolchain = true + inToolchain = true + continue + } + if (!inToolchain) throw new Error(`Content outside [toolchain] in ${filename}`) + + const assignment = splitAssignment(line, filename) + requireKnownKey(assignment.key, filename) + if (isCompleteValue(assignment.value)) { + setValue(values, assignment.key, parseValue(assignment.value, filename), filename) + } else { + pending = assignment + } + } + + if (pending) throw new Error(`Unterminated ${pending.key} value in ${filename}`) + if (values.size === 0) throw new Error(`${filename} must contain a [toolchain] table`) + return values +} + +function splitAssignment(line: string, filename: string): { key: string; value: string } { + const separator = findOutsideString(line, '=') + if (separator < 0) throw new Error(`Invalid assignment in ${filename}: ${line.trim()}`) + const key = line.slice(0, separator).trim() + const value = line.slice(separator + 1).trim() + if (!/^[A-Za-z][A-Za-z0-9_-]*$/u.test(key) || value.length === 0) { + throw new Error(`Invalid assignment in ${filename}: ${line.trim()}`) + } + return { key, value } +} + +function requireKnownKey(key: string, filename: string): void { + if (!['channel', 'components', 'path', 'profile', 'targets'].includes(key)) { + throw new Error(`Unsupported toolchain key ${JSON.stringify(key)} in ${filename}`) + } +} + +function setValue( + values: Map, + key: string, + value: ParsedValue, + filename: string +): void { + if (values.has(key)) throw new Error(`Duplicate toolchain key ${JSON.stringify(key)} in ${filename}`) + values.set(key, value) +} + +function parseValue(value: string, filename: string): ParsedValue { + const trimmed = value.trim() + if (trimmed.startsWith('[')) return parseStringArray(trimmed, filename) + return parseString(trimmed, filename) +} + +function parseStringArray(value: string, filename: string): readonly string[] { + if (!value.endsWith(']')) throw new Error(`Unterminated array in ${filename}`) + const items: string[] = [] + let index = 1 + + while (index < value.length - 1) { + index = skipWhitespace(value, index) + if (value[index] === ']') break + const parsed = parseStringAt(value, index, filename) + items.push(parsed.value) + index = skipWhitespace(value, parsed.next) + if (value[index] === ',') { + index += 1 + continue + } + if (value[index] !== ']') throw new Error(`Expected a comma in an array in ${filename}`) + } + + index = skipWhitespace(value, index) + if (value[index] !== ']' || value.slice(index + 1).trim().length > 0) { + throw new Error(`Invalid array in ${filename}`) + } + return items +} + +function parseString(value: string, filename: string): string { + const parsed = parseStringAt(value, 0, filename) + if (value.slice(parsed.next).trim().length > 0) { + throw new Error(`Invalid string value in ${filename}`) + } + return parsed.value +} + +function parseStringAt( + value: string, + start: number, + filename: string +): { next: number; value: string } { + const quote = value[start] + if (quote !== '"' && quote !== "'") { + throw new Error(`Expected a quoted string in ${filename}`) + } + let escaped = false + for (let index = start + 1; index < value.length; index += 1) { + const character = value[index] + if (quote === '"' && character === '\\' && !escaped) { + escaped = true + continue + } + if (character === quote && !escaped) { + const raw = value.slice(start, index + 1) + return { + next: index + 1, + value: quote === '"' ? parseBasicString(raw, filename) : raw.slice(1, -1) + } + } + escaped = false + } + throw new Error(`Unterminated string in ${filename}`) +} + +function parseBasicString(value: string, filename: string): string { + try { + return JSON.parse(value) as string + } catch { + throw new Error(`Unsupported string escape in ${filename}`) + } +} + +function isCompleteValue(value: string): boolean { + let quote: string | undefined + let escaped = false + let depth = 0 + for (const character of value) { + if (quote) { + if (quote === '"' && character === '\\' && !escaped) { + escaped = true + continue + } + if (character === quote && !escaped) quote = undefined + escaped = false + continue + } + if (character === '"' || character === "'") quote = character + else if (character === '[') depth += 1 + else if (character === ']') depth -= 1 + } + return quote === undefined && depth === 0 +} + +function stripComment(line: string): string { + const comment = findOutsideString(line, '#') + return comment < 0 ? line : line.slice(0, comment) +} + +function findOutsideString(value: string, sought: string): number { + let quote: string | undefined + let escaped = false + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (quote) { + if (quote === '"' && character === '\\' && !escaped) { + escaped = true + continue + } + if (character === quote && !escaped) quote = undefined + escaped = false + continue + } + if (character === '"' || character === "'") quote = character + else if (character === sought) return index + } + return -1 +} + +function skipWhitespace(value: string, start: number): number { + let index = start + while (/\s/u.test(value[index] ?? '')) index += 1 + return index +} + +function stringValue( + values: ReadonlyMap, + key: string, + filename: string +): string | undefined { + const value = values.get(key) + if (value === undefined) return undefined + if (typeof value !== 'string') throw new Error(`${key} must be a string in ${filename}`) + return value +} + +function arrayValue( + values: ReadonlyMap, + key: string, + filename: string +): readonly string[] { + const value = values.get(key) + if (value === undefined) return [] + if (typeof value === 'string') throw new Error(`${key} must be an array in ${filename}`) + return value +} diff --git a/src/workspace.ts b/src/workspace.ts index ee97d04..0747e7c 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -43,7 +43,15 @@ async function findToolchainFile( while (isWithin(workspace, current)) { for (const filename of TOOLCHAIN_FILES) { const candidate = path.join(current, filename) - if (await exists(candidate)) return candidate + if (await exists(candidate)) { + const resolved = await realpath(candidate) + if (!isWithin(workspace, resolved)) { + throw new Error('Rust toolchain file must remain inside GITHUB_WORKSPACE') + } + const details = await stat(resolved) + if (!details.isFile()) throw new Error(`${filename} is not a file`) + return candidate + } } if (current === workspace) break current = path.dirname(current) diff --git a/tests/action.test.js b/tests/action.test.js index fcc251d..d98bcf6 100644 --- a/tests/action.test.js +++ b/tests/action.test.js @@ -7,6 +7,7 @@ const path = require('node:path') const { test } = require('node:test') const { runAction } = require('../dist/action') +const ACTIVE_TOOLCHAIN = '1.88.0-x86_64-unknown-linux-gnu' const RUSTC_VERBOSE = [ 'rustc 1.88.0 (6b00bc388 2025-06-23)', 'binary: rustc', @@ -18,79 +19,121 @@ const RUSTC_VERBOSE = [ '' ].join('\n') -test('installs and exports an explicit toolchain with safe argv boundaries', async (context) => { +test('keeps no-update explicit installs additive and argument-safe', async (context) => { const fixture = await createFixture(context) const calls = [] const environment = { ...fixture.environment, - 'INPUT_ALLOW-DOWNGRADE': 'true', INPUT_COMPONENTS: 'clippy, rustfmt,clippy', - INPUT_PROFILE: '', INPUT_TARGETS: 'wasm32-unknown-unknown', INPUT_TOOLCHAIN: '1.88.0', - INPUT_UPDATE: 'false', - 'INPUT_WORKING-DIRECTORY': '.' + INPUT_UPDATE: 'false' } const result = await runAction(environment, { runCommand: fakeRunner(calls) }) - assert.deepEqual(calls[0].arguments, [ + assert.deepEqual(command(calls, 'toolchain', 'install').arguments, [ 'toolchain', 'install', '1.88.0', '--profile', 'minimal', - '--component', - 'clippy,rustfmt', - '--target', + '--no-self-update', + '--no-update' + ]) + assert.deepEqual(command(calls, 'component', 'add').arguments, [ + 'component', + 'add', + 'clippy', + 'rustfmt', + '--toolchain', + ACTIVE_TOOLCHAIN + ]) + assert.deepEqual(command(calls, 'target', 'add').arguments, [ + 'target', + 'add', 'wasm32-unknown-unknown', - '--no-update', - '--allow-downgrade' + '--toolchain', + ACTIVE_TOOLCHAIN ]) - assert.equal(calls[0].environment.RUSTUP_TOOLCHAIN, '1.88.0') - assert.equal(result.toolchain, '1.88.0-x86_64-unknown-linux-gnu') + assert.equal(command(calls, 'toolchain', 'list').environment.RUSTUP_TOOLCHAIN, undefined) + assert.equal(command(calls, 'toolchain', 'install').environment.RUSTUP_TOOLCHAIN, '1.88.0') + assert.equal(result.toolchain, ACTIVE_TOOLCHAIN) assert.equal(result.toolchainSource, 'input') - assert.equal(result.rustcVersion, '1.88.0') - assert.equal(result.rustcCommit, '6b00bc3880198600130e1cf62b8f8a93494488cc') - assert.equal(result.host, 'x86_64-unknown-linux-gnu') - assert.equal( - environment.RUSTUP_TOOLCHAIN, - '1.88.0-x86_64-unknown-linux-gnu' - ) - assert.match( - await readFile(fixture.environmentFile, 'utf8'), - /^RUSTUP_TOOLCHAIN=1\.88\.0-x86_64-unknown-linux-gnu$/mu - ) - const outputs = await readFile(fixture.outputFile, 'utf8') - assert.match(outputs, /^toolchain-source=input$/mu) - assert.match(outputs, /^cargo-version=cargo 1\.88\.0 .*$/mu) + assert.equal(environment.RUSTUP_TOOLCHAIN, ACTIVE_TOOLCHAIN) + assert.match(await readFile(fixture.environmentFile, 'utf8'), /^RUSTUP_TOOLCHAIN=1\.88\.0-/mu) }) -test('uses the repository toolchain file and clears inherited selection', async (context) => { +test('merges repository and action modifiers into an explicit file-selected install', async (context) => { const fixture = await createFixture(context) const nested = path.join(fixture.root, 'crates', 'core') await mkdir(nested, { recursive: true }) await writeFile( path.join(fixture.root, 'rust-toolchain.toml'), - '[toolchain]\nchannel = "1.88.0"\nprofile = "minimal"\n' + '[toolchain]\nchannel = "1.88.0"\nprofile = "minimal"\ncomponents = ["clippy"]\n' ) const calls = [] - const result = await runAction( { ...fixture.environment, + 'INPUT_ALLOW-DOWNGRADE': 'true', RUSTUP_TOOLCHAIN: 'stable', + INPUT_COMPONENTS: 'rustfmt,clippy', + INPUT_TARGETS: 'wasm32-unknown-unknown', 'INPUT_WORKING-DIRECTORY': 'crates/core' }, { runCommand: fakeRunner(calls) } ) - assert.deepEqual(calls[0].arguments, ['toolchain', 'install']) - assert.equal(calls[0].cwd, await realpath(nested)) - assert.equal(calls[0].environment.RUSTUP_TOOLCHAIN, undefined) - assert.equal(calls[1].environment.RUSTUP_TOOLCHAIN, undefined) + assert.deepEqual(command(calls, 'toolchain', 'install').arguments, [ + 'toolchain', + 'install', + '1.88.0', + '--profile', + 'minimal', + '--no-self-update', + '--component', + 'clippy,rustfmt', + '--target', + 'wasm32-unknown-unknown', + '--allow-downgrade' + ]) + assert.equal(command(calls, 'toolchain', 'install').cwd, await realpath(nested)) + assert.equal(command(calls, 'toolchain', 'list').environment.RUSTUP_TOOLCHAIN, undefined) + assert.equal(command(calls, 'toolchain', 'install').environment.RUSTUP_TOOLCHAIN, '1.88.0') assert.equal(result.toolchainSource, 'rust-toolchain.toml') }) +test('restores an absent global default after installation', async (context) => { + const fixture = await createFixture(context) + const calls = [] + await runAction( + { ...fixture.environment, INPUT_TOOLCHAIN: '1.88.0' }, + { runCommand: fakeRunner(calls, { hasDefault: false }) } + ) + + const installIndex = calls.findIndex( + (call) => call.arguments[0] === 'toolchain' && call.arguments[1] === 'install' + ) + const restoreIndex = calls.findIndex((call) => call.arguments[0] === 'default') + const showIndex = calls.findIndex((call) => call.arguments[0] === 'show') + assert.ok(installIndex >= 0 && restoreIndex > installIndex && showIndex > restoreIndex) + assert.deepEqual(calls[restoreIndex].arguments, ['default', 'none']) +}) + +test('restores an absent global default even when installation fails', async (context) => { + const fixture = await createFixture(context) + const calls = [] + const runner = fakeRunner(calls, { hasDefault: false, installError: new Error('rustup failed') }) + + await assert.rejects( + runAction({ ...fixture.environment, INPUT_TOOLCHAIN: '1.88.0' }, { runCommand: runner }), + /rustup failed/u + ) + assert.deepEqual(command(calls, 'default', 'none').arguments, ['default', 'none']) + assert.equal(await readFile(fixture.environmentFile, 'utf8'), '') + assert.equal(await readFile(fixture.outputFile, 'utf8'), '') +}) + test('fails closed when neither an input nor a toolchain file selects Rust', async (context) => { const fixture = await createFixture(context) let called = false @@ -105,67 +148,116 @@ test('fails closed when neither an input nor a toolchain file selects Rust', asy /No Rust toolchain selected/u ) assert.equal(called, false) - assert.equal(await readFile(fixture.environmentFile, 'utf8'), '') - assert.equal(await readFile(fixture.outputFile, 'utf8'), '') }) -test('does not publish outputs when rustup installation fails', async (context) => { +test('rejects path toolchain files before invoking rustup', async (context) => { + const fixture = await createFixture(context) + await writeFile( + path.join(fixture.root, 'rust-toolchain.toml'), + '[toolchain]\npath = "/opt/rust/custom"\n' + ) + let called = false + + await assert.rejects( + runAction(fixture.environment, { + runCommand: async () => { + called = true + throw new Error('unexpected command') + } + }), + /path toolchain.*does not support/u + ) + assert.equal(called, false) +}) + +test('rejects unsupported rustup before installation', async (context) => { const fixture = await createFixture(context) + const calls = [] + const runner = fakeRunner(calls, { rustupVersion: 'rustup 1.27.1 (old 2024-01-01)' }) await assert.rejects( - runAction( - { ...fixture.environment, INPUT_TOOLCHAIN: '1.88.0' }, - { runCommand: async () => Promise.reject(new Error('rustup failed')) } - ), - /rustup failed/u + runAction({ ...fixture.environment, INPUT_TOOLCHAIN: '1.88.0' }, { runCommand: runner }), + /requires rustup 1\.28\.0 or newer/u ) - assert.equal(await readFile(fixture.environmentFile, 'utf8'), '') - assert.equal(await readFile(fixture.outputFile, 'utf8'), '') + assert.equal(calls.some((call) => call.arguments[0] === 'toolchain'), false) }) -test('rejects an invalid active toolchain reported by rustup', async (context) => { +test('rejects downgrade permission when updates are disabled', async (context) => { const fixture = await createFixture(context) - const runner = fakeRunner([]) + let called = false await assert.rejects( runAction( - { ...fixture.environment, INPUT_TOOLCHAIN: '1.88.0' }, { - runCommand: async (command, arguments_, options) => { - if (command === 'rustup' && arguments_[0] === 'show') { - return { stderr: '', stdout: '--help (unexpected)\n' } - } - return runner(command, arguments_, options) + ...fixture.environment, + 'INPUT_ALLOW-DOWNGRADE': 'true', + INPUT_TOOLCHAIN: 'stable', + INPUT_UPDATE: 'false' + }, + { + runCommand: async () => { + called = true + throw new Error('unexpected command') } } ), - /Invalid toolchain item/u + /allow-downgrade requires update to be true/u + ) + assert.equal(called, false) +}) + +test('rejects an invalid active toolchain reported by rustup', async (context) => { + const fixture = await createFixture(context) + const calls = [] + const runner = fakeRunner(calls, { activeToolchain: '/outside/toolchain' }) + + await assert.rejects( + runAction({ ...fixture.environment, INPUT_TOOLCHAIN: '1.88.0' }, { runCommand: runner }), + /unsupported active toolchain/u ) - assert.equal(await readFile(fixture.environmentFile, 'utf8'), '') - assert.equal(await readFile(fixture.outputFile, 'utf8'), '') }) -function fakeRunner(calls) { - return async (command, arguments_, options) => { - calls.push({ arguments: [...arguments_], command, ...options }) - if (command === 'rustup' && arguments_[0] === 'toolchain') { +function fakeRunner(calls, options = {}) { + const activeToolchain = options.activeToolchain ?? ACTIVE_TOOLCHAIN + const hasDefault = options.hasDefault ?? true + const rustupVersion = options.rustupVersion ?? 'rustup 1.29.0 (28d1352db 2026-03-05)' + return async (executable, arguments_, commandOptions) => { + calls.push({ arguments: [...arguments_], command: executable, ...commandOptions }) + if (executable === 'rustup' && arguments_[0] === '--version') { + return { stderr: '', stdout: `${rustupVersion}\n` } + } + if (executable === 'rustup' && arguments_[0] === 'toolchain' && arguments_[1] === 'list') { + return { stderr: '', stdout: hasDefault ? 'stable-x86_64-unknown-linux-gnu (default)\n' : '' } + } + if (executable === 'rustup' && arguments_[0] === 'toolchain') { + if (options.installError) throw options.installError return { stderr: '', stdout: '' } } - if (command === 'rustup' && arguments_[0] === 'show') { - return { - stderr: '', - stdout: '1.88.0-x86_64-unknown-linux-gnu (overridden by RUSTUP_TOOLCHAIN)\n' - } + if (executable === 'rustup' && arguments_[0] === 'default') { + return { stderr: '', stdout: '' } + } + if (executable === 'rustup' && arguments_[0] === 'show') { + return { stderr: '', stdout: `${activeToolchain} (overridden by RUSTUP_TOOLCHAIN)\n` } } - if (command === 'rustc') return { stderr: '', stdout: RUSTC_VERBOSE } - if (command === 'cargo') { + if (executable === 'rustup' && ['component', 'target'].includes(arguments_[0])) { + return { stderr: '', stdout: '' } + } + if (executable === 'rustc') return { stderr: '', stdout: RUSTC_VERBOSE } + if (executable === 'cargo') { return { stderr: '', stdout: 'cargo 1.88.0 (873a06493 2025-05-10)\n' } } - if (command === 'rustup') return { stderr: '', stdout: 'rustup 1.28.2 (e4f3ad6f8 2025-04-28)\n' } - throw new Error(`Unexpected command ${command}`) + throw new Error(`Unexpected command ${executable} ${arguments_.join(' ')}`) } } +function command(calls, first, second) { + const found = calls.find( + (call) => call.arguments[0] === first && (second === undefined || call.arguments[1] === second) + ) + assert.ok(found, `Expected command arguments starting with ${first} ${second ?? ''}`) + return found +} + async function createFixture(context) { const root = await mkdtemp(path.join(os.tmpdir(), 'setup-rust-action-')) context.after(() => rm(root, { force: true, recursive: true })) diff --git a/tests/contracts.test.js b/tests/contracts.test.js index 5ce0d74..7302c37 100644 --- a/tests/contracts.test.js +++ b/tests/contracts.test.js @@ -5,6 +5,7 @@ const { test } = require('node:test') const { booleanInput, nonEmptyDirectory, + optionalInstallableToolchain, optionalProfile, optionalToolchain, rustupList @@ -20,6 +21,16 @@ test('rejects toolchain values that could become command options', () => { assert.throws(() => optionalToolchain('stable latest'), /Invalid toolchain item/u) }) +test('accepts only installable distribution toolchains at the action boundary', () => { + assert.equal(optionalInstallableToolchain('stable'), 'stable') + assert.equal( + optionalInstallableToolchain('nightly-2026-08-01-x86_64-unknown-linux-gnu'), + 'nightly-2026-08-01-x86_64-unknown-linux-gnu' + ) + assert.equal(optionalInstallableToolchain('1.88.0-beta.1'), '1.88.0-beta.1') + assert.throws(() => optionalInstallableToolchain('review-custom'), /expected an installable/u) +}) + test('parses and deduplicates rustup lists without reordering them', () => { assert.deepEqual(rustupList('clippy, rustfmt\nclippy', 'components'), [ 'clippy', diff --git a/tests/fixtures/file-toolchain/rust-toolchain.toml b/tests/fixtures/file-toolchain/rust-toolchain.toml index 468ef58..55b200d 100644 --- a/tests/fixtures/file-toolchain/rust-toolchain.toml +++ b/tests/fixtures/file-toolchain/rust-toolchain.toml @@ -1,5 +1,3 @@ [toolchain] channel = "1.88.0" profile = "minimal" -components = ["clippy", "rustfmt"] -targets = ["wasm32-unknown-unknown"] diff --git a/tests/toolchain-file.test.js b/tests/toolchain-file.test.js new file mode 100644 index 0000000..2479b1e --- /dev/null +++ b/tests/toolchain-file.test.js @@ -0,0 +1,77 @@ +'use strict' + +const assert = require('node:assert/strict') +const { test } = require('node:test') +const { parseToolchainFile } = require('../dist/toolchain-file') + +test('parses a legacy rust-toolchain file', () => { + assert.deepEqual(parseToolchainFile('nightly-2026-08-01\n', 'rust-toolchain'), { + components: [], + profile: undefined, + targets: [], + toolchain: 'nightly-2026-08-01' + }) +}) + +test('parses the supported toolchain TOML contract', () => { + const config = parseToolchainFile( + [ + '# repository policy', + '[toolchain]', + 'channel = "1.88.0" # exact release', + "profile = 'minimal'", + 'components = [', + ' "clippy",', + ' "rustfmt", # formatting', + ' "clippy",', + ']', + 'targets = ["wasm32-unknown-unknown"]' + ].join('\n'), + 'rust-toolchain.toml' + ) + + assert.deepEqual(config, { + components: ['clippy', 'rustfmt'], + profile: 'minimal', + targets: ['wasm32-unknown-unknown'], + toolchain: '1.88.0' + }) +}) + +test('rejects path toolchains with an explicit boundary', () => { + assert.throws( + () => + parseToolchainFile( + '[toolchain]\npath = "/opt/rust/custom"\n', + 'rust-toolchain.toml' + ), + /path toolchain.*does not support/u + ) +}) + +test('rejects malformed and ambiguous toolchain tables', () => { + assert.throws( + () => parseToolchainFile('[toolchain]\ncomponents = ["clippy"]\n', 'rust-toolchain.toml'), + /must define toolchain\.channel/u + ) + assert.throws( + () => + parseToolchainFile( + '[toolchain]\nchannel = "stable"\nchannel = "beta"\n', + 'rust-toolchain.toml' + ), + /Duplicate toolchain key/u + ) + assert.throws( + () => parseToolchainFile('[toolchain]\nchannel = "stable\n', 'rust-toolchain.toml'), + /Unterminated/u + ) + assert.throws( + () => + parseToolchainFile( + '[toolchain]\nchannel = "stable"\n[other]\nvalue = "no"\n', + 'rust-toolchain.toml' + ), + /Unsupported table/u + ) +}) diff --git a/tests/workspace.test.js b/tests/workspace.test.js index 8533808..72175f1 100644 --- a/tests/workspace.test.js +++ b/tests/workspace.test.js @@ -41,6 +41,17 @@ test('rejects lexical and symlink escapes from the workspace', async (context) = const link = path.join(root, 'outside') await symlink(outside, link, 'dir') await assert.rejects(resolveWorkspacePaths(root, 'outside'), /must remain inside/u) + + const externalToolchain = path.join(outside, 'external-toolchain.toml') + await writeFile(externalToolchain, '[toolchain]\nchannel = "stable"\n') + await symlink(externalToolchain, path.join(root, 'rust-toolchain.toml')) + await assert.rejects(resolveWorkspacePaths(root, '.'), /toolchain file must remain inside/iu) +}) + +test('rejects a directory named like a toolchain file', async (context) => { + const root = await fixture(context) + await mkdir(path.join(root, 'rust-toolchain.toml')) + await assert.rejects(resolveWorkspacePaths(root, '.'), /is not a file/u) }) async function fixture(context) { From 84860acff7f3b5ca967259af8c450a2f42e29daa Mon Sep 17 00:00:00 2001 From: zsumz Date: Mon, 24 Aug 2026 14:19:03 -0500 Subject: [PATCH 2/4] docs: pin hardened action revision --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bd965dd..d4cbd37 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ components = ["clippy", "rustfmt"] ```yaml - name: Set up Rust - uses: zactionsz/setup-rust@f284a629c36439306a68624f66759dc33288b950 + uses: zactionsz/setup-rust@17eb499f91d94972dc514e6d6bbe873ea5c44e6d ``` An explicit input overrides any repository toolchain file. This is useful for @@ -31,7 +31,7 @@ compatibility and latest-stable jobs: ```yaml - name: Set up Rust 1.88 id: rust - uses: zactionsz/setup-rust@f284a629c36439306a68624f66759dc33288b950 + uses: zactionsz/setup-rust@17eb499f91d94972dc514e6d6bbe873ea5c44e6d with: toolchain: "1.88.0" components: rustfmt,clippy From 329a2736cd0ad2a71140f3fb6011f5abbd8fb5a1 Mon Sep 17 00:00:00 2001 From: zsumz Date: Mon, 24 Aug 2026 14:21:30 -0500 Subject: [PATCH 3/4] fix: read toolchain policy atomically --- dist/toolchain-file.js | 18 +++++++++++++----- src/toolchain-file.ts | 18 ++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/dist/toolchain-file.js b/dist/toolchain-file.js index 966b678..af79d33 100644 --- a/dist/toolchain-file.js +++ b/dist/toolchain-file.js @@ -40,12 +40,20 @@ const path = __importStar(require("node:path")); const contracts_1 = require("./contracts"); const MAX_TOOLCHAIN_FILE_BYTES = 64 * 1024; async function readToolchainFile(file) { - const details = await (0, promises_1.stat)(file); - if (details.size > MAX_TOOLCHAIN_FILE_BYTES) { - throw new Error(`Rust toolchain file exceeds ${String(MAX_TOOLCHAIN_FILE_BYTES)} bytes`); + const handle = await (0, promises_1.open)(file, 'r'); + try { + const details = await handle.stat(); + if (!details.isFile()) + throw new Error('Rust toolchain policy is not a regular file'); + if (details.size > MAX_TOOLCHAIN_FILE_BYTES) { + throw new Error(`Rust toolchain file exceeds ${String(MAX_TOOLCHAIN_FILE_BYTES)} bytes`); + } + const contents = await handle.readFile({ encoding: 'utf8' }); + return parseToolchainFile(contents, path.basename(file)); + } + finally { + await handle.close(); } - const contents = await (0, promises_1.readFile)(file, 'utf8'); - return parseToolchainFile(contents, path.basename(file)); } function parseToolchainFile(contents, filename) { if (contents.includes('\0')) diff --git a/src/toolchain-file.ts b/src/toolchain-file.ts index 203b491..035897a 100644 --- a/src/toolchain-file.ts +++ b/src/toolchain-file.ts @@ -1,4 +1,4 @@ -import { readFile, stat } from 'node:fs/promises' +import { open } from 'node:fs/promises' import * as path from 'node:path' import { optionalInstallableToolchain, @@ -18,12 +18,18 @@ type ParsedValue = string | readonly string[] const MAX_TOOLCHAIN_FILE_BYTES = 64 * 1024 export async function readToolchainFile(file: string): Promise { - const details = await stat(file) - if (details.size > MAX_TOOLCHAIN_FILE_BYTES) { - throw new Error(`Rust toolchain file exceeds ${String(MAX_TOOLCHAIN_FILE_BYTES)} bytes`) + const handle = await open(file, 'r') + try { + const details = await handle.stat() + if (!details.isFile()) throw new Error('Rust toolchain policy is not a regular file') + if (details.size > MAX_TOOLCHAIN_FILE_BYTES) { + throw new Error(`Rust toolchain file exceeds ${String(MAX_TOOLCHAIN_FILE_BYTES)} bytes`) + } + const contents = await handle.readFile({ encoding: 'utf8' }) + return parseToolchainFile(contents, path.basename(file)) + } finally { + await handle.close() } - const contents = await readFile(file, 'utf8') - return parseToolchainFile(contents, path.basename(file)) } export function parseToolchainFile(contents: string, filename: string): ToolchainFileConfig { From a56239f5b686124c8fb0624e193164e5109f03d9 Mon Sep 17 00:00:00 2001 From: zsumz Date: Mon, 24 Aug 2026 14:21:59 -0500 Subject: [PATCH 4/4] docs: pin atomic policy revision --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d4cbd37..701d111 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ components = ["clippy", "rustfmt"] ```yaml - name: Set up Rust - uses: zactionsz/setup-rust@17eb499f91d94972dc514e6d6bbe873ea5c44e6d + uses: zactionsz/setup-rust@329a2736cd0ad2a71140f3fb6011f5abbd8fb5a1 ``` An explicit input overrides any repository toolchain file. This is useful for @@ -31,7 +31,7 @@ compatibility and latest-stable jobs: ```yaml - name: Set up Rust 1.88 id: rust - uses: zactionsz/setup-rust@17eb499f91d94972dc514e6d6bbe873ea5c44e6d + uses: zactionsz/setup-rust@329a2736cd0ad2a71140f3fb6011f5abbd8fb5a1 with: toolchain: "1.88.0" components: rustfmt,clippy