diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..4021947934 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Shell entry points must remain LF so they can run before Rust tooling exists. +# The shared bootstrap parser still accepts well-formed CRLF in the two TOML +# files needed by an existing Windows worktree. Keep these rules coordinated +# with `tools/toolchain.sh` and `ci/check_tools.sh`. +*.sh text eol=lf +githooks/pre-push text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c5d70bf7c..cee70f0857 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -824,6 +824,16 @@ jobs: - name: Check Rust formatting run: ./ci/check_fmt.sh + check_tools: + runs-on: ubuntu-latest + name: Check repository tools + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test repository tools + run: ./ci/check_tools.sh + check_stale_stderr: runs-on: ubuntu-latest name: Check stale stderr files @@ -1048,7 +1058,7 @@ jobs: # success in branch protection, so this aggregation must fail closed. if: ${{ always() }} runs-on: ubuntu-latest - needs: [build_test, miri, codegen, coverage, kani, check_be_aarch64, check_avr_atmega, check_fmt, check_actions, check_readme, check_versions, check_msrv_is_minimal, check_stale_stderr, check-all-toolchains-tested, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env] + needs: [build_test, miri, codegen, coverage, kani, check_be_aarch64, check_avr_atmega, check_fmt, check_tools, check_actions, check_readme, check_versions, check_msrv_is_minimal, check_stale_stderr, check-all-toolchains-tested, check-job-dependencies, check-todo, run-git-hooks, zizmor, build_docker_env] steps: - name: Reject workflow cancellation if: ${{ cancelled() }} diff --git a/.github/workflows/roll-pinned-toolchain-versions.yml b/.github/workflows/roll-pinned-toolchain-versions.yml index 0a795c44dd..fb89495933 100644 --- a/.github/workflows/roll-pinned-toolchain-versions.yml +++ b/.github/workflows/roll-pinned-toolchain-versions.yml @@ -114,6 +114,31 @@ jobs: ./tools/update-expected-test-output.sh } + function update-tools-toolchain { + VERSION="$1" + + # `zerocopy/cargo.sh` builds repository tools from the `tools` + # directory, so rustup selects this pin before cargo-zerocopy can + # read the library's Cargo metadata. Keep this explicit copy + # coordinated with the stable pin above. This explicit update and + # the exact-match checks make a future file reorganization fail + # visibly instead of silently leaving the tools compiler behind. + REGEX='^channel = "[0-9]+\.[0-9]+\.[0-9]+"$' + MATCH_COUNT="$( + grep -Ec "$REGEX" tools/rust-toolchain.toml || true + )" + if [ "$MATCH_COUNT" -ne 1 ]; then + echo "Expected exactly one line matching '$REGEX' in" \ + "tools/rust-toolchain.toml; found $MATCH_COUNT" >&2 + exit 1 + fi + sed -i -E \ + "s/$REGEX/channel = \"$VERSION\"/" \ + tools/rust-toolchain.toml + grep -Fx "channel = \"$VERSION\"" \ + tools/rust-toolchain.toml >/dev/null + } + if [ "$TOOLCHAIN" == stable ]; then STABLE_VERSION="$( rustc +stable --version --verbose \ @@ -123,6 +148,7 @@ jobs: echo "Could not determine the latest stable rustc version" >&2 exit 1 fi + update-tools-toolchain "$STABLE_VERSION" update-pinned-version stable "$STABLE_VERSION" \ '--features __internal_use_only_features_that_work_on_stable' diff --git a/ci/check_tools.sh b/ci/check_tools.sh new file mode 100755 index 0000000000..640948a13a --- /dev/null +++ b/ci/check_tools.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +set -eo pipefail +cd "$(dirname "$0")/../tools" + +# shellcheck source=../tools/toolchain.sh +source ./toolchain.sh + +# The stable-toolchain roller intentionally updates these two files together. +# Check the copy here as well so a hand edit cannot make local tool builds use +# a different compiler from the stable CI lane. Require one exact declaration +# in each file; a future reorganization must update this check explicitly. +# `tools/cargo.sh` and `zerocopy/win-cargo.bat` also parse the tools channel so +# they can defeat persisted rustup directory overrides. The Unix scripts share +# `tools/toolchain.sh`; keep its exact format coordinated with the independent +# batch parser. A pre-existing Windows worktree may still contain CRLF TOML, so +# the bootstrap parser accepts well-formed CRLF but rejects bare CR. +# +# Store the parser output before comparing the two pins. Do not use `mapfile`: +# macOS still ships Bash 3.2, which does not provide that builtin. +if ! TOOLS_CHANNEL="$( + zc_read_exact_rust_version_assignment rust-toolchain.toml toolchain channel +)"; then + echo "Expected one canonical [toolchain] channel" \ + "in tools/rust-toolchain.toml" >&2 + exit 1 +fi +if ! STABLE_CHANNEL="$( + zc_read_exact_rust_version_assignment \ + ../zerocopy/Cargo.toml package.metadata.ci pinned-stable +)"; then + echo "Expected one canonical [package.metadata.ci] pinned-stable" \ + "in zerocopy/Cargo.toml" >&2 + exit 1 +fi +if [[ -z "$TOOLS_CHANNEL" || "$TOOLS_CHANNEL" == *$'\n'* || \ + -z "$STABLE_CHANNEL" || "$STABLE_CHANNEL" == *$'\n'* ]]; then + echo "Expected one tools channel and one Zerocopy stable channel" >&2 + exit 1 +fi +if [[ "$TOOLS_CHANNEL" != "$STABLE_CHANNEL" ]]; then + echo "Tools compiler $TOOLS_CHANNEL does not match Zerocopy stable" \ + "compiler $STABLE_CHANNEL" >&2 + exit 1 +fi + +# Exercise the bootstrap parser independently of this checkout. Existing +# Git-for-Windows worktrees can retain CRLF after pulling `.gitattributes`, but +# every table and assignment choice is otherwise a strict coordination +# contract. Keep these hostile fixtures synchronized with `tools/toolchain.sh` +# and the independent live-file parser in `zerocopy/win-cargo.bat`. +# +# The parser opens its input twice so it can detect NUL before Bash `read` +# discards that byte. Materialize each fixture instead of piping through +# /dev/stdin; production callers likewise provide ordinary repository files. +TOOLS_CHANNEL_FIXTURE_DIR="$(mktemp -d)" +TOOLS_CHANNEL_FIXTURE="$TOOLS_CHANNEL_FIXTURE_DIR/rust-toolchain.toml" +cleanup_tools_channel_fixture() { + rm -f "$TOOLS_CHANNEL_FIXTURE" + rmdir "$TOOLS_CHANNEL_FIXTURE_DIR" +} +trap cleanup_tools_channel_fixture EXIT + +write_tools_channel_fixture() { + printf '%s' "$1" > "$TOOLS_CHANNEL_FIXTURE" +} + +write_tools_channel_fixture $'# channel = "ignored"\n[toolchain]\nchannel = "1.2.3"\n' +if ! PARSED_LF_CHANNEL="$( + zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" toolchain channel +)" || [[ "$PARSED_LF_CHANNEL" != "1.2.3" ]]; then + echo "Tools channel parser rejected a canonical LF declaration" >&2 + exit 1 +fi +write_tools_channel_fixture \ + $'[toolchain]\r\nchannel = "1.2.3"\r\nnote = "write \\"unsafe\\""\r\nprofile = "minimal"\r\n' +if ! PARSED_CRLF_CHANNEL="$( + zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" toolchain channel +)" || [[ "$PARSED_CRLF_CHANNEL" != "1.2.3" ]]; then + echo "Tools channel parser rejected a canonical CRLF declaration" >&2 + exit 1 +fi + +CANONICAL_TOOLCHAIN=$'[toolchain]\nchannel = "1.2.3"\n' + +# A quoted key containing a literal dot is one TOML component, not a dotted +# path. Keep this accepted fixture coordinated with the segment-aware alias +# check in `tools/toolchain.sh`; flattening quotes before comparison would +# incorrectly reject this unrelated table. +write_tools_channel_fixture \ + "${CANONICAL_TOOLCHAIN}"$'["toolchain.unrelated"]\nnote = "accepted"\n' +if ! PARSED_LITERAL_DOT_CHANNEL="$( + zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" toolchain channel +)" || [[ "$PARSED_LITERAL_DOT_CHANNEL" != "1.2.3" ]]; then + echo "Tools channel parser confused a literal dot with a dotted key" >&2 + exit 1 +fi + +expect_tools_channel_rejected() { + local description="$1" + local source="$2" + write_tools_channel_fixture "$source" + if zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" toolchain channel >/dev/null; then + echo "Tools channel parser accepted $description" >&2 + return 1 + fi +} + +expect_tools_channel_rejected "an assignment without its table" \ + $'channel = "1.2.3"\n' +expect_tools_channel_rejected "an assignment in the wrong table" \ + $'[other]\nchannel = "1.2.3"\n' +expect_tools_channel_rejected "a noncanonical table header" \ + $'[ toolchain ]\nchannel = "1.2.3"\n' +expect_tools_channel_rejected "a duplicate table" \ + "${CANONICAL_TOOLCHAIN}"$'[toolchain]\n' +expect_tools_channel_rejected "a duplicate assignment" \ + "${CANONICAL_TOOLCHAIN}"$'channel = "4.5.6"\n' +expect_tools_channel_rejected "a compact assignment" \ + "${CANONICAL_TOOLCHAIN}"$'channel="4.5.6"\n' +expect_tools_channel_rejected "a leading-space assignment" \ + "${CANONICAL_TOOLCHAIN}"$' channel = "4.5.6"\n' +expect_tools_channel_rejected "a trailing-space assignment" \ + "${CANONICAL_TOOLCHAIN}"$'channel = "4.5.6" \n' +expect_tools_channel_rejected "an assignment with an inline comment" \ + "${CANONICAL_TOOLCHAIN}"$'channel = "4.5.6" # duplicate authority\n' +expect_tools_channel_rejected "a nonnumeric channel" \ + "${CANONICAL_TOOLCHAIN}"$'channel = "nightly"\n' +expect_tools_channel_rejected "a quoted channel key" \ + "${CANONICAL_TOOLCHAIN}"$'"channel" = "4.5.6"\n' +expect_tools_channel_rejected "a channel key with a short Unicode escape" \ + "${CANONICAL_TOOLCHAIN}"$'"\\u0063hannel" = "4.5.6"\n' +expect_tools_channel_rejected "a channel key with a long Unicode escape" \ + "${CANONICAL_TOOLCHAIN}"$'"\\U00000063hannel" = "4.5.6"\n' +expect_tools_channel_rejected "an escaped duplicate table" \ + "${CANONICAL_TOOLCHAIN}"$'["tool\\u0063hain"]\n' +expect_tools_channel_rejected "a dotted channel assignment" \ + "${CANONICAL_TOOLCHAIN}"$'[other]\ntoolchain.channel = "4.5.6"\n' +expect_tools_channel_rejected "an inline-table channel assignment" \ + "${CANONICAL_TOOLCHAIN}"$'other = {channel = "4.5.6"}\n' +expect_tools_channel_rejected "a duplicate noncanonical table" \ + "${CANONICAL_TOOLCHAIN}"$'[ toolchain ]\n' +expect_tools_channel_rejected "a duplicate double-quoted table" \ + "${CANONICAL_TOOLCHAIN}"$'["toolchain"]\n' +expect_tools_channel_rejected "a duplicate single-quoted table" \ + "${CANONICAL_TOOLCHAIN}"$"['toolchain']\n" +expect_tools_channel_rejected "a conflicting quoted array table" \ + "${CANONICAL_TOOLCHAIN}"$'[["toolchain"]]\n' +expect_tools_channel_rejected "an assignment after leaving its table" \ + "${CANONICAL_TOOLCHAIN}"$'[other]\nchannel = "4.5.6"\n' +expect_tools_channel_rejected "an assignment inside a multiline string" \ + $'[toolchain]\nnote = """\nchannel = "9.9.9"\n"""\n' +expect_tools_channel_rejected "a table inside a multiline literal string" \ + $'[other]\nnote = \047\047\047\n[toolchain]\nchannel = "9.9.9"\n\047\047\047\n' +expect_tools_channel_rejected "an unterminated bare CR" \ + "${CANONICAL_TOOLCHAIN}"$'profile = "minimal"\r' +expect_tools_channel_rejected "an embedded bare CR" \ + "${CANONICAL_TOOLCHAIN}"$'profile = "mini\rmal"\n' +expect_tools_channel_rejected "an unterminated declaration" \ + "${CANONICAL_TOOLCHAIN}"$'profile = "minimal"' + +# Exercise a dotted expected path independently. TOML permits a different key +# spelling for every component, so a mixed quoted/bare duplicate must not +# evade the same one-table contract. Conversely, quotes can make a dot literal +# inside one component; those unrelated paths must remain accepted. +CANONICAL_CI_METADATA=$'[package.metadata.ci]\npinned-stable = "1.2.3"\n' +write_tools_channel_fixture \ + "${CANONICAL_CI_METADATA}"$'[ "package" . metadata . \047ci\047 ] # duplicate\n' +if zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" package.metadata.ci pinned-stable \ + >/dev/null; then + echo "Tools channel parser accepted a mixed-quoted duplicate table" >&2 + exit 1 +fi +write_tools_channel_fixture \ + "${CANONICAL_CI_METADATA}"$'["package.metadata.ci"]\n[package."metadata.ci"]\n[target.\047cfg(any())\047.dependencies]\n' +if ! PARSED_LITERAL_DOT_METADATA="$( + zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" package.metadata.ci pinned-stable +)" || [[ "$PARSED_LITERAL_DOT_METADATA" != "1.2.3" ]]; then + echo "Tools channel parser confused quoted dotted components with a path" >&2 + exit 1 +fi + +printf '[toolchain]\nchannel = "1.2' > "$TOOLS_CHANNEL_FIXTURE" +printf '\0' >> "$TOOLS_CHANNEL_FIXTURE" +printf '.3"\n' >> "$TOOLS_CHANNEL_FIXTURE" +if zc_read_exact_rust_version_assignment \ + "$TOOLS_CHANNEL_FIXTURE" toolchain channel >/dev/null; then + echo "Tools channel parser accepted a NUL byte" >&2 + exit 1 +fi + +if zc_read_exact_rust_version_assignment \ + /dev/null/missing toolchain channel \ + >/dev/null 2>&1; then + echo "Tools channel parser accepted an input it could not open" >&2 + exit 1 +fi + +# RUSTUP_TOOLCHAIN overrides tools/rust-toolchain.toml. Use an intentionally +# invalid value to prove that the Unix wrapper's explicit pin defeats this +# ambient override while it builds cargo-zerocopy, then reports the configured +# stable version. +WRAPPER_STABLE="$( + RUSTUP_TOOLCHAIN=zerocopy-ci-intentionally-invalid \ + ../zerocopy/cargo.sh --version stable +)" +if [[ "$WRAPPER_STABLE" != "$STABLE_CHANNEL" ]]; then + echo "cargo.sh resolved stable to $WRAPPER_STABLE, expected $STABLE_CHANNEL" \ + >&2 + exit 1 +fi + +# Prove that the shared Unix wrapper defeats an ambient rustup override. Keep +# the lockfile read-only: repository tools are part of CI's trusted setup, so +# an unreviewed dependency change must never be created as a side effect of +# testing them. +RUSTUP_TOOLCHAIN=zerocopy-ci-intentionally-invalid \ + ./cargo.sh test --locked --workspace diff --git a/githooks/pre-push b/githooks/pre-push index 886dad249b..6c4445bae3 100755 --- a/githooks/pre-push +++ b/githooks/pre-push @@ -50,15 +50,18 @@ for lockfile in "${LOCKFILES[@]}"; do LOCKFILE_HASHES_BEFORE+=("$(lockfile_hash "$lockfile")") done -# check_fmt.sh uses cargo-zerocopy's pinned nightly toolchain. On a fresh -# runner, it previously reached rustup concurrently with other checks. Rustup -# shares download and rollback paths across toolchains, so one installation -# could remove another installation's partial file. Bootstrap nightly before -# retaining parallelism between the checks themselves. +# check_fmt.sh uses cargo-zerocopy's pinned nightly toolchain. check_tools.sh +# uses the exact stable compiler pinned in tools/rust-toolchain.toml. On a fresh +# runner, these checks previously reached rustup concurrently. Rustup shares +# download and rollback paths across toolchains, so one installation could +# remove another installation's partial file. Bootstrap both toolchains in +# sequence before retaining parallelism between the checks themselves. # # Keep this list coordinated with the backgrounded scripts below. If another # check starts using a different cargo-zerocopy descriptor, initialize it here -# before that check may run in parallel. +# before that check may run in parallel. The stable bootstrap also installs the +# exact compiler pinned by tools/rust-toolchain.toml: zerocopy/cargo.sh builds +# cargo-zerocopy with that pin, and CI requires it to equal `+stable`. CHECKS_FAILED=0 TOOLCHAINS_READY=1 bootstrap_toolchain() { @@ -80,6 +83,7 @@ bootstrap_toolchain() { TOOLCHAINS_READY=0 } +bootstrap_toolchain stable bootstrap_toolchain nightly # Forego redirecting stdout to /dev/null on check_fmt.sh because the output from @@ -106,6 +110,7 @@ if [[ "$TOOLCHAINS_READY" -eq 1 ]]; then ./ci/check_actions.sh & ACTIONS_PID=$! ./ci/check_fmt.sh & FMT_PID=$! ./ci/check_job_dependencies.sh >/dev/null & JOB_DEPS_PID=$! + ./ci/check_tools.sh & TOOLS_PID=$! ./zerocopy/ci/check_all_toolchains_tested.sh >/dev/null & TOOLCHAINS_PID=$! ./zerocopy/ci/check_readme.sh >/dev/null & README_PID=$! ./zerocopy/ci/check_stale_stderr.sh >/dev/null & STALE_STDERR_PID=$! @@ -119,6 +124,7 @@ if [[ "$TOOLCHAINS_READY" -eq 1 ]]; then wait_for_check "ci/check_actions.sh" "$ACTIONS_PID" wait_for_check "ci/check_fmt.sh" "$FMT_PID" wait_for_check "ci/check_job_dependencies.sh" "$JOB_DEPS_PID" + wait_for_check "ci/check_tools.sh" "$TOOLS_PID" wait_for_check \ "zerocopy/ci/check_all_toolchains_tested.sh" "$TOOLCHAINS_PID" wait_for_check "zerocopy/ci/check_readme.sh" "$README_PID" diff --git a/githooks/test_pre_push.py b/githooks/test_pre_push.py index f26fc44512..994e2e64fa 100755 --- a/githooks/test_pre_push.py +++ b/githooks/test_pre_push.py @@ -31,6 +31,9 @@ "ci/check_actions.sh", "ci/check_fmt.sh", "ci/check_job_dependencies.sh", + # Keep this fixture coordinated with the explicit fan-out in pre-push. + # Omitting a real check here would leave its child handling untested. + "ci/check_tools.sh", "zerocopy/ci/check_all_toolchains_tested.sh", "zerocopy/ci/check_readme.sh", "zerocopy/ci/check_stale_stderr.sh", @@ -213,7 +216,7 @@ def test_bootstraps_serially_and_runs_every_check(self): self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual( self.cargo_invocations(), - ["+nightly --version"], + ["+stable --version", "+nightly --version"], ) for check in _CHECKS: self.assertTrue(self.marker_for(check).is_file(), check) @@ -246,19 +249,19 @@ def test_can_run_from_a_repository_subdirectory(self): def test_bootstrap_failure_still_checks_lockfiles(self): lockfile = _LOCKFILES[-1] result = self.repository.run_hook( - FAIL_CARGO_INVOCATION="+nightly --version", - MUTATING_CARGO_INVOCATION="+nightly --version", + FAIL_CARGO_INVOCATION="+stable --version", + MUTATING_CARGO_INVOCATION="+stable --version", MUTATE_LOCKFILE=str(self.repository.path / lockfile), ) self.assertNotEqual(result.returncode, 0) self.assertIn( - "zerocopy/cargo.sh +nightly --version failed with status 23", + "zerocopy/cargo.sh +stable --version failed with status 23", result.stderr, ) self.assertIn(f"{lockfile} was modified", result.stderr) self.assertEqual( self.cargo_invocations(), - ["+nightly --version"], + ["+stable --version", "+nightly --version"], ) for check in _CHECKS: self.assertFalse(self.marker_for(check).exists(), check) diff --git a/tools/cargo.sh b/tools/cargo.sh new file mode 100755 index 0000000000..bd5ee32d12 --- /dev/null +++ b/tools/cargo.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +set -eo pipefail + +TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" + +# shellcheck source=toolchain.sh +source "$TOOLS_DIR/toolchain.sh" + +# Do not rely on rustup's directory lookup here. RUSTUP_TOOLCHAIN and a +# persisted `rustup override set` both take precedence over a toolchain file, +# while an explicit `+` takes precedence over those overrides. Keep +# this deliberately narrow parser coordinated with rust-toolchain.toml, +# toolchain.sh, and ../ci/check_tools.sh. A changed or duplicate declaration +# must fail closed instead of silently selecting another compiler. The shared +# parser accepts a pre-existing Windows worktree whose TOML still uses CRLF. +if ! TOOLS_TOOLCHAIN="$( + zc_read_exact_rust_version_assignment \ + "$TOOLS_DIR/rust-toolchain.toml" toolchain channel +)"; then + echo "Expected one canonical [toolchain] channel" \ + "in tools/rust-toolchain.toml" >&2 + exit 1 +fi +if [[ -z "$TOOLS_TOOLCHAIN" || "$TOOLS_TOOLCHAIN" == *$'\n'* ]]; then + echo "Expected one exact channel in tools/rust-toolchain.toml" >&2 + exit 1 +fi + +# Establish the tools workspace as Cargo's discovery directory. This keeps +# every caller on the checked-in tools lockfile and Cargo configuration unless +# the caller explicitly supplies different command-line options. +# +# Cargo subcommands can launch Cargo again. Export the same validated pin so a +# child process cannot fall back to the caller's RUSTUP_TOOLCHAIN or a persisted +# directory override after the explicit outer `+` has done its job. +export RUSTUP_TOOLCHAIN="$TOOLS_TOOLCHAIN" +cd "$TOOLS_DIR" +exec cargo "+$TOOLS_TOOLCHAIN" "$@" diff --git a/tools/generate-readme/src/main.rs b/tools/generate-readme/src/main.rs index 3d4efe0c8e..9c8715f5e8 100644 --- a/tools/generate-readme/src/main.rs +++ b/tools/generate-readme/src/main.rs @@ -6,7 +6,11 @@ // This file may not be copied, modified, or distributed except according to // those terms. -use std::{env, path::PathBuf, process::Command}; +use std::{ + env, io, + path::PathBuf, + process::{Command, Output}, +}; use regex::{Captures, Regex}; @@ -30,6 +34,19 @@ const DISCLAIMER_FOOTER: &str = "\ Disclaimer: Zerocopy is not an officially supported Google product.\ "; +fn checked_output(command: &mut Command) -> io::Result { + let output = command.output()?; + if output.status.success() { + Ok(output) + } else { + Err(io::Error::other(format!( + "child command failed with {}:\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ))) + } +} + fn main() { let cwd = env::current_dir().unwrap(); let readme_dir = env::var_os("ZEROCOPY_README_DIR").map(PathBuf::from).unwrap_or_else(|| { @@ -42,11 +59,14 @@ fn main() { // This uses the `cargo readme` tool, which you can install via `cargo install // cargo-readme --version 3.2.0`. - let output = Command::new("cargo") - .current_dir(readme_dir) - .args(["readme", "--no-license"]) - .output() - .unwrap(); + // The tools wrapper exports its checked compiler pin for this nested Cargo + // invocation. Propagate failure before printing any generated framing: a + // caller may redirect stdout directly onto README.md, so partial output + // would turn a tool failure into a destructive-looking regeneration. + let output = checked_output( + Command::new("cargo").current_dir(readme_dir).args(["readme", "--no-license"]), + ) + .unwrap(); let readme = String::from_utf8(output.stdout).unwrap(); @@ -62,3 +82,33 @@ fn main() { println!("{}\n\n{}\n{}", COPYRIGHT_HEADER, body, DISCLAIMER_FOOTER); } + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::checked_output; + + #[cfg(any(unix, windows))] + #[test] + fn rejects_child_failure_without_returning_partial_stdout() { + #[cfg(unix)] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "printf partial-output; printf child-diagnostic >&2; exit 7"]); + command + }; + #[cfg(windows)] + let mut command = { + let mut command = Command::new("cmd"); + command + .args(["/C", "&2 echo child-diagnostic & exit /b 7"]); + command + }; + + let error = checked_output(&mut command).unwrap_err().to_string(); + assert!(error.contains("child command failed with")); + assert!(error.contains("child-diagnostic")); + assert!(!error.contains("partial-output")); + } +} diff --git a/tools/rust-toolchain.toml b/tools/rust-toolchain.toml new file mode 100644 index 0000000000..74f3f02dd8 --- /dev/null +++ b/tools/rust-toolchain.toml @@ -0,0 +1,23 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# This pin selects the compiler used to build repository tools. It does not set +# Zerocopy's minimum supported Rust version. The stable-toolchain roller updates +# this value together with `package.metadata.ci.pinned-stable` in +# `zerocopy/Cargo.toml` so the two build paths do not drift accidentally. +# +# The shared Unix tools wrapper (`cargo.sh`) and the Windows cargo-zerocopy +# wrapper (`../zerocopy/win-cargo.bat`) parse the channel line so they can pass +# an explicit `+` to cargo. `../ci/check_tools.sh` validates the same +# line. Keep its key, spacing, quotes, and numeric version format coordinated +# with those parsers; changing the format should make them fail. The batch +# bootstrap additionally recognizes these three semantic lines as one ordered +# unit, so adding a field or changing `profile` must update win-cargo.bat too. +[toolchain] +channel = "1.93.1" +profile = "minimal" diff --git a/tools/toolchain.sh b/tools/toolchain.sh new file mode 100644 index 0000000000..fd8d593b3b --- /dev/null +++ b/tools/toolchain.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# Shared bootstrap parser for one exact Rust-version assignment in one exact +# TOML table. This code runs before the typed Rust audit can normalize +# repository text, so it accepts LF and well-formed CRLF directly. A carriage +# return in any other position, including an unterminated final line, fails +# closed. +# +# Keep the two callers (`cargo.sh` and `../ci/check_tools.sh`) coordinated with +# the independent parser in `../zerocopy/win-cargo.bat`. The table and key +# arguments are fixed repository constants, not user input. This is +# intentionally a narrow recognizer rather than a partial TOML parser: any +# alternate spelling of the target assignment must fail and force the +# bootstrap contract to be updated deliberately. +zc_read_exact_rust_version_assignment() { + if [[ $# -ne 3 ]]; then + return 2 + fi + + local path="$1" + local expected_table="$2" + local key="$3" + local line="" + local normalized + local expected_header="[$expected_table]" + local expected_table_path_ere="" + local expected_table_header_ere + local expected_array_table_header_ere + local separator="" + local part + local -a expected_table_parts + local expected_table_count=0 + local in_expected_table=0 + local assignment_count=0 + local prefix="$key = \"" + local suffix='"' + local assignment_ere="(^|[.{,[:space:]])[\"']?$key[\"']?[[:space:]]*=" + local unicode_escape_ere='\\(u[[:xdigit:]]{4}|U[[:xdigit:]]{8})' + local version + local selected_version="" + local rust_version_ere='^[0-9]+\.[0-9]+\.[0-9]+$' + + # These values are fixed repository constants, but they are interpolated + # into regular expressions below. Reject an accidental future caller which + # would give punctuation regex meaning instead of silently widening the + # bootstrap grammar. + [[ "$expected_table" =~ ^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$ ]] || return 2 + [[ "$key" =~ ^[A-Za-z0-9_-]+$ ]] || return 2 + + # TOML permits each component of a dotted key to be bare, basic quoted, or + # literal quoted. Build the exact semantic spelling of the expected path so + # quoted aliases are rejected without confusing a literal dot inside one + # quoted component with a separator. For example, `["package".metadata.ci]` + # aliases `[package.metadata.ci]`, while `["package.metadata.ci"]` names one + # unrelated component and remains valid. Unicode escapes are handled by the + # global fail-closed check below. + IFS='.' read -r -a expected_table_parts <<< "$expected_table" + for part in "${expected_table_parts[@]}"; do + expected_table_path_ere+="$separator($part|\"$part\"|'$part')" + separator='[[:space:]]*\.[[:space:]]*' + done + expected_table_header_ere="^[[:space:]]*\\[[[:space:]]*${expected_table_path_ere}[[:space:]]*\\][[:space:]]*(#.*)?$" + expected_array_table_header_ere="^[[:space:]]*\\[[[:space:]]*\\[[[:space:]]*${expected_table_path_ere}[[:space:]]*\\][[:space:]]*\\][[:space:]]*(#.*)?$" + + # Reject an unavailable input before entering the loop so callers receive a + # useful status rather than an empty, apparently successful result. The + # conditional around the loop also preserves a redirection failure if the + # file becomes unavailable between this check and opening it. + [[ -r "$path" ]] || return 1 + + # Bash `read` silently removes NUL bytes. Detect one before the line parser + # can turn a noncanonical file into canonical-looking text. All callers pass + # reopenable repository or fixture paths: this preflight and the loop below + # deliberately open the file independently. + if IFS= read -r -d '' normalized < "$path"; then + return 1 + fi + if ! while true; do + line="" + if IFS= read -r line; then + : + elif [[ -n "$line" ]]; then + # `read` saw bytes without a terminating LF. In particular, do not + # mistake a final bare CR for the end of a CRLF record. + return 1 + else + break + fi + + case "$line" in + *$'\r') + normalized="${line%$'\r'}" + # Removing the one CR which belongs to CRLF must leave no other CR. + [[ "$normalized" != *$'\r'* ]] || return 1 + ;; + *$'\r'*) + return 1 + ;; + *) + normalized="$line" + ;; + esac + + if [[ -z "$normalized" || "$normalized" =~ ^[[:space:]]*# ]]; then + continue + fi + + # A line-oriented recognizer cannot distinguish apparent tables or + # assignments inside TOML multiline strings. Neither coordinated input + # needs multiline strings today, so reject both delimiter forms. If one is + # introduced later, replace this recognizer with a parser which tracks TOML + # string state rather than letting embedded text gain bootstrap authority. + if [[ "$normalized" == *'"""'* || "$normalized" == *"'''"* ]]; then + return 1 + fi + + # TOML basic quoted keys and table names decode Unicode escapes before + # comparing identifiers. For example, `"\u0063hannel"` is another + # spelling of `channel`, but the deliberately textual checks below cannot + # recognize that semantic duplicate. Decoding TOML escapes here would turn + # this bootstrap recognizer into a partial parser, so fail closed on either + # Unicode-escape form anywhere in a semantic line. Ordinary escapes such as + # `\"` remain accepted in unrelated values. If a coordinated input ever + # needs a Unicode escape, replace this recognizer with a parser and update + # the hostile fixtures in `../ci/check_tools.sh` at the same time. + if [[ "$normalized" =~ $unicode_escape_ere ]]; then + return 1 + fi + + # A table header with any alternate spacing is deliberately not the + # expected canonical header. It still leaves the expected table so an + # exact-looking assignment beneath another table cannot be accepted. + # + # Quoted TOML keys are another spelling of a bare key. For example, + # `["toolchain"]` and `['toolchain']` both reopen `[toolchain]`. Array + # tables receive the same treatment because defining `[[toolchain]]` + # alongside `[toolchain]` is also a semantic collision. Keep these checks + # coordinated with the hostile fixtures in `../ci/check_tools.sh`. + if [[ "$normalized" =~ ^[[:space:]]*\[ ]]; then + if [[ "$normalized" == "$expected_header" ]]; then + expected_table_count=$((expected_table_count + 1)) + [[ $expected_table_count -eq 1 ]] || return 1 + in_expected_table=1 + else + if [[ "$normalized" =~ $expected_table_header_ere || \ + "$normalized" =~ $expected_array_table_header_ere ]]; then + return 1 + fi + in_expected_table=0 + fi + continue + fi + + if [[ "$normalized" == "$prefix"*"$suffix" ]]; then + version="${normalized#"$prefix"}" + version="${version%"$suffix"}" + [[ $in_expected_table -eq 1 ]] || return 1 + [[ "$version" =~ $rust_version_ere ]] || return 1 + assignment_count=$((assignment_count + 1)) + [[ $assignment_count -eq 1 ]] || return 1 + selected_version="$version" + continue + fi + + # Catch compact, spaced, quoted, dotted, and inline-table spellings of the + # same key. + # False positives are intentionally fail-closed: if a future TOML value + # needs to contain assignment-like text, update this bootstrap grammar and + # its hostile fixtures together. + if [[ "$normalized" =~ $assignment_ere ]]; then + return 1 + fi + done < "$path"; then + return 1 + fi + + [[ $expected_table_count -eq 1 && $assignment_count -eq 1 ]] || return 1 + printf '%s' "$selected_version" +} diff --git a/zerocopy/AGENTS.md b/zerocopy/AGENTS.md index dcc4b7d4ad..f8a5d09009 100644 --- a/zerocopy/AGENTS.md +++ b/zerocopy/AGENTS.md @@ -27,7 +27,8 @@ you **MUST** also read [agent_docs/reviewing.md](./agent_docs/reviewing.md). - **README Generation:** **DON'T** edit `README.md` directly. It is generated from `src/lib.rs`. Edit the top-level doc comment in `src/lib.rs` instead. - **To regenerate:** - `(cd .. && cargo -q run --manifest-path tools/Cargo.toml -p generate-readme) > README.md` + `ZEROCOPY_README_DIR=../zerocopy ../tools/cargo.sh -q run \ + --locked -p generate-readme > README.md` - **TODOs:** **DON'T** use `TODO` comments unless you explicitly intend to block diff --git a/zerocopy/cargo.sh b/zerocopy/cargo.sh index 82bacd0b38..62a347340e 100755 --- a/zerocopy/cargo.sh +++ b/zerocopy/cargo.sh @@ -13,13 +13,15 @@ set -eo pipefail ZEROCOPY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" REPO_DIR="$(dirname "$ZEROCOPY_DIR")" -# Build `cargo-zerocopy` without any RUSTFLAGS or CARGO_TARGET_DIR set in the -# environment. Build it from the repository root so that Zerocopy's vendoring -# config does not apply to the unvendored tools workspace. -( - cd "$REPO_DIR" - env -u RUSTFLAGS -u CARGO_TARGET_DIR cargo +stable build --manifest-path tools/cargo-zerocopy/Cargo.toml -p cargo-zerocopy -q -) +# Build `cargo-zerocopy` without any RUSTFLAGS, CARGO_TARGET_DIR, or +# RUSTUP_TOOLCHAIN set in the environment. `tools/cargo.sh` supplies the exact +# compiler pin; clearing the variables also keeps their other effects out of +# the build. That wrapper establishes `tools` as Cargo's discovery directory, +# outside Zerocopy's vendored configuration. `--locked` prevents this bootstrap +# step from silently changing the tools dependency graph. +env -u RUSTFLAGS -u CARGO_TARGET_DIR -u RUSTUP_TOOLCHAIN \ + "$REPO_DIR/tools/cargo.sh" build --locked --manifest-path Cargo.toml \ + -p cargo-zerocopy -q cd "$ZEROCOPY_DIR" exec "$REPO_DIR/tools/target/debug/cargo-zerocopy" "$@" diff --git a/zerocopy/ci/check_readme.sh b/zerocopy/ci/check_readme.sh index 40989c49b4..aec6a5471b 100755 --- a/zerocopy/ci/check_readme.sh +++ b/zerocopy/ci/check_readme.sh @@ -11,13 +11,25 @@ set -eo pipefail cd "$(dirname "$0")/.." +# Exercise the tools wrapper with an override which rustup cannot resolve. The +# wrapper must replace it with the checked tools pin for both its outer Cargo +# process and the generator's nested `cargo readme`; otherwise this check fails +# before comparing generated output. +export RUSTUP_TOOLCHAIN=zerocopy-ci-intentionally-invalid + # Install again in case the installation failed during the # `generate_cache` step. We treat that step as best-effort and # suppress all errors from it. -( - cd .. - cargo install -q cargo-readme --version 3.2.0 -) +../tools/cargo.sh install -q cargo-readme --version 3.2.0 --locked -diff <(cd .. && cargo -q run --manifest-path tools/Cargo.toml -p generate-readme) README.md >&2 +# `tools/cargo.sh` pins the compiler explicitly, defeating both +# RUSTUP_TOOLCHAIN and rustup directory overrides, and establishes the tools +# workspace as Cargo's discovery directory. Keep this invocation identical to +# the regeneration commands in `../src/lib.rs` and `../AGENTS.md`. The +# generator needs its source directory explicitly because its repository-layout +# auto-detection intentionally starts from the tools workspace. +diff <( + ZEROCOPY_README_DIR=../zerocopy \ + ../tools/cargo.sh -q run --locked -p generate-readme +) README.md >&2 exit $? diff --git a/zerocopy/src/lib.rs b/zerocopy/src/lib.rs index b5ecbb58a8..4061f5926c 100644 --- a/zerocopy/src/lib.rs +++ b/zerocopy/src/lib.rs @@ -12,7 +12,8 @@ // After updating the following doc comment, make sure to run the following // command to update `README.md` based on its contents: // -// (cd .. && cargo -q run --manifest-path tools/Cargo.toml -p generate-readme) > README.md +// ZEROCOPY_README_DIR=../zerocopy ../tools/cargo.sh -q run --locked \ +// -p generate-readme > README.md //! ***Fast, safe, compile error. Pick two.*** diff --git a/zerocopy/win-cargo.bat b/zerocopy/win-cargo.bat index dfacf455ac..5228d7b25e 100644 --- a/zerocopy/win-cargo.bat +++ b/zerocopy/win-cargo.bat @@ -6,22 +6,122 @@ @rem This file may not be copied, modified, or distributed except according to @rem those terms. -@rem Build `cargo-zerocopy` without any RUSTFLAGS set in the environment. -@rem Build from the repository root so that Zerocopy's vendoring config does -@rem not apply to the unvendored tools workspace. -@set SCRIPT_DIR=%~dp0 -@set TEMP_RUSTFLAGS=%RUSTFLAGS% -@set RUSTFLAGS= -@pushd "%SCRIPT_DIR%.." -@cargo +stable build --manifest-path tools\Cargo.toml -p cargo-zerocopy -q -@set CARGO_ZEROCOPY_BUILD_STATUS=%ERRORLEVEL% +@echo off +@rem Parse the tools compiler and pass it to cargo explicitly. An explicit +@rem `+` takes precedence over both RUSTUP_TOOLCHAIN and a persisted +@rem `rustup override set` in this checkout. This bootstrap parser requires the +@rem complete non-comment file shape: [toolchain], one canonical numeric +@rem channel, then profile = "minimal". A future semantic field must update this +@rem parser deliberately. Keep it coordinated with tools\rust-toolchain.toml, +@rem tools\toolchain.sh, tools\cargo.sh, and ci\check_tools.sh. +@rem Resolve the checkout path before enabling delayed expansion. A checkout +@rem path may legally contain `!`, which delayed expansion would remove while +@rem expanding `%~dp0`. The parser enables it only while already inside tools. +@setlocal EnableExtensions DisableDelayedExpansion +@rem A caller can define an ordinary ERRORLEVEL variable which shadows cmd's +@rem dynamic status value during `%ERRORLEVEL%` and `!ERRORLEVEL!` expansion. +@rem Clear only this local copy so status capture remains trustworthy; the +@rem final ENDLOCAL restores any value the caller supplied. +@set "ERRORLEVEL=" +@pushd "%~dp0..\tools" +@if errorlevel 1 exit /b 1 +@set "TOOLS_TOOLCHAIN_FILE_VALID=1" +@rem FOR /F collapses whitespace before the token checks below. Reject every +@rem noncanonical space and tab in a semantic line first. The last /C pattern +@rem contains one literal tab. +@findstr /V /B /C:"#" "rust-toolchain.toml" | findstr /R /C:"^ " /C:" $" /C:" " /C:" " >nul +@if not errorlevel 1 @set "TOOLS_TOOLCHAIN_FILE_VALID=0" +@rem Delayed expansion makes a single FOR block possible without CALL. Reject +@rem command-interpreter metacharacters before enabling it so a malformed +@rem semantic line can only make validation fail, never change the commands +@rem which validate it. The doubled percent denotes one literal character +@rem after cmd.exe parses this line. `|` is also the FOR /F end-of-line marker +@rem below, so rejecting it prevents a semantic line from being skipped. +@findstr /V /B /C:"#" "rust-toolchain.toml" | ^ + findstr /L /C:"!" /C:"&" /C:"|" /C:"^" /C:"%%" /C:"<" /C:">" ^ + /C:"(" /C:")" >nul +@if not errorlevel 1 @set "TOOLS_TOOLCHAIN_FILE_VALID=0" +@if not "%TOOLS_TOOLCHAIN_FILE_VALID%"=="1" ( + @echo Expected one canonical [toolchain] channel ^ + in tools\rust-toolchain.toml >&2 + @popd + @exit /b 1 +) + +@setlocal EnableDelayedExpansion +@set "TOOLS_TOOLCHAIN=" +@set "TOOLS_TOOLCHAIN_LINE_COUNT=0" +@set "TOOLS_TOOLCHAIN_CHANNEL_RAW=" +@set "TOOLS_TOOLCHAIN_PROFILE_RAW=" +@for /f "eol=| tokens=1,2,3,4" %%A in ('findstr /V /B /C:"#" "rust-toolchain.toml"') do ( + @set /a TOOLS_TOOLCHAIN_LINE_COUNT+=1 >nul + @if "!TOOLS_TOOLCHAIN_LINE_COUNT!"=="1" ( + @if not "%%A"=="[toolchain]" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @if not "%%B"=="" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + ) else if "!TOOLS_TOOLCHAIN_LINE_COUNT!"=="2" ( + @if not "%%A"=="channel" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @if not "%%B"=="=" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @if not "%%D"=="" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @set "TOOLS_TOOLCHAIN=%%~C" + @set "TOOLS_TOOLCHAIN_CHANNEL_RAW=%%C" + ) else if "!TOOLS_TOOLCHAIN_LINE_COUNT!"=="3" ( + @if not "%%A"=="profile" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @if not "%%B"=="=" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @if not "%%~C"=="minimal" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @if not "%%D"=="" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + @set "TOOLS_TOOLCHAIN_PROFILE_RAW=%%C" + ) else ( + @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + ) +) +@if not "!TOOLS_TOOLCHAIN_LINE_COUNT!"=="3" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + +@rem `%%~C` deliberately removes quotes when recording the channel. Compare +@rem the raw token separately so alternate or missing quotes cannot pass. A +@rem sentinel before each operand also makes an empty token safe. +@set TOOLS_TOOLCHAIN_EXPECTED_CHANNEL_RAW="!TOOLS_TOOLCHAIN!" +@if not x!TOOLS_TOOLCHAIN_CHANNEL_RAW!==x!TOOLS_TOOLCHAIN_EXPECTED_CHANNEL_RAW! ^ + @set "TOOLS_TOOLCHAIN_FILE_VALID=0" +@if not x!TOOLS_TOOLCHAIN_PROFILE_RAW!==x"minimal" ^ + @set "TOOLS_TOOLCHAIN_FILE_VALID=0" + +@rem Require exactly three nonempty decimal components. FOR /F collapses +@rem repeated delimiters, so reconstructing the version also rejects leading, +@rem trailing, and repeated dots. Initialize the result to failure because an +@rem empty value causes FOR /F to execute no iterations. +@set "TOOLS_TOOLCHAIN_VERSION_VALID=0" +@for /f "eol=| tokens=1-4 delims=." %%V in ("!TOOLS_TOOLCHAIN!") do ( + @if not "%%V"=="" if not "%%W"=="" if not "%%X"=="" ^ + if "%%Y"=="" if "!TOOLS_TOOLCHAIN!"=="%%V.%%W.%%X" ( + @set "TOOLS_TOOLCHAIN_VERSION_VALID=1" + @for /f "eol=| delims=0123456789" %%N in ("%%V%%W%%X") do ^ + @set "TOOLS_TOOLCHAIN_VERSION_VALID=0" + ) +) +@if not "!TOOLS_TOOLCHAIN_VERSION_VALID!"=="1" @set "TOOLS_TOOLCHAIN_FILE_VALID=0" +@if not "!TOOLS_TOOLCHAIN_FILE_VALID!"=="1" ( + @echo Expected one canonical [toolchain] channel ^ + in tools\rust-toolchain.toml >&2 + @popd + @exit /b 1 +) + +@rem Build `cargo-zerocopy` without output overrides from the environment. The +@rem explicit toolchain pins the outer Cargo invocation; export that same pin +@rem so nested Cargo or build-script children cannot recover the caller's +@rem override. Endlocal restores all three variables before cargo-zerocopy. +@set "RUSTFLAGS=" +@set "CARGO_TARGET_DIR=" +@set "RUSTUP_TOOLCHAIN=!TOOLS_TOOLCHAIN!" +@cargo +!TOOLS_TOOLCHAIN! build --locked --manifest-path Cargo.toml -p cargo-zerocopy -q +@set "CARGO_ZEROCOPY_BUILD_STATUS=!ERRORLEVEL!" @popd -@set RUSTFLAGS=%TEMP_RUSTFLAGS% -@set TEMP_RUSTFLAGS= +@endlocal & @set "CARGO_ZEROCOPY_BUILD_STATUS=%CARGO_ZEROCOPY_BUILD_STATUS%" @if not "%CARGO_ZEROCOPY_BUILD_STATUS%"=="0" exit /b %CARGO_ZEROCOPY_BUILD_STATUS% @rem Thin wrapper around the `cargo-zerocopy` binary in `tools/cargo-zerocopy` -@pushd "%SCRIPT_DIR%" +@pushd "%~dp0" +@if errorlevel 1 exit /b 1 @..\tools\target\debug\cargo-zerocopy %* -@set CARGO_ZEROCOPY_STATUS=%ERRORLEVEL% +@set "CARGO_ZEROCOPY_STATUS=%ERRORLEVEL%" @popd -@exit /b %CARGO_ZEROCOPY_STATUS% +@endlocal & @exit /b %CARGO_ZEROCOPY_STATUS%