From 0739a521f97a980300462858e5a100c64860b5ed Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 13 Aug 2026 14:59:11 +0900 Subject: [PATCH 01/10] fix(install): require npm provenance for platform packages --- .github/workflows/test-standalone-install.yml | 371 ++++++++++++++++++ Cargo.lock | 1 + crates/vp_setup/Cargo.toml | 1 + crates/vp_setup/src/error.rs | 5 + crates/vp_setup/src/registry.rs | 223 +++++++++++ packages/cli/install.ps1 | 73 +++- packages/cli/install.sh | 247 +++++++++++- .../tests/fixtures/provenance-registry.mjs | 129 ++++++ 8 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 packages/cli/tests/fixtures/provenance-registry.mjs diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index bed6b99911..55ac4600e5 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,6 +8,7 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/tests/fixtures/provenance-registry.mjs' - 'packages/cli/install-legacy.sh' - 'packages/cli/install-legacy.ps1' - '.github/scripts/test-install-legacy-remote.ps1' @@ -131,6 +132,191 @@ jobs: vp upgrade --rollback vp --version + test-install-sh-provenance: + name: Test install.sh (npm provenance) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Verify platform package provenance before download + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + run_case() { + mode="$1" + expected="$2" + case_dir="$RUNNER_TEMP/vite-plus-provenance-sh-$mode" + port_file="$case_dir/port" + log_file="$case_dir/requests.jsonl" + vp_home="$case_dir/vp-home" + + rm -rf "$case_dir" + mkdir -p "$case_dir/home" "$vp_home" + node packages/cli/tests/fixtures/provenance-registry.mjs \ + --port-file "$port_file" \ + --log-file "$log_file" \ + --mode "$mode" \ + --version "$TEST_VERSION" & + server_pid=$! + + for _ in $(seq 1 100); do + [ -s "$port_file" ] && break + sleep 0.1 + done + if [ ! -s "$port_file" ]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + echo "Mock registry did not start" + return 1 + fi + + registry="http://127.0.0.1:$(cat "$port_file")" + set +e + output=$(env \ + CI=true \ + HOME="$case_dir/home" \ + VP_HOME="$vp_home" \ + VP_NODE_MANAGER=no \ + VP_VERSION="$TEST_VERSION" \ + NPM_CONFIG_REGISTRY="$registry" \ + bash packages/cli/install.sh 2>&1) + status=$? + set -e + + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + printf '%s\n' "$output" + + if [ "$status" -eq 0 ]; then + echo "Expected the fixture tarball endpoint to prevent installation" + return 1 + fi + + if [ "$expected" = reject ]; then + printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata" + printf '%s\n' "$output" | grep -F \ + "@voidzero-dev/vite-plus-cli-" + printf '%s\n' "$output" | grep -F "$TEST_VERSION" + + if grep -F '"path":"/platform.tgz"' "$log_file"; then + echo "Platform tarball was requested before provenance validation" + return 1 + fi + if [ -e "$vp_home/current" ] || [ -e "$vp_home/$TEST_VERSION/bin/vp" ]; then + echo "Rejected package left an active or executable installation" + return 1 + fi + else + if printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata"; then + echo "Supported provenance metadata was rejected" + return 1 + fi + grep -F '"path":"/platform.tgz"' "$log_file" + fi + } + + run_case missing reject + run_case malformed reject + run_case top-level-only reject + run_case unsupported reject + run_case valid-v1 allow + run_case valid-v0.2 allow + + test-vp-upgrade-provenance: + name: Test vp upgrade (npm provenance) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - uses: ./.github/actions/clone + + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24) + + - name: Test shared provenance resolver + run: cargo test -p vp_setup registry + + - name: Build global vp + run: cargo build -p vp_global_cli + + - name: Verify upgrade provenance before download + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + case_dir="$RUNNER_TEMP/vite-plus-provenance-upgrade" + port_file="$case_dir/port" + log_file="$case_dir/requests.jsonl" + rm -rf "$case_dir" + mkdir -p "$case_dir" + + node packages/cli/tests/fixtures/provenance-registry.mjs \ + --port-file "$port_file" \ + --log-file "$log_file" \ + --mode missing \ + --version "$TEST_VERSION" & + server_pid=$! + cleanup() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + } + trap cleanup EXIT + + for _ in $(seq 1 100); do + [ -s "$port_file" ] && break + sleep 0.1 + done + if [ ! -s "$port_file" ]; then + echo "Mock registry did not start" + exit 1 + fi + + registry="http://127.0.0.1:$(cat "$port_file")" + run_upgrade() { + label="$1" + shift + home_dir="$case_dir/home-$label" + vp_home="$case_dir/vp-home-$label" + mkdir -p "$home_dir" + + set +e + output=$(env \ + CI=true \ + HOME="$home_dir" \ + VP_HOME="$vp_home" \ + target/debug/vp upgrade "$TEST_VERSION" --registry "$registry" "$@" 2>&1) + status=$? + set -e + printf '%s\n' "$output" + + if [ "$status" -eq 0 ]; then + echo "Expected vp upgrade $label to reject missing provenance" + return 1 + fi + printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata" + printf '%s\n' "$output" | grep -F \ + "@voidzero-dev/vite-plus-cli-" + printf '%s\n' "$output" | grep -F "$TEST_VERSION" + + if [ -e "$vp_home/current" ] || [ -e "$vp_home/$TEST_VERSION" ]; then + echo "Rejected upgrade changed the active or target version" + return 1 + fi + } + + run_upgrade install + run_upgrade check --check + + if grep -F '"path":"/platform.tgz"' "$log_file"; then + echo "Platform tarball was requested before provenance validation" + exit 1 + fi + grep -F '"path":"/@voidzero-dev/vite-plus-cli-' "$log_file" + test-install-sh-layout: name: Test install.sh layout (fresh split + grandfather) runs-on: ubuntu-latest @@ -1067,6 +1253,123 @@ jobs: vp upgrade --rollback vp --version + test-install-ps1-provenance: + name: Test install.ps1 (npm provenance, Windows PowerShell 5.1) + runs-on: namespace-profile-windows-4c-8g + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Verify platform package provenance before download + shell: powershell + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + $ErrorActionPreference = "Stop" + + function Invoke-ProvenanceCase { + param( + [string]$Mode, + [bool]$ExpectRejection, + [bool]$RawContentType = $false + ) + + $caseDir = Join-Path $env:RUNNER_TEMP "vite-plus-provenance-ps1-$Mode" + $homeDir = Join-Path $caseDir "home" + $vpHome = Join-Path $caseDir "vp-home" + $portFile = Join-Path $caseDir "port" + $logFile = Join-Path $caseDir "requests.jsonl" + $stdoutFile = Join-Path $caseDir "registry.stdout.log" + $stderrFile = Join-Path $caseDir "registry.stderr.log" + + Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $homeDir, $vpHome -Force | Out-Null + + $fixture = Join-Path (Get-Location) "packages/cli/tests/fixtures/provenance-registry.mjs" + $serverArgs = @( + $fixture, + "--port-file", $portFile, + "--log-file", $logFile, + "--mode", $Mode, + "--version", $env:TEST_VERSION + ) + if ($RawContentType) { + $serverArgs += @("--raw-content-type", "true") + } + + $server = Start-Process -FilePath "node" -ArgumentList $serverArgs -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + + try { + for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path $portFile); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $portFile)) { + throw "Mock registry did not start: $(Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue)" + } + + $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" + $env:CI = "true" + $env:USERPROFILE = $homeDir + $env:VP_HOME = $vpHome + $env:VP_NODE_MANAGER = "no" + $env:VP_VERSION = $env:TEST_VERSION + $env:NPM_CONFIG_REGISTRY = $registry + + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File .\packages\cli\install.ps1 2>&1 + $exitCode = $LASTEXITCODE + $text = $output -join "`n" + } finally { + if (-not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit() + } + } + + Write-Host $text + if ($exitCode -eq 0) { + throw "Expected the fixture tarball endpoint to prevent installation" + } + + $requests = Get-Content -Path $logFile -Raw + $tarballRequested = $requests.Contains('"path":"/platform.tgz"') + $provenanceError = "does not contain supported npm provenance metadata" + + if ($ExpectRejection) { + if (-not $text.Contains($provenanceError)) { + throw "Expected provenance rejection for $Mode" + } + if (-not $text.Contains("@voidzero-dev/vite-plus-cli-") -or + -not $text.Contains($env:TEST_VERSION)) { + throw "Expected rejected package name and version in installer output" + } + if ($tarballRequested) { + throw "Platform tarball was requested before provenance validation" + } + if ((Test-Path (Join-Path $vpHome "current")) -or + (Test-Path (Join-Path $vpHome "$($env:TEST_VERSION)\bin\vp.exe"))) { + throw "Rejected package left an active or executable installation" + } + } else { + if ($text.Contains($provenanceError)) { + throw "Supported provenance metadata was rejected" + } + if (-not $tarballRequested) { + throw "Supported provenance metadata did not reach the tarball endpoint" + } + } + + # The child installer and the deliberate tarball failure are expected. + $global:LASTEXITCODE = 0 + } + + Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "malformed" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "top-level-only" -ExpectRejection $true -RawContentType $true + Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "valid-v1" -ExpectRejection $false + Invoke-ProvenanceCase -Mode "valid-v0.2" -ExpectRejection $false + test-install-ps1-release-age: name: Test legacy install.ps1 (minimum-release-age) runs-on: namespace-profile-windows-4c-8g @@ -1358,6 +1661,74 @@ jobs: cargo build --release -p vp_global_cli -p vp_installer node packages/tools/src/build-trampoline.ts --release + - name: Verify vp-setup.exe provenance before download + shell: pwsh + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + $ErrorActionPreference = "Stop" + $caseDir = Join-Path $env:RUNNER_TEMP "vite-plus-provenance-vp-setup" + $installDir = Join-Path $caseDir "install" + $portFile = Join-Path $caseDir "port" + $logFile = Join-Path $caseDir "requests.jsonl" + $stdoutFile = Join-Path $caseDir "registry.stdout.log" + $stderrFile = Join-Path $caseDir "registry.stderr.log" + Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $caseDir -Force | Out-Null + + $fixture = Join-Path (Get-Location) "packages/cli/tests/fixtures/provenance-registry.mjs" + $serverArgs = @( + $fixture, + "--port-file", $portFile, + "--log-file", $logFile, + "--mode", "missing", + "--version", $env:TEST_VERSION + ) + $server = Start-Process -FilePath "node" -ArgumentList $serverArgs -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + + try { + for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path $portFile); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $portFile)) { + throw "Mock registry did not start: $(Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue)" + } + + $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" + $binary = "${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }}" + $output = & $binary --yes --no-node-manager --no-modify-path --install-dir $installDir --registry $registry --version $env:TEST_VERSION 2>&1 + $exitCode = $LASTEXITCODE + $text = $output -join "`n" + } finally { + if (-not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit() + } + } + + Write-Host $text + if ($exitCode -eq 0) { + throw "Expected vp-setup.exe to reject missing provenance" + } + if (-not $text.Contains("does not contain supported npm provenance metadata") -or + -not $text.Contains("@voidzero-dev/vite-plus-cli-") -or + -not $text.Contains($env:TEST_VERSION)) { + throw "Expected provenance rejection with the package name and version" + } + + $requests = Get-Content -Path $logFile -Raw + if ($requests.Contains('"path":"/platform.tgz"')) { + throw "Platform tarball was requested before provenance validation" + } + if ((Test-Path (Join-Path $installDir "current")) -or + (Test-Path (Join-Path $installDir "bin\vp.exe")) -or + (Test-Path (Join-Path $installDir $env:TEST_VERSION))) { + throw "Rejected package left a partial vp-setup.exe installation" + } + + # The child installer is expected to fail in this test. + $global:LASTEXITCODE = 0 + - name: Test trampoline with an extended-length payload path shell: pwsh run: | diff --git a/Cargo.lock b/Cargo.lock index 432303622f..fa1d61bf11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8901,6 +8901,7 @@ version = "0.0.0" dependencies = [ "base64-simd", "flate2", + "httpmock", "junction", "node-semver", "serde", diff --git a/crates/vp_setup/Cargo.toml b/crates/vp_setup/Cargo.toml index 0d0b691ec9..09d2dc5e25 100644 --- a/crates/vp_setup/Cargo.toml +++ b/crates/vp_setup/Cargo.toml @@ -28,6 +28,7 @@ vt_str = { workspace = true } junction = { workspace = true } [dev-dependencies] +httpmock = { workspace = true } tempfile = { workspace = true } [lib] diff --git a/crates/vp_setup/src/error.rs b/crates/vp_setup/src/error.rs index 0dd6467eab..f9a5cadbd2 100644 --- a/crates/vp_setup/src/error.rs +++ b/crates/vp_setup/src/error.rs @@ -21,4 +21,9 @@ pub enum Error { #[error("Unsupported integrity format: {0} (only sha512 is supported)")] UnsupportedIntegrity(Str), + + #[error( + "Refusing to install {package}@{version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + )] + UnsupportedPlatformPackageProvenance { package: Str, version: Str }, } diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index 7bf8465430..e98dd64ff0 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -20,6 +20,22 @@ pub struct PackageVersionMetadata { pub struct DistInfo { pub tarball: String, pub integrity: String, + #[serde(default)] + pub attestations: Option, +} + +/// npm attestations attached to a package version. +#[derive(Debug, Deserialize)] +pub struct NpmAttestations { + #[serde(default)] + pub provenance: Option, +} + +/// npm provenance metadata used to identify the attestation predicate. +#[derive(Debug, Deserialize)] +pub struct NpmProvenance { + #[serde(rename = "predicateType", default)] + pub predicate_type: Option, } /// Resolved version info with URLs and integrity for the platform package. @@ -33,6 +49,32 @@ pub struct ResolvedVersion { const MAIN_PACKAGE_NAME: &str = "vite-plus"; const PLATFORM_PACKAGE_SCOPE: &str = "@voidzero-dev"; const CLI_PACKAGE_NAME_PREFIX: &str = "vite-plus-cli"; +const SUPPORTED_PROVENANCE_PREDICATE_TYPES: [&str; 2] = + ["https://slsa.dev/provenance/v1", "https://slsa.dev/provenance/v0.2"]; + +fn validate_platform_package_provenance( + package_name: &str, + version: &str, + dist: &DistInfo, +) -> Result<(), Error> { + let predicate_type = dist + .attestations + .as_ref() + .and_then(|attestations| attestations.provenance.as_ref()) + .and_then(|provenance| provenance.predicate_type.as_deref()) + .filter(|predicate_type| !predicate_type.is_empty()); + + if predicate_type.is_some_and(|predicate_type| { + SUPPORTED_PROVENANCE_PREDICATE_TYPES.contains(&predicate_type) + }) { + return Ok(()); + } + + Err(Error::UnsupportedPlatformPackageProvenance { + package: package_name.into(), + version: version.into(), + }) +} /// Resolve a version string from the npm registry. /// @@ -86,6 +128,11 @@ pub async fn resolve_platform_package( ) })?; + // npm registry signatures only prove that registry metadata was signed. The + // provenance object separately binds the package to its supported build + // attestation, so reject before exposing the tarball URL to any caller. + validate_platform_package_provenance(&cli_package_name, version, &cli_meta.dist)?; + Ok(ResolvedVersion { version: version.to_owned(), platform_tarball_url: cli_meta.dist.tarball, @@ -109,8 +156,34 @@ pub async fn resolve_version( #[cfg(test)] mod tests { + use httpmock::prelude::*; + use super::*; + const TEST_PACKAGE_NAME: &str = "@voidzero-dev/vite-plus-cli-darwin-arm64"; + const TEST_VERSION: &str = "1.2.3"; + + fn parse_metadata(dist: serde_json::Value) -> PackageVersionMetadata { + serde_json::from_value(serde_json::json!({ + "version": TEST_VERSION, + "dist": dist, + })) + .unwrap() + } + + fn dist_with_provenance(predicate_type: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "signatures": [{ "keyid": "registry-signature-is-not-provenance" }], + "attestations": { + "provenance": { + "predicateType": predicate_type, + } + } + }) + } + #[test] fn test_cli_package_name_construction() { let suffix = "darwin-arm64"; @@ -118,6 +191,156 @@ mod tests { assert_eq!(name, "@voidzero-dev/vite-plus-cli-darwin-arm64"); } + #[test] + fn test_platform_package_accepts_supported_provenance_predicates() { + for predicate_type in SUPPORTED_PROVENANCE_PREDICATE_TYPES { + let metadata = parse_metadata(dist_with_provenance(predicate_type.into())); + assert!( + validate_platform_package_provenance( + TEST_PACKAGE_NAME, + TEST_VERSION, + &metadata.dist, + ) + .is_ok(), + "expected {predicate_type} to be accepted" + ); + } + } + + #[test] + fn test_platform_package_rejects_missing_or_unsupported_provenance() { + let cases = [ + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + }), + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "attestations": {}, + }), + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "attestations": { "provenance": {} }, + }), + dist_with_provenance("".into()), + dist_with_provenance(" https://slsa.dev/provenance/v1 ".into()), + dist_with_provenance("https://example.test/unknown-provenance/v1".into()), + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "signatures": [{ "keyid": "signature-only" }], + }), + ]; + + for dist in cases { + let metadata = parse_metadata(dist); + let error = validate_platform_package_provenance( + TEST_PACKAGE_NAME, + TEST_VERSION, + &metadata.dist, + ) + .unwrap_err(); + + match error { + Error::UnsupportedPlatformPackageProvenance { package, version } => { + assert_eq!(package.as_str(), TEST_PACKAGE_NAME); + assert_eq!(version.as_str(), TEST_VERSION); + } + other => panic!("unexpected error: {other:?}"), + } + } + } + + #[test] + fn test_platform_package_ignores_top_level_attestations() { + let metadata: PackageVersionMetadata = serde_json::from_value(serde_json::json!({ + "version": TEST_VERSION, + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + }, + "dist": { + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test" + } + })) + .unwrap(); + + assert!(matches!( + validate_platform_package_provenance(TEST_PACKAGE_NAME, TEST_VERSION, &metadata.dist,), + Err(Error::UnsupportedPlatformPackageProvenance { .. }) + )); + } + + #[test] + fn test_platform_package_metadata_rejects_malformed_provenance_shape() { + let result = serde_json::from_value::(serde_json::json!({ + "version": TEST_VERSION, + "dist": { + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "attestations": { "provenance": "not-an-object" } + } + })); + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_resolve_platform_package_returns_verified_distribution() { + let server = MockServer::start(); + let metadata_mock = server.mock(|when, then| { + when.method(GET).path("/@voidzero-dev/vite-plus-cli-darwin-arm64/1.2.3"); + then.status(200).json_body(serde_json::json!({ + "version": TEST_VERSION, + "dist": dist_with_provenance("https://slsa.dev/provenance/v1".into()), + })); + }); + + let resolved = + resolve_platform_package(TEST_VERSION, "darwin-arm64", Some(&server.base_url())) + .await + .unwrap(); + + metadata_mock.assert(); + assert_eq!(resolved.version, TEST_VERSION); + assert_eq!(resolved.platform_tarball_url, "https://registry.example.test/platform.tgz"); + assert_eq!(resolved.platform_integrity, "sha512-test"); + } + + #[tokio::test] + async fn test_resolve_platform_package_rejects_before_returning_distribution() { + let server = MockServer::start(); + let metadata_mock = server.mock(|when, then| { + when.method(GET).path("/@voidzero-dev/vite-plus-cli-darwin-arm64/1.2.3"); + then.status(200).json_body(serde_json::json!({ + "version": TEST_VERSION, + "dist": { + "tarball": format!("{}/platform.tgz", server.base_url()), + "integrity": "sha512-test", + } + })); + }); + let tarball_mock = server.mock(|when, then| { + when.method(GET).path("/platform.tgz"); + then.status(200).body("must not be downloaded"); + }); + + let error = + resolve_platform_package(TEST_VERSION, "darwin-arm64", Some(&server.base_url())) + .await + .unwrap_err(); + + metadata_mock.assert(); + assert_eq!(tarball_mock.hits(), 0); + assert!(matches!(error, Error::UnsupportedPlatformPackageProvenance { .. })); + assert!(error.to_string().contains(TEST_PACKAGE_NAME)); + assert!(error.to_string().contains(TEST_VERSION)); + } + #[test] fn test_all_platform_suffixes_match_published_cli_packages() { // These are the actual published CLI package suffixes diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 63c97ff4c0..256ab152ea 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -38,6 +38,10 @@ $PrVersion = $env:VP_PR_VERSION # pulls a coherent, clearly-defined test build. $BridgeDownloadBase = "https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" $BridgeRegistry = "https://registry-bridge.viteplus.dev/" +$SupportedProvenancePredicateTypes = @( + "https://slsa.dev/provenance/v1", + "https://slsa.dev/provenance/v0.2" +) $script:InstallStopSignal = 'VP_INSTALL_STOP' $script:PackageMetadata = $null @@ -177,6 +181,72 @@ function Get-VersionFromMetadata { return $metadata.version } +function Get-PlatformPackageMetadata { + param( + [string]$PackageName, + [string]$Version + ) + + $encodedPackageName = [System.Uri]::EscapeDataString($PackageName) + $metadataUrl = "$NpmRegistry/$encodedPackageName/$Version" + try { + $metadata = Invoke-RestMethod -Uri $metadataUrl -Headers @{ Accept = "application/json" } + } catch { + if (Test-IsInstallStopException $_) { throw } + $errorMsg = $_.ErrorDetails.Message + if ($errorMsg) { + try { + $errorJson = $errorMsg | ConvertFrom-Json + if ($errorJson.error) { + Write-Error-Exit "Failed to fetch CLI package metadata '${PackageName}@${Version}': $($errorJson.error)`n URL: $metadataUrl" + } + } catch { + if (Test-IsInstallStopException $_) { throw } + # JSON parsing failed, fall through to the generic network error. + } + } + Write-Error-Exit "Failed to fetch CLI package metadata from: $metadataUrl`nError: $_" + } + + # Some custom registries return JSON using a non-JSON content type. Match + # Get-PackageMetadata by parsing that raw string before inspecting fields. + if ($metadata -is [string]) { + try { + $metadata = $metadata | ConvertFrom-Json + } catch { + if (Test-IsInstallStopException $_) { throw } + Write-Error-Exit "Failed to parse CLI package metadata '${PackageName}@${Version}'`n URL: $metadataUrl" + } + } + if ($metadata.error) { + Write-Error-Exit "Failed to fetch CLI package metadata '${PackageName}@${Version}': $($metadata.error)`n URL: $metadataUrl" + } + + return $metadata +} + +function Get-VerifiedPlatformTarballUrl { + param( + [object]$Metadata, + [string]$PackageName, + [string]$Version + ) + + # Registry signatures and trusted-publisher labels are not substitutes for + # npm provenance. Check the typed object path so package-defined top-level + # fields cannot satisfy the gate, and deny unknown predicates before download. + $predicateType = $Metadata.dist.attestations.provenance.predicateType + if (-not $predicateType -or $SupportedProvenancePredicateTypes -notcontains $predicateType) { + Write-Error-Exit "Refusing to install ${PackageName}@${Version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + } + + $tarballUrl = $Metadata.dist.tarball + if (-not $tarballUrl) { + Write-Error-Exit "CLI package metadata for ${PackageName}@${Version} does not include dist.tarball" + } + return [string]$tarballUrl +} + function Get-PlatformSuffix { param([string]$Platform) # Windows needs -msvc suffix, other platforms map directly @@ -273,7 +343,8 @@ function Get-PayloadAndHandoff { $platformUrl = "$BridgeDownloadBase/@voidzero-dev/vite-plus-cli-$platformSuffix@$($PrCommitVersion.Substring(13))" } else { $packageName = "@voidzero-dev/vite-plus-cli-$platformSuffix" - $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$platformSuffix-$ViteVersion.tgz" + $platformMetadata = Get-PlatformPackageMetadata -PackageName $packageName -Version $ViteVersion + $platformUrl = Get-VerifiedPlatformTarballUrl -Metadata $platformMetadata -PackageName $packageName -Version $ViteVersion } $platformTempFile = New-TemporaryFile diff --git a/packages/cli/install.sh b/packages/cli/install.sh index be5f2b9735..2fed13d28b 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -219,6 +219,10 @@ check_requirements() { fi } +# Fetch package metadata from npm registry (cached for reuse) +# Uses VP_VERSION to fetch the correct version's metadata +PACKAGE_METADATA="" +PLATFORM_TARBALL_URL="" fetch_package_metadata() { if [ -z "$PACKAGE_METADATA" ]; then local version_path metadata_url @@ -257,6 +261,246 @@ get_version_from_metadata() { fi } +# Extract the platform tarball URL and provenance predicate from npm version +# metadata. Bootstrap runs before Node.js is available and cannot require jq, +# so this parser tracks complete JSON object paths rather than matching key +# names anywhere in the response. That distinction prevents a package-defined +# top-level `attestations` field or `dist.signatures` from being mistaken for +# npm's `dist.attestations.provenance` metadata. Invalid JSON fails closed. +parse_platform_distribution_metadata() { + awk ' + function fail_json(message) { + print message > "/dev/stderr" + exit 2 + } + + function skip_whitespace( c) { + while (json_pos <= json_length) { + c = substr(json_text, json_pos, 1) + if (c == " " || c == "\t" || c == "\r" || c == "\n") { + json_pos++ + } else { + return + } + } + } + + function parse_string( result, c, escaped, hex) { + skip_whitespace() + if (substr(json_text, json_pos, 1) != "\"") { + fail_json("expected JSON string") + } + json_pos++ + + while (json_pos <= json_length) { + c = substr(json_text, json_pos, 1) + json_pos++ + if (c == "\"") { + return result + } + if (c == "\\") { + if (json_pos > json_length) { + fail_json("unterminated JSON escape") + } + escaped = substr(json_text, json_pos, 1) + json_pos++ + if (escaped == "\"" || escaped == "\\" || escaped == "/") { + result = result escaped + } else if (escaped == "b" || escaped == "f" || escaped == "n" || escaped == "r" || escaped == "t") { + # Keep control escapes printable so extracted values cannot inject lines. + result = result "\\" escaped + } else if (escaped == "u") { + hex = substr(json_text, json_pos, 4) + if (length(hex) != 4 || hex ~ /[^0-9A-Fa-f]/) { + fail_json("invalid JSON unicode escape") + } + result = result "\\u" hex + json_pos += 4 + } else { + fail_json("invalid JSON escape") + } + } else { + if (c ~ /[[:cntrl:]]/) { + fail_json("unescaped control character in JSON string") + } + result = result c + } + } + + fail_json("unterminated JSON string") + } + + function remember_string(path, value) { + if (path == "dist.tarball") { + if (++tarball_count != 1) fail_json("duplicate dist.tarball") + tarball = value + } else if (path == "dist.attestations.provenance.predicateType") { + if (++predicate_count != 1) fail_json("duplicate provenance predicateType") + predicate_type = value + } else if (path == "error") { + if (++error_count != 1) fail_json("duplicate registry error") + registry_error = value + } + } + + function parse_number( start, value, c) { + start = json_pos + while (json_pos <= json_length) { + c = substr(json_text, json_pos, 1) + if (c ~ /[-+0-9.eE]/) json_pos++ + else break + } + value = substr(json_text, start, json_pos - start) + if (value !~ /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) { + fail_json("invalid JSON number") + } + } + + function parse_literal(literal) { + if (substr(json_text, json_pos, length(literal)) != literal) { + fail_json("invalid JSON literal") + } + json_pos += length(literal) + } + + function parse_array(path, c) { + json_pos++ + skip_whitespace() + if (substr(json_text, json_pos, 1) == "]") { + json_pos++ + return + } + + while (1) { + parse_value(path "[]") + skip_whitespace() + c = substr(json_text, json_pos, 1) + if (c == "]") { + json_pos++ + return + } + if (c != ",") fail_json("expected comma in JSON array") + json_pos++ + } + } + + function parse_object(path, key, child_path, c, object_id) { + json_pos++ + object_id = ++object_count + skip_whitespace() + if (substr(json_text, json_pos, 1) == "}") { + json_pos++ + return + } + + while (1) { + key = parse_string() + if ((object_id SUBSEP key) in object_keys) { + fail_json("duplicate key in JSON object") + } + object_keys[object_id SUBSEP key] = 1 + skip_whitespace() + if (substr(json_text, json_pos, 1) != ":") { + fail_json("expected colon in JSON object") + } + json_pos++ + child_path = path == "" ? key : path "." key + parse_value(child_path) + skip_whitespace() + c = substr(json_text, json_pos, 1) + if (c == "}") { + json_pos++ + return + } + if (c != ",") fail_json("expected comma in JSON object") + json_pos++ + } + } + + function parse_value(path, c, value) { + skip_whitespace() + c = substr(json_text, json_pos, 1) + if (c == "{") { + parse_object(path) + } else if (c == "[") { + parse_array(path) + } else if (c == "\"") { + value = parse_string() + remember_string(path, value) + } else if (c == "t") { + parse_literal("true") + } else if (c == "f") { + parse_literal("false") + } else if (c == "n") { + parse_literal("null") + } else if (c == "-" || c ~ /[0-9]/) { + parse_number() + } else { + fail_json("invalid JSON value") + } + } + + { json_text = json_text $0 "\n" } + + END { + json_length = length(json_text) + json_pos = 1 + parse_value("") + skip_whitespace() + if (json_pos <= json_length) fail_json("unexpected data after JSON value") + + print tarball + print predicate_type + print registry_error + } + ' +} + +# Fetch exact platform package metadata and admit only npm provenance predicate +# types supported by Vite+. `dist.signatures` is deliberately insufficient: it +# authenticates registry metadata, while provenance binds this release binary +# to the build that produced it. Any missing or unrecognized evidence is denied +# before the tarball URL is used. +resolve_platform_distribution() { + local package_name="$1" + local package_version="$2" + local encoded_package_name="${package_name/\//%2F}" + local metadata_url="${NPM_REGISTRY}/${encoded_package_name}/${package_version}" + local metadata parsed registry_error predicate_type + + metadata=$(curl_with_error_handling -s "$metadata_url") + if [ -z "$metadata" ]; then + error "Failed to fetch CLI package metadata from: $metadata_url" + fi + + if ! parsed=$(printf '%s\n' "$metadata" | parse_platform_distribution_metadata); then + error "Failed to parse CLI package metadata for ${package_name}@${package_version}\n URL: $metadata_url" + fi + + PLATFORM_TARBALL_URL=$(printf '%s\n' "$parsed" | sed -n '1p') + predicate_type=$(printf '%s\n' "$parsed" | sed -n '2p') + registry_error=$(printf '%s\n' "$parsed" | sed -n '3p') + + if [ -n "$registry_error" ]; then + error "Failed to fetch CLI package metadata '${package_name}@${package_version}': ${registry_error}\n URL: $metadata_url" + fi + + case "$predicate_type" in + https://slsa.dev/provenance/v1|https://slsa.dev/provenance/v0.2) ;; + *) + error "Refusing to install ${package_name}@${package_version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + ;; + esac + + if [ -z "$PLATFORM_TARBALL_URL" ]; then + error "CLI package metadata for ${package_name}@${package_version} does not include dist.tarball\n URL: $metadata_url" + fi +} + +# Get platform suffix for CLI package download +# Sets PLATFORM_SUFFIX global variable +# Platform format from detect_platform(): darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, win32-x64, etc. +# CLI package format: @voidzero-dev/vite-plus-cli-darwin-arm64, @voidzero-dev/vite-plus-cli-linux-x64-gnu, etc. get_platform_suffix() { local platform="$1" case "$platform" in @@ -407,7 +651,8 @@ acquire_and_handoff() ( platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_COMMIT_VERSION#0.0.0-commit.}" else local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" - platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" + resolve_platform_distribution "$package_name" "$VP_VERSION" + platform_url="$PLATFORM_TARBALL_URL" fi # Create temp directory for extraction diff --git a/packages/cli/tests/fixtures/provenance-registry.mjs b/packages/cli/tests/fixtures/provenance-registry.mjs new file mode 100644 index 0000000000..8f53526f44 --- /dev/null +++ b/packages/cli/tests/fixtures/provenance-registry.mjs @@ -0,0 +1,129 @@ +import { appendFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + const name = process.argv[index]; + const value = process.argv[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid argument at position ${index}: ${name ?? ''}`); + } + args.set(name.slice(2), value); +} + +const portFile = args.get('port-file'); +const logFile = args.get('log-file'); +const mode = args.get('mode') ?? 'missing'; +const version = args.get('version') ?? '9.9.9-provenance-test.1'; +const rawContentType = args.get('raw-content-type') === 'true'; + +if (!portFile || !logFile) { + throw new Error('--port-file and --log-file are required'); +} +if ( + !['missing', 'malformed', 'top-level-only', 'unsupported', 'valid-v1', 'valid-v0.2'].includes( + mode, + ) +) { + throw new Error(`Unsupported mode: ${mode}`); +} + +writeFileSync(logFile, ''); + +function sendJson(response, status, body) { + const json = JSON.stringify(body); + response.writeHead(status, { + 'content-length': Buffer.byteLength(json), + 'content-type': rawContentType ? 'text/plain' : 'application/json', + }); + response.end(json); +} + +function platformMetadata(packageName, registryBase) { + const metadata = { + name: packageName, + version, + dist: { + tarball: `${registryBase}/platform.tgz`, + integrity: 'sha512-test-only', + signatures: [{ keyid: 'registry-signature-is-not-provenance', sig: 'test-only' }], + }, + }; + + if (mode === 'malformed') { + metadata.dist.attestations = { provenance: 'not-an-object' }; + } else if (mode === 'top-level-only') { + metadata.attestations = { + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, + }; + } else if (mode === 'unsupported') { + metadata.dist.attestations = { + provenance: { predicateType: 'https://example.test/provenance/v1' }, + }; + } else if (mode === 'valid-v1') { + metadata.dist.attestations = { + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, + }; + } else if (mode === 'valid-v0.2') { + metadata.dist.attestations = { + provenance: { predicateType: 'https://slsa.dev/provenance/v0.2' }, + }; + } + + return metadata; +} + +const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + let decodedPath; + try { + decodedPath = decodeURIComponent(url.pathname); + } catch { + sendJson(response, 400, { error: 'invalid URL encoding' }); + return; + } + + appendFileSync(logFile, `${JSON.stringify({ method: request.method, path: decodedPath })}\n`); + const requestHost = request.headers.host ?? `127.0.0.1:${server.address().port}`; + const registryBase = `http://${requestHost}`; + + if (decodedPath === `/vite-plus/${version}`) { + sendJson(response, 200, { + name: 'vite-plus', + version, + dist: { + tarball: `${registryBase}/vite-plus.tgz`, + integrity: 'sha512-test-only', + }, + }); + return; + } + + const platformMatch = decodedPath.match( + new RegExp(`^/(@voidzero-dev/vite-plus-cli-[a-z0-9-]+)/${version.replaceAll('.', '\\.')}$`), + ); + if (platformMatch) { + sendJson(response, 200, platformMetadata(platformMatch[1], registryBase)); + return; + } + + if (decodedPath === '/platform.tgz' || decodedPath === '/vite-plus.tgz') { + response.writeHead(500, { 'content-type': 'text/plain' }); + response.end('The provenance gate must reject before requesting a tarball.\n'); + return; + } + + sendJson(response, 404, { error: `No fixture response for ${decodedPath}` }); +}); + +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener'); + } + writeFileSync(portFile, String(address.port)); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))); +} From eaca4d8369fca0b5c3ea75fe357780981b6f8cbb Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Thu, 13 Aug 2026 15:09:15 +0900 Subject: [PATCH 02/10] test(install): capture PowerShell output safely --- .github/workflows/test-standalone-install.yml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 55ac4600e5..6698939f99 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -1282,6 +1282,8 @@ jobs: $logFile = Join-Path $caseDir "requests.jsonl" $stdoutFile = Join-Path $caseDir "registry.stdout.log" $stderrFile = Join-Path $caseDir "registry.stderr.log" + $installerStdoutFile = Join-Path $caseDir "installer.stdout.log" + $installerStderrFile = Join-Path $caseDir "installer.stderr.log" Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $homeDir, $vpHome -Force | Out-Null @@ -1316,9 +1318,20 @@ jobs: $env:VP_VERSION = $env:TEST_VERSION $env:NPM_CONFIG_REGISTRY = $registry - $output = & powershell -NoProfile -ExecutionPolicy Bypass -File .\packages\cli\install.ps1 2>&1 - $exitCode = $LASTEXITCODE - $text = $output -join "`n" + # Windows PowerShell 5.1 turns redirected native stderr into a + # NativeCommandError. Capture each stream separately so the + # expected tarball failure cannot stop this parent test script. + $installerArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", ".\packages\cli\install.ps1" + ) + $installer = Start-Process -FilePath "powershell.exe" -ArgumentList $installerArgs -PassThru -Wait -RedirectStandardOutput $installerStdoutFile -RedirectStandardError $installerStderrFile + $exitCode = $installer.ExitCode + $text = @( + Get-Content -Path $installerStdoutFile -Raw -ErrorAction SilentlyContinue + Get-Content -Path $installerStderrFile -Raw -ErrorAction SilentlyContinue + ) -join "`n" } finally { if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force From 85f9af9043c9690984a18ccc49ca5fca6bc10b30 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 14 Aug 2026 00:35:29 +0900 Subject: [PATCH 03/10] fix(install): prevent provenance path spoofing --- .github/workflows/test-standalone-install.yml | 2 + packages/cli/install.sh | 52 ++++++++++++------- .../tests/fixtures/provenance-registry.mjs | 14 +++-- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 6698939f99..c593953793 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -222,6 +222,7 @@ jobs: run_case missing reject run_case malformed reject run_case top-level-only reject + run_case dotted-top-level-key reject run_case unsupported reject run_case valid-v1 allow run_case valid-v0.2 allow @@ -1379,6 +1380,7 @@ jobs: Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true Invoke-ProvenanceCase -Mode "malformed" -ExpectRejection $true Invoke-ProvenanceCase -Mode "top-level-only" -ExpectRejection $true -RawContentType $true + Invoke-ProvenanceCase -Mode "dotted-top-level-key" -ExpectRejection $true Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $true Invoke-ProvenanceCase -Mode "valid-v1" -ExpectRejection $false Invoke-ProvenanceCase -Mode "valid-v0.2" -ExpectRejection $false diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 2fed13d28b..5ff344586b 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -263,10 +263,10 @@ get_version_from_metadata() { # Extract the platform tarball URL and provenance predicate from npm version # metadata. Bootstrap runs before Node.js is available and cannot require jq, -# so this parser tracks complete JSON object paths rather than matching key -# names anywhere in the response. That distinction prevents a package-defined -# top-level `attestations` field or `dist.signatures` from being mistaken for -# npm's `dist.attestations.provenance` metadata. Invalid JSON fails closed. +# so this parser tracks each JSON path segment and container boundary rather +# than matching key names or dot-joined paths. Keeping segments separate means +# a package-defined key containing dots cannot impersonate npm's nested +# `dist.attestations.provenance` metadata. Invalid JSON fails closed. parse_platform_distribution_metadata() { awk ' function fail_json(message) { @@ -330,14 +330,21 @@ parse_platform_distribution_metadata() { fail_json("unterminated JSON string") } - function remember_string(path, value) { - if (path == "dist.tarball") { + function is_object_key(depth, key) { + return path_kind[depth] == "object-key" && path_key[depth] == key + } + + function remember_string(depth, value) { + if (depth == 2 && is_object_key(1, "dist") && is_object_key(2, "tarball")) { if (++tarball_count != 1) fail_json("duplicate dist.tarball") tarball = value - } else if (path == "dist.attestations.provenance.predicateType") { + } else if (depth == 4 && is_object_key(1, "dist") && + is_object_key(2, "attestations") && + is_object_key(3, "provenance") && + is_object_key(4, "predicateType")) { if (++predicate_count != 1) fail_json("duplicate provenance predicateType") predicate_type = value - } else if (path == "error") { + } else if (depth == 1 && is_object_key(1, "error")) { if (++error_count != 1) fail_json("duplicate registry error") registry_error = value } @@ -363,7 +370,7 @@ parse_platform_distribution_metadata() { json_pos += length(literal) } - function parse_array(path, c) { + function parse_array(depth, c, child_depth) { json_pos++ skip_whitespace() if (substr(json_text, json_pos, 1) == "]") { @@ -372,7 +379,12 @@ parse_platform_distribution_metadata() { } while (1) { - parse_value(path "[]") + child_depth = depth + 1 + path_kind[child_depth] = "array-item" + path_key[child_depth] = "" + parse_value(child_depth) + delete path_kind[child_depth] + delete path_key[child_depth] skip_whitespace() c = substr(json_text, json_pos, 1) if (c == "]") { @@ -384,7 +396,7 @@ parse_platform_distribution_metadata() { } } - function parse_object(path, key, child_path, c, object_id) { + function parse_object(depth, key, child_depth, c, object_id) { json_pos++ object_id = ++object_count skip_whitespace() @@ -404,8 +416,12 @@ parse_platform_distribution_metadata() { fail_json("expected colon in JSON object") } json_pos++ - child_path = path == "" ? key : path "." key - parse_value(child_path) + child_depth = depth + 1 + path_kind[child_depth] = "object-key" + path_key[child_depth] = key + parse_value(child_depth) + delete path_kind[child_depth] + delete path_key[child_depth] skip_whitespace() c = substr(json_text, json_pos, 1) if (c == "}") { @@ -417,16 +433,16 @@ parse_platform_distribution_metadata() { } } - function parse_value(path, c, value) { + function parse_value(depth, c, value) { skip_whitespace() c = substr(json_text, json_pos, 1) if (c == "{") { - parse_object(path) + parse_object(depth) } else if (c == "[") { - parse_array(path) + parse_array(depth) } else if (c == "\"") { value = parse_string() - remember_string(path, value) + remember_string(depth, value) } else if (c == "t") { parse_literal("true") } else if (c == "f") { @@ -445,7 +461,7 @@ parse_platform_distribution_metadata() { END { json_length = length(json_text) json_pos = 1 - parse_value("") + parse_value(0) skip_whitespace() if (json_pos <= json_length) fail_json("unexpected data after JSON value") diff --git a/packages/cli/tests/fixtures/provenance-registry.mjs b/packages/cli/tests/fixtures/provenance-registry.mjs index 8f53526f44..156fed5331 100644 --- a/packages/cli/tests/fixtures/provenance-registry.mjs +++ b/packages/cli/tests/fixtures/provenance-registry.mjs @@ -21,9 +21,15 @@ if (!portFile || !logFile) { throw new Error('--port-file and --log-file are required'); } if ( - !['missing', 'malformed', 'top-level-only', 'unsupported', 'valid-v1', 'valid-v0.2'].includes( - mode, - ) + ![ + 'missing', + 'malformed', + 'top-level-only', + 'dotted-top-level-key', + 'unsupported', + 'valid-v1', + 'valid-v0.2', + ].includes(mode) ) { throw new Error(`Unsupported mode: ${mode}`); } @@ -56,6 +62,8 @@ function platformMetadata(packageName, registryBase) { metadata.attestations = { provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, }; + } else if (mode === 'dotted-top-level-key') { + metadata['dist.attestations.provenance.predicateType'] = 'https://slsa.dev/provenance/v1'; } else if (mode === 'unsupported') { metadata.dist.attestations = { provenance: { predicateType: 'https://example.test/provenance/v1' }, From 6022568c771406ce74d53707c90ef4267ce1f462 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Tue, 1 Sep 2026 16:06:47 +0900 Subject: [PATCH 04/10] test: fix installer and snapshot CI fixtures --- packages/tools/src/local-npm-registry.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index f471d04b89..2e94694265 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -265,6 +265,8 @@ const localPackuments = new Map(); // package name -> local-o // Far in the past so package-manager minimum-release-age gates never // quarantine the locally served versions. const LOCAL_PACKAGE_TIME = '2020-01-01T00:00:00.000Z'; +const VITE_PLUS_PLATFORM_PACKAGE_PREFIX = '@voidzero-dev/vite-plus-cli-'; +const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1'; if (packagesDir) { for (const basename of readdirSync(packagesDir)) { if (!basename.endsWith('.tgz')) { @@ -286,6 +288,18 @@ if (packagesDir) { // that verifies it (npm, pnpm, yarn, bun) gets a match. integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, shasum: createHash('sha1').update(bytes).digest('hex'), + // npm provenance is registry metadata, not part of the packed + // package.json. Published Vite+ platform packages carry this + // attestation, so synthesize it for their local test tarballs. + // Other local packages remain unchanged, allowing provenance + // rejection tests to serve packages without an attestation. + ...(pkg.name.startsWith(VITE_PLUS_PLATFORM_PACKAGE_PREFIX) && { + attestations: { + provenance: { + predicateType: SLSA_PROVENANCE_V1, + }, + }, + }), }, }, }, From ccf671b2a734e7b92863a5aeb8bb071cf86849c8 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sun, 13 Sep 2026 20:48:09 +0900 Subject: [PATCH 05/10] test(install): adapt provenance fixtures to self-setup --- .github/scripts/test-install-bootstrap.ps1 | 19 +++++++++++++++++-- .github/scripts/test-install-bootstrap.sh | 3 +++ .github/workflows/test-standalone-install.yml | 11 +++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.github/scripts/test-install-bootstrap.ps1 b/.github/scripts/test-install-bootstrap.ps1 index 9fc9ddec15..fac546d5e0 100644 --- a/.github/scripts/test-install-bootstrap.ps1 +++ b/.github/scripts/test-install-bootstrap.ps1 @@ -50,9 +50,24 @@ Assert ($LASTEXITCODE -eq 0) 'Could not create fixture' $env:TEMP = "$testRoot/tmp" function Invoke-RestMethod { - param($Uri) + param($Uri, $Headers) $script:Requests.Add("GET $Uri") - return @{ version = '0.2.9' } + if ($Uri -eq 'https://custom.example/vite-plus/latest') { + return @{ version = '0.2.9' } + } + if ([System.Uri]::UnescapeDataString($Uri) -like 'https://custom.example/@voidzero-dev/vite-plus-cli-*/0.2.9') { + # Release payloads must pass the real provenance gate before handoff. + return @{ + version = '0.2.9' + dist = @{ + tarball = 'https://custom.example/platform.tgz' + attestations = @{ + provenance = @{ predicateType = 'https://slsa.dev/provenance/v1' } + } + } + } + } + throw "Unexpected metadata request: $Uri" } function Invoke-WebRequest { param($Uri, $Method, $OutFile, [switch]$UseBasicParsing, $ErrorAction) diff --git a/.github/scripts/test-install-bootstrap.sh b/.github/scripts/test-install-bootstrap.sh index 3ec14e6ee8..0fdf6419b4 100644 --- a/.github/scripts/test-install-bootstrap.sh +++ b/.github/scripts/test-install-bootstrap.sh @@ -59,6 +59,9 @@ curl() { *file://*) command curl "$@" ;; *-fsSIL*) printf 'x-commit-key: voidzero-dev:vite-plus:%s\r\n' "$fixture_sha" ;; *'https://custom.example/vite-plus/'*) printf '{"version":"0.2.9"}\n' ;; + *'https://custom.example/@voidzero-dev%2Fvite-plus-cli-'*) + # Release payloads must pass the real provenance gate before handoff. + printf '{"version":"0.2.9","dist":{"tarball":"https://custom.example/platform.tgz","attestations":{"provenance":{"predicateType":"https://slsa.dev/provenance/v1"}}}}\n' ;; *) cp "$test_root/payload.tgz" "${@: -1}" ;; esac } diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index c593953793..ccb8c58242 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -20,6 +20,8 @@ on: - 'crates/vp_shared/**' - 'crates/vp_pm_cli/**' - 'crates/vp_setup/**' + - '.github/scripts/test-install-bootstrap.sh' + - '.github/scripts/test-install-bootstrap.ps1' - '.github/workflows/test-standalone-install.yml' concurrency: @@ -252,7 +254,12 @@ jobs: port_file="$case_dir/port" log_file="$case_dir/requests.jsonl" rm -rf "$case_dir" - mkdir -p "$case_dir" + mkdir -p "$case_dir/bin" + + # Model a CLI whose setup already completed. Otherwise first-start + # setup creates current before the upgrade rejection is tested. + cp target/debug/vp "$case_dir/bin/vp" + touch "$case_dir/bin/.vp-setup-complete" node packages/cli/tests/fixtures/provenance-registry.mjs \ --port-file "$port_file" \ @@ -288,7 +295,7 @@ jobs: CI=true \ HOME="$home_dir" \ VP_HOME="$vp_home" \ - target/debug/vp upgrade "$TEST_VERSION" --registry "$registry" "$@" 2>&1) + "$case_dir/bin/vp" upgrade "$TEST_VERSION" --registry "$registry" "$@" 2>&1) status=$? set -e printf '%s\n' "$output" From 8f30c2f882112d3a87ed65906b0a7f5af6ab2454 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sun, 13 Sep 2026 21:11:31 +0900 Subject: [PATCH 06/10] ci: retrigger checks for Windows snapshot timeout From ba11d2572c99847a6660db431e3eb9ec6cad7f28 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 22:09:46 +0800 Subject: [PATCH 07/10] fix(install): exempt commit previews from provenance checks --- .github/workflows/test-standalone-install.yml | 44 ++++++++++++++++++- crates/vp_global_cli/src/self_setup.rs | 5 +-- crates/vp_setup/src/lib.rs | 36 ++++++++++++++- crates/vp_setup/src/registry.rs | 37 +++++++++++++++- docs/guide/upgrade.md | 2 + packages/cli/install.ps1 | 4 +- packages/cli/install.sh | 15 ++++--- 7 files changed, 130 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index ccb8c58242..4fb958f9f0 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -229,6 +229,26 @@ jobs: run_case valid-v1 allow run_case valid-v0.2 allow + # Commit previews can omit provenance on any registry. Other + # prereleases and malformed commit versions must still be rejected. + TEST_VERSION=0.0.0-commit.0123456789abcdef0123456789abcdef01234567 + run_case missing allow + run_case unsupported allow + TEST_VERSION=0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567 + run_case missing allow + for TEST_VERSION in \ + 1.2.3-beta.1 \ + 0.0.0 \ + 0.0.0-beta.1 \ + 0.0.0-commit. \ + 0.0.0-commit.abc1234 \ + 0.0.0-commit.0123456789abcdef0123456789abcdef012345678 \ + 0.0.0-commit.0123456789abcdef0123456789abcdef0123456g \ + 0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567 \ + 0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra; do + run_case missing reject + done + test-vp-upgrade-provenance: name: Test vp upgrade (npm provenance) runs-on: ubuntu-latest @@ -1392,6 +1412,27 @@ jobs: Invoke-ProvenanceCase -Mode "valid-v1" -ExpectRejection $false Invoke-ProvenanceCase -Mode "valid-v0.2" -ExpectRejection $false + # Match the version policy in Rust and install.sh on a custom registry. + $env:TEST_VERSION = "0.0.0-commit.0123456789abcdef0123456789abcdef01234567" + Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $false + Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $false + $env:TEST_VERSION = "0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567" + Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $false + foreach ($version in @( + "1.2.3-beta.1", + "0.0.0", + "0.0.0-beta.1", + "0.0.0-commit.", + "0.0.0-commit.abc1234", + "0.0.0-commit.0123456789abcdef0123456789abcdef012345678", + "0.0.0-commit.0123456789abcdef0123456789abcdef0123456g", + "0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567", + "0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra" + )) { + $env:TEST_VERSION = $version + Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true + } + test-install-ps1-release-age: name: Test legacy install.ps1 (minimum-release-age) runs-on: namespace-profile-windows-4c-8g @@ -1718,7 +1759,8 @@ jobs: $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" $binary = "${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }}" - $output = & $binary --yes --no-node-manager --no-modify-path --install-dir $installDir --registry $registry --version $env:TEST_VERSION 2>&1 + $env:VP_HOME = $installDir + $output = & $binary --yes --no-node-manager --no-modify-path --registry $registry --version $env:TEST_VERSION 2>&1 $exitCode = $LASTEXITCODE $text = $output -join "`n" } finally { diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index 050d5655e7..48e45c215b 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -7,7 +7,7 @@ use std::{path::Path, process::ExitCode}; use dialoguer::{Confirm, theme::ColorfulTheme}; use vp_pm_cli::PackageManagerType; -use vp_setup::{SELF_SETUP_MARKER, VP_BINARY_NAME, install}; +use vp_setup::{SELF_SETUP_MARKER, VP_BINARY_NAME, install, is_commit_preview_version}; use vp_shared::{EnvConfig, env_vars, output}; use vt_path::{AbsolutePath, AbsolutePathBuf}; @@ -187,8 +187,7 @@ async fn run(source: &Path, bundled: bool) -> Result { .ok() .filter(|value| !value.is_empty()) .or_else(|| { - version - .starts_with("0.0.0-commit.") + is_commit_preview_version(version) .then(|| "https://registry-bridge.viteplus.dev/".to_string()) }); let registry = registry.as_deref(); diff --git a/crates/vp_setup/src/lib.rs b/crates/vp_setup/src/lib.rs index e9fe1deffc..a940e5eae7 100644 --- a/crates/vp_setup/src/lib.rs +++ b/crates/vp_setup/src/lib.rs @@ -29,6 +29,17 @@ pub const SELF_SETUP_MARKER: &str = ".vp-setup-complete"; pub use vp_shared::VP_BINARY_NAME; +/// Return `true` for a canonical `0.0.0-commit.` preview version. +/// +/// The commit SHA must contain exactly 40 hexadecimal characters. Other +/// prereleases and abbreviated commit versions do not qualify. +#[must_use] +pub fn is_commit_preview_version(version: &str) -> bool { + version + .strip_prefix("0.0.0-commit.") + .is_some_and(|sha| sha.len() == 40 && sha.bytes().all(|byte| byte.is_ascii_hexdigit())) +} + /// Return `true` if `version` supports the split directory layout. /// /// Vite+ 0.3.0 and later versions support this layout. This includes @@ -50,7 +61,30 @@ pub fn supports_split_layout(version: &str) -> bool { #[cfg(test)] mod tests { - use super::supports_split_layout; + use super::{is_commit_preview_version, supports_split_layout}; + + #[test] + fn commit_preview_versions() { + let cases = [ + ("0.0.0-commit.0123456789abcdef0123456789abcdef01234567", true), + ("0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567", true), + ("1.2.3", false), + ("1.2.3-beta.1", false), + ("0.0.0", false), + ("0.0.0-beta.1", false), + ("0.0.0-pr.1891", false), + ("0.0.0-commit.", false), + ("0.0.0-commit.abc1234", false), + ("0.0.0-commit.0123456789abcdef0123456789abcdef012345678", false), + ("0.0.0-commit.0123456789abcdef0123456789abcdef0123456g", false), + ("0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567", false), + ("0.0.0-commit.0123456789abcdef0123456789abcdef01234567\n", false), + ("0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra", false), + ]; + for (version, expected) in cases { + assert_eq!(is_commit_preview_version(version), expected, "version: {version:?}"); + } + } #[test] fn split_layout_support_by_version() { diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index e98dd64ff0..d3d5ba577c 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use vp_pm_cli::{HttpClient, npm_registry}; -use crate::error::Error; +use crate::{error::Error, is_commit_preview_version}; /// npm package version metadata (subset of fields we need). #[derive(Debug, Deserialize)] @@ -57,6 +57,11 @@ fn validate_platform_package_provenance( version: &str, dist: &DistInfo, ) -> Result<(), Error> { + // Commit preview builds do not carry npm provenance, regardless of registry. + if is_commit_preview_version(version) { + return Ok(()); + } + let predicate_type = dist .attestations .as_ref() @@ -311,6 +316,36 @@ mod tests { assert_eq!(resolved.platform_integrity, "sha512-test"); } + #[tokio::test] + async fn test_resolve_preview_without_provenance_from_custom_registry() { + let version = "0.0.0-commit.0123456789abcdef0123456789abcdef01234567"; + let server = MockServer::start(); + let metadata = serde_json::json!({ + "version": version, + "dist": { + "tarball": format!("{}/platform.tgz", server.base_url()), + "integrity": "sha512-preview", + }, + }); + let main_mock = server.mock(|when, then| { + when.method(GET).path("/vite-plus/preview"); + then.status(200).json_body(metadata.clone()); + }); + let platform_mock = server.mock(|when, then| { + when.method(GET).path(format!("/{TEST_PACKAGE_NAME}/{version}")); + then.status(200).json_body(metadata); + }); + + let resolved = + resolve_version("preview", "darwin-arm64", Some(&server.base_url())).await.unwrap(); + + main_mock.assert(); + platform_mock.assert(); + assert_eq!(resolved.version, version); + assert_eq!(resolved.platform_tarball_url, format!("{}/platform.tgz", server.base_url())); + assert_eq!(resolved.platform_integrity, "sha512-preview"); + } + #[tokio::test] async fn test_resolve_platform_package_rejects_before_returning_distribution() { let server = MockServer::start(); diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index a838d6732b..c08a11fdf0 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -99,6 +99,8 @@ Each commit on an eligible pull request is published to the [registry bridge](ht Both `vite-plus` and `@voidzero-dev/vite-plus-core` publish under the same `0.0.0-commit.` version. Each pull request carries a comment listing the exact version for its latest commit, along with ready-to-copy install steps. +Installers and `vp upgrade` skip the npm provenance requirement for versions that match `0.0.0-commit.`, where `` is a full 40-character hexadecimal commit SHA. This exception applies to any registry. Other versions still require supported npm provenance metadata. + You can find preview builds in pull requests that automatically update upstream dependencies. For examples, search the merged pull requests for [upstream dependency updates](https://github.com/voidzero-dev/vite-plus/pulls?q=is%3Apr+is%3Amerged+upgrade+upstream+dependencies). Preview builds are addressed by pull request number or commit SHA. They are not a stable version range, and you should avoid leaving them in long-lived branches unless a maintainer asks you to. diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 256ab152ea..dae714f94c 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -236,7 +236,9 @@ function Get-VerifiedPlatformTarballUrl { # npm provenance. Check the typed object path so package-defined top-level # fields cannot satisfy the gate, and deny unknown predicates before download. $predicateType = $Metadata.dist.attestations.provenance.predicateType - if (-not $predicateType -or $SupportedProvenancePredicateTypes -notcontains $predicateType) { + # Commit preview builds do not carry npm provenance, regardless of registry. + if ($Version -cnotmatch '\A0\.0\.0-commit\.[0-9a-fA-F]{40}\z' -and + (-not $predicateType -or $SupportedProvenancePredicateTypes -notcontains $predicateType)) { Write-Error-Exit "Refusing to install ${PackageName}@${Version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." } diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5ff344586b..1cf9e498be 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -501,12 +501,15 @@ resolve_platform_distribution() { error "Failed to fetch CLI package metadata '${package_name}@${package_version}': ${registry_error}\n URL: $metadata_url" fi - case "$predicate_type" in - https://slsa.dev/provenance/v1|https://slsa.dev/provenance/v0.2) ;; - *) - error "Refusing to install ${package_name}@${package_version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." - ;; - esac + # Commit preview builds do not carry npm provenance, regardless of registry. + if [[ ! "$package_version" =~ ^0\.0\.0-commit\.[0-9a-fA-F]{40}$ ]]; then + case "$predicate_type" in + https://slsa.dev/provenance/v1|https://slsa.dev/provenance/v0.2) ;; + *) + error "Refusing to install ${package_name}@${package_version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + ;; + esac + fi if [ -z "$PLATFORM_TARBALL_URL" ]; then error "CLI package metadata for ${package_name}@${package_version} does not include dist.tarball\n URL: $metadata_url" From 5b4b21f76d042a924400470f46f9caa3cd56328f Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 22:19:18 +0800 Subject: [PATCH 08/10] test(install): avoid trailing-dot URLs in PowerShell 5.1 --- .github/workflows/test-standalone-install.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 4fb958f9f0..28506cd0cb 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -1422,7 +1422,9 @@ jobs: "1.2.3-beta.1", "0.0.0", "0.0.0-beta.1", - "0.0.0-commit.", + # PowerShell 5.1 strips trailing dots from URL paths. Use a bare + # commit label to test a missing SHA through the registry fixture. + "0.0.0-commit", "0.0.0-commit.abc1234", "0.0.0-commit.0123456789abcdef0123456789abcdef012345678", "0.0.0-commit.0123456789abcdef0123456789abcdef0123456g", From 1b537f8158234c6b667ba6f5e74348e36bdc1146 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 22:25:27 +0800 Subject: [PATCH 09/10] refactor(install): simplify provenance checks and tests --- .github/scripts/test-install-provenance.ps1 | 141 ++++++++++ .github/scripts/test-install-provenance.sh | 106 ++++++++ .github/workflows/test-standalone-install.yml | 248 +----------------- crates/vp_setup/src/registry.rs | 23 +- packages/cli/install.ps1 | 9 +- packages/cli/install.sh | 10 +- packages/tools/src/local-npm-registry.ts | 7 +- 7 files changed, 269 insertions(+), 275 deletions(-) create mode 100644 .github/scripts/test-install-provenance.ps1 create mode 100644 .github/scripts/test-install-provenance.sh diff --git a/.github/scripts/test-install-provenance.ps1 b/.github/scripts/test-install-provenance.ps1 new file mode 100644 index 0000000000..2294d00c8f --- /dev/null +++ b/.github/scripts/test-install-provenance.ps1 @@ -0,0 +1,141 @@ +# Run from the repository root with RUNNER_TEMP and TEST_VERSION set. +$ErrorActionPreference = "Stop" + +function Invoke-ProvenanceCase { + param( + [string]$Mode, + [bool]$ExpectRejection, + [bool]$RawContentType = $false + ) + + $caseDir = Join-Path $env:RUNNER_TEMP "vite-plus-provenance-ps1-$Mode" + $homeDir = Join-Path $caseDir "home" + $vpHome = Join-Path $caseDir "vp-home" + $portFile = Join-Path $caseDir "port" + $logFile = Join-Path $caseDir "requests.jsonl" + $stdoutFile = Join-Path $caseDir "registry.stdout.log" + $stderrFile = Join-Path $caseDir "registry.stderr.log" + $installerStdoutFile = Join-Path $caseDir "installer.stdout.log" + $installerStderrFile = Join-Path $caseDir "installer.stderr.log" + + Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $homeDir, $vpHome -Force | Out-Null + + $fixture = Join-Path (Get-Location) "packages/cli/tests/fixtures/provenance-registry.mjs" + $serverArgs = @( + $fixture, + "--port-file", $portFile, + "--log-file", $logFile, + "--mode", $Mode, + "--version", $env:TEST_VERSION + ) + if ($RawContentType) { + $serverArgs += @("--raw-content-type", "true") + } + + $server = Start-Process -FilePath "node" -ArgumentList $serverArgs -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + + try { + for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path $portFile); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $portFile)) { + throw "Mock registry did not start: $(Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue)" + } + + $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" + $env:CI = "true" + $env:USERPROFILE = $homeDir + $env:VP_HOME = $vpHome + $env:VP_NODE_MANAGER = "no" + $env:VP_VERSION = $env:TEST_VERSION + $env:NPM_CONFIG_REGISTRY = $registry + + # Windows PowerShell 5.1 turns redirected native stderr into a + # NativeCommandError. Capture each stream separately so the + # expected tarball failure cannot stop this parent test script. + $installerArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", ".\packages\cli\install.ps1" + ) + $installer = Start-Process -FilePath "powershell.exe" -ArgumentList $installerArgs -PassThru -Wait -RedirectStandardOutput $installerStdoutFile -RedirectStandardError $installerStderrFile + $exitCode = $installer.ExitCode + $text = @( + Get-Content -Path $installerStdoutFile -Raw -ErrorAction SilentlyContinue + Get-Content -Path $installerStderrFile -Raw -ErrorAction SilentlyContinue + ) -join "`n" + } finally { + if (-not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit() + } + } + + Write-Host $text + if ($exitCode -eq 0) { + throw "Expected the fixture tarball endpoint to prevent installation" + } + + $requests = Get-Content -Path $logFile -Raw + $tarballRequested = $requests.Contains('"path":"/platform.tgz"') + $provenanceError = "does not contain supported npm provenance metadata" + + if ($ExpectRejection) { + if (-not $text.Contains($provenanceError)) { + throw "Expected provenance rejection for $Mode" + } + if (-not $text.Contains("@voidzero-dev/vite-plus-cli-") -or + -not $text.Contains($env:TEST_VERSION)) { + throw "Expected rejected package name and version in installer output" + } + if ($tarballRequested) { + throw "Platform tarball was requested before provenance validation" + } + if ((Test-Path (Join-Path $vpHome "current")) -or + (Test-Path (Join-Path $vpHome "$($env:TEST_VERSION)\bin\vp.exe"))) { + throw "Rejected package left an active or executable installation" + } + } else { + if ($text.Contains($provenanceError)) { + throw "Supported provenance metadata was rejected" + } + if (-not $tarballRequested) { + throw "Supported provenance metadata did not reach the tarball endpoint" + } + } + + # The child installer and the deliberate tarball failure are expected. + $global:LASTEXITCODE = 0 +} + +Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true +Invoke-ProvenanceCase -Mode "malformed" -ExpectRejection $true +Invoke-ProvenanceCase -Mode "top-level-only" -ExpectRejection $true -RawContentType $true +Invoke-ProvenanceCase -Mode "dotted-top-level-key" -ExpectRejection $true +Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $true +Invoke-ProvenanceCase -Mode "valid-v1" -ExpectRejection $false +Invoke-ProvenanceCase -Mode "valid-v0.2" -ExpectRejection $false + +# Match the version policy in Rust and install.sh on a custom registry. +$env:TEST_VERSION = "0.0.0-commit.0123456789abcdef0123456789abcdef01234567" +Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $false +Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $false +$env:TEST_VERSION = "0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567" +Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $false +foreach ($version in @( + "1.2.3-beta.1", + "0.0.0", + "0.0.0-beta.1", + # PowerShell 5.1 strips trailing dots from URL paths. Use a bare + # commit label to test a missing SHA through the registry fixture. + "0.0.0-commit", + "0.0.0-commit.abc1234", + "0.0.0-commit.0123456789abcdef0123456789abcdef012345678", + "0.0.0-commit.0123456789abcdef0123456789abcdef0123456g", + "0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567", + "0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra" +)) { + $env:TEST_VERSION = $version + Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true +} diff --git a/.github/scripts/test-install-provenance.sh b/.github/scripts/test-install-provenance.sh new file mode 100644 index 0000000000..4e4de7f189 --- /dev/null +++ b/.github/scripts/test-install-provenance.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Run from the repository root with RUNNER_TEMP and TEST_VERSION set. +set -eo pipefail + +run_case() { + mode="$1" + expected="$2" + case_dir="$RUNNER_TEMP/vite-plus-provenance-sh-$mode" + port_file="$case_dir/port" + log_file="$case_dir/requests.jsonl" + vp_home="$case_dir/vp-home" + + rm -rf "$case_dir" + mkdir -p "$case_dir/home" "$vp_home" + node packages/cli/tests/fixtures/provenance-registry.mjs \ + --port-file "$port_file" \ + --log-file "$log_file" \ + --mode "$mode" \ + --version "$TEST_VERSION" & + server_pid=$! + + for _ in $(seq 1 100); do + [ -s "$port_file" ] && break + sleep 0.1 + done + if [ ! -s "$port_file" ]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + echo "Mock registry did not start" + return 1 + fi + + registry="http://127.0.0.1:$(cat "$port_file")" + set +e + output=$(env \ + CI=true \ + HOME="$case_dir/home" \ + VP_HOME="$vp_home" \ + VP_NODE_MANAGER=no \ + VP_VERSION="$TEST_VERSION" \ + NPM_CONFIG_REGISTRY="$registry" \ + bash packages/cli/install.sh 2>&1) + status=$? + set -e + + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + printf '%s\n' "$output" + + if [ "$status" -eq 0 ]; then + echo "Expected the fixture tarball endpoint to prevent installation" + return 1 + fi + + if [ "$expected" = reject ]; then + printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata" + printf '%s\n' "$output" | grep -F \ + "@voidzero-dev/vite-plus-cli-" + printf '%s\n' "$output" | grep -F "$TEST_VERSION" + + if grep -F '"path":"/platform.tgz"' "$log_file"; then + echo "Platform tarball was requested before provenance validation" + return 1 + fi + if [ -e "$vp_home/current" ] || [ -e "$vp_home/$TEST_VERSION/bin/vp" ]; then + echo "Rejected package left an active or executable installation" + return 1 + fi + else + if printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata"; then + echo "Supported provenance metadata was rejected" + return 1 + fi + grep -F '"path":"/platform.tgz"' "$log_file" + fi +} + +run_case missing reject +run_case malformed reject +run_case top-level-only reject +run_case dotted-top-level-key reject +run_case unsupported reject +run_case valid-v1 allow +run_case valid-v0.2 allow + +# Commit previews can omit provenance on any registry. Other +# prereleases and malformed commit versions must still be rejected. +TEST_VERSION=0.0.0-commit.0123456789abcdef0123456789abcdef01234567 +run_case missing allow +run_case unsupported allow +TEST_VERSION=0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567 +run_case missing allow +for TEST_VERSION in \ + 1.2.3-beta.1 \ + 0.0.0 \ + 0.0.0-beta.1 \ + 0.0.0-commit. \ + 0.0.0-commit.abc1234 \ + 0.0.0-commit.0123456789abcdef0123456789abcdef012345678 \ + 0.0.0-commit.0123456789abcdef0123456789abcdef0123456g \ + 0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567 \ + 0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra; do + run_case missing reject +done diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 28506cd0cb..985c28d878 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -22,6 +22,8 @@ on: - 'crates/vp_setup/**' - '.github/scripts/test-install-bootstrap.sh' - '.github/scripts/test-install-bootstrap.ps1' + - '.github/scripts/test-install-provenance.sh' + - '.github/scripts/test-install-provenance.ps1' - '.github/workflows/test-standalone-install.yml' concurrency: @@ -145,109 +147,7 @@ jobs: - name: Verify platform package provenance before download env: TEST_VERSION: 9.9.9-provenance-test.1 - run: | - run_case() { - mode="$1" - expected="$2" - case_dir="$RUNNER_TEMP/vite-plus-provenance-sh-$mode" - port_file="$case_dir/port" - log_file="$case_dir/requests.jsonl" - vp_home="$case_dir/vp-home" - - rm -rf "$case_dir" - mkdir -p "$case_dir/home" "$vp_home" - node packages/cli/tests/fixtures/provenance-registry.mjs \ - --port-file "$port_file" \ - --log-file "$log_file" \ - --mode "$mode" \ - --version "$TEST_VERSION" & - server_pid=$! - - for _ in $(seq 1 100); do - [ -s "$port_file" ] && break - sleep 0.1 - done - if [ ! -s "$port_file" ]; then - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - echo "Mock registry did not start" - return 1 - fi - - registry="http://127.0.0.1:$(cat "$port_file")" - set +e - output=$(env \ - CI=true \ - HOME="$case_dir/home" \ - VP_HOME="$vp_home" \ - VP_NODE_MANAGER=no \ - VP_VERSION="$TEST_VERSION" \ - NPM_CONFIG_REGISTRY="$registry" \ - bash packages/cli/install.sh 2>&1) - status=$? - set -e - - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - printf '%s\n' "$output" - - if [ "$status" -eq 0 ]; then - echo "Expected the fixture tarball endpoint to prevent installation" - return 1 - fi - - if [ "$expected" = reject ]; then - printf '%s\n' "$output" | grep -F \ - "does not contain supported npm provenance metadata" - printf '%s\n' "$output" | grep -F \ - "@voidzero-dev/vite-plus-cli-" - printf '%s\n' "$output" | grep -F "$TEST_VERSION" - - if grep -F '"path":"/platform.tgz"' "$log_file"; then - echo "Platform tarball was requested before provenance validation" - return 1 - fi - if [ -e "$vp_home/current" ] || [ -e "$vp_home/$TEST_VERSION/bin/vp" ]; then - echo "Rejected package left an active or executable installation" - return 1 - fi - else - if printf '%s\n' "$output" | grep -F \ - "does not contain supported npm provenance metadata"; then - echo "Supported provenance metadata was rejected" - return 1 - fi - grep -F '"path":"/platform.tgz"' "$log_file" - fi - } - - run_case missing reject - run_case malformed reject - run_case top-level-only reject - run_case dotted-top-level-key reject - run_case unsupported reject - run_case valid-v1 allow - run_case valid-v0.2 allow - - # Commit previews can omit provenance on any registry. Other - # prereleases and malformed commit versions must still be rejected. - TEST_VERSION=0.0.0-commit.0123456789abcdef0123456789abcdef01234567 - run_case missing allow - run_case unsupported allow - TEST_VERSION=0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567 - run_case missing allow - for TEST_VERSION in \ - 1.2.3-beta.1 \ - 0.0.0 \ - 0.0.0-beta.1 \ - 0.0.0-commit. \ - 0.0.0-commit.abc1234 \ - 0.0.0-commit.0123456789abcdef0123456789abcdef012345678 \ - 0.0.0-commit.0123456789abcdef0123456789abcdef0123456g \ - 0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567 \ - 0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra; do - run_case missing reject - done + run: bash .github/scripts/test-install-provenance.sh test-vp-upgrade-provenance: name: Test vp upgrade (npm provenance) @@ -1293,147 +1193,7 @@ jobs: shell: powershell env: TEST_VERSION: 9.9.9-provenance-test.1 - run: | - $ErrorActionPreference = "Stop" - - function Invoke-ProvenanceCase { - param( - [string]$Mode, - [bool]$ExpectRejection, - [bool]$RawContentType = $false - ) - - $caseDir = Join-Path $env:RUNNER_TEMP "vite-plus-provenance-ps1-$Mode" - $homeDir = Join-Path $caseDir "home" - $vpHome = Join-Path $caseDir "vp-home" - $portFile = Join-Path $caseDir "port" - $logFile = Join-Path $caseDir "requests.jsonl" - $stdoutFile = Join-Path $caseDir "registry.stdout.log" - $stderrFile = Join-Path $caseDir "registry.stderr.log" - $installerStdoutFile = Join-Path $caseDir "installer.stdout.log" - $installerStderrFile = Join-Path $caseDir "installer.stderr.log" - - Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path $homeDir, $vpHome -Force | Out-Null - - $fixture = Join-Path (Get-Location) "packages/cli/tests/fixtures/provenance-registry.mjs" - $serverArgs = @( - $fixture, - "--port-file", $portFile, - "--log-file", $logFile, - "--mode", $Mode, - "--version", $env:TEST_VERSION - ) - if ($RawContentType) { - $serverArgs += @("--raw-content-type", "true") - } - - $server = Start-Process -FilePath "node" -ArgumentList $serverArgs -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile - - try { - for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path $portFile); $attempt++) { - Start-Sleep -Milliseconds 100 - } - if (-not (Test-Path $portFile)) { - throw "Mock registry did not start: $(Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue)" - } - - $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" - $env:CI = "true" - $env:USERPROFILE = $homeDir - $env:VP_HOME = $vpHome - $env:VP_NODE_MANAGER = "no" - $env:VP_VERSION = $env:TEST_VERSION - $env:NPM_CONFIG_REGISTRY = $registry - - # Windows PowerShell 5.1 turns redirected native stderr into a - # NativeCommandError. Capture each stream separately so the - # expected tarball failure cannot stop this parent test script. - $installerArgs = @( - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", ".\packages\cli\install.ps1" - ) - $installer = Start-Process -FilePath "powershell.exe" -ArgumentList $installerArgs -PassThru -Wait -RedirectStandardOutput $installerStdoutFile -RedirectStandardError $installerStderrFile - $exitCode = $installer.ExitCode - $text = @( - Get-Content -Path $installerStdoutFile -Raw -ErrorAction SilentlyContinue - Get-Content -Path $installerStderrFile -Raw -ErrorAction SilentlyContinue - ) -join "`n" - } finally { - if (-not $server.HasExited) { - Stop-Process -Id $server.Id -Force - $server.WaitForExit() - } - } - - Write-Host $text - if ($exitCode -eq 0) { - throw "Expected the fixture tarball endpoint to prevent installation" - } - - $requests = Get-Content -Path $logFile -Raw - $tarballRequested = $requests.Contains('"path":"/platform.tgz"') - $provenanceError = "does not contain supported npm provenance metadata" - - if ($ExpectRejection) { - if (-not $text.Contains($provenanceError)) { - throw "Expected provenance rejection for $Mode" - } - if (-not $text.Contains("@voidzero-dev/vite-plus-cli-") -or - -not $text.Contains($env:TEST_VERSION)) { - throw "Expected rejected package name and version in installer output" - } - if ($tarballRequested) { - throw "Platform tarball was requested before provenance validation" - } - if ((Test-Path (Join-Path $vpHome "current")) -or - (Test-Path (Join-Path $vpHome "$($env:TEST_VERSION)\bin\vp.exe"))) { - throw "Rejected package left an active or executable installation" - } - } else { - if ($text.Contains($provenanceError)) { - throw "Supported provenance metadata was rejected" - } - if (-not $tarballRequested) { - throw "Supported provenance metadata did not reach the tarball endpoint" - } - } - - # The child installer and the deliberate tarball failure are expected. - $global:LASTEXITCODE = 0 - } - - Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true - Invoke-ProvenanceCase -Mode "malformed" -ExpectRejection $true - Invoke-ProvenanceCase -Mode "top-level-only" -ExpectRejection $true -RawContentType $true - Invoke-ProvenanceCase -Mode "dotted-top-level-key" -ExpectRejection $true - Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $true - Invoke-ProvenanceCase -Mode "valid-v1" -ExpectRejection $false - Invoke-ProvenanceCase -Mode "valid-v0.2" -ExpectRejection $false - - # Match the version policy in Rust and install.sh on a custom registry. - $env:TEST_VERSION = "0.0.0-commit.0123456789abcdef0123456789abcdef01234567" - Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $false - Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $false - $env:TEST_VERSION = "0.0.0-commit.0123456789ABCDEF0123456789ABCDEF01234567" - Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $false - foreach ($version in @( - "1.2.3-beta.1", - "0.0.0", - "0.0.0-beta.1", - # PowerShell 5.1 strips trailing dots from URL paths. Use a bare - # commit label to test a missing SHA through the registry fixture. - "0.0.0-commit", - "0.0.0-commit.abc1234", - "0.0.0-commit.0123456789abcdef0123456789abcdef012345678", - "0.0.0-commit.0123456789abcdef0123456789abcdef0123456g", - "0.0.0-COMMIT.0123456789abcdef0123456789abcdef01234567", - "0.0.0-commit.0123456789abcdef0123456789abcdef01234567.extra" - )) { - $env:TEST_VERSION = $version - Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true - } + run: ./.github/scripts/test-install-provenance.ps1 test-install-ps1-release-age: name: Test legacy install.ps1 (minimum-release-age) diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index d3d5ba577c..5c6d5e8a32 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -20,21 +20,19 @@ pub struct PackageVersionMetadata { pub struct DistInfo { pub tarball: String, pub integrity: String, - #[serde(default)] pub attestations: Option, } /// npm attestations attached to a package version. #[derive(Debug, Deserialize)] pub struct NpmAttestations { - #[serde(default)] pub provenance: Option, } /// npm provenance metadata used to identify the attestation predicate. #[derive(Debug, Deserialize)] pub struct NpmProvenance { - #[serde(rename = "predicateType", default)] + #[serde(rename = "predicateType")] pub predicate_type: Option, } @@ -66,8 +64,7 @@ fn validate_platform_package_provenance( .attestations .as_ref() .and_then(|attestations| attestations.provenance.as_ref()) - .and_then(|provenance| provenance.predicate_type.as_deref()) - .filter(|predicate_type| !predicate_type.is_empty()); + .and_then(|provenance| provenance.predicate_type.as_deref()); if predicate_type.is_some_and(|predicate_type| { SUPPORTED_PROVENANCE_PREDICATE_TYPES.contains(&predicate_type) @@ -133,9 +130,7 @@ pub async fn resolve_platform_package( ) })?; - // npm registry signatures only prove that registry metadata was signed. The - // provenance object separately binds the package to its supported build - // attestation, so reject before exposing the tarball URL to any caller. + // Check release provenance metadata before returning the tarball URL. validate_platform_package_provenance(&cli_package_name, version, &cli_meta.dist)?; Ok(ResolvedVersion { @@ -176,7 +171,7 @@ mod tests { .unwrap() } - fn dist_with_provenance(predicate_type: serde_json::Value) -> serde_json::Value { + fn dist_with_provenance(predicate_type: &str) -> serde_json::Value { serde_json::json!({ "tarball": "https://registry.example.test/platform.tgz", "integrity": "sha512-test", @@ -199,7 +194,7 @@ mod tests { #[test] fn test_platform_package_accepts_supported_provenance_predicates() { for predicate_type in SUPPORTED_PROVENANCE_PREDICATE_TYPES { - let metadata = parse_metadata(dist_with_provenance(predicate_type.into())); + let metadata = parse_metadata(dist_with_provenance(predicate_type)); assert!( validate_platform_package_provenance( TEST_PACKAGE_NAME, @@ -229,9 +224,9 @@ mod tests { "integrity": "sha512-test", "attestations": { "provenance": {} }, }), - dist_with_provenance("".into()), - dist_with_provenance(" https://slsa.dev/provenance/v1 ".into()), - dist_with_provenance("https://example.test/unknown-provenance/v1".into()), + dist_with_provenance(""), + dist_with_provenance(" https://slsa.dev/provenance/v1 "), + dist_with_provenance("https://example.test/unknown-provenance/v1"), serde_json::json!({ "tarball": "https://registry.example.test/platform.tgz", "integrity": "sha512-test", @@ -301,7 +296,7 @@ mod tests { when.method(GET).path("/@voidzero-dev/vite-plus-cli-darwin-arm64/1.2.3"); then.status(200).json_body(serde_json::json!({ "version": TEST_VERSION, - "dist": dist_with_provenance("https://slsa.dev/provenance/v1".into()), + "dist": dist_with_provenance("https://slsa.dev/provenance/v1"), })); }); diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index dae714f94c..7fb7ee5475 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -232,13 +232,12 @@ function Get-VerifiedPlatformTarballUrl { [string]$Version ) - # Registry signatures and trusted-publisher labels are not substitutes for - # npm provenance. Check the typed object path so package-defined top-level - # fields cannot satisfy the gate, and deny unknown predicates before download. + # Check nested provenance metadata; registry signatures and package-defined + # top-level fields do not satisfy the release check. $predicateType = $Metadata.dist.attestations.provenance.predicateType # Commit preview builds do not carry npm provenance, regardless of registry. - if ($Version -cnotmatch '\A0\.0\.0-commit\.[0-9a-fA-F]{40}\z' -and - (-not $predicateType -or $SupportedProvenancePredicateTypes -notcontains $predicateType)) { + $isCommitPreview = $Version -cmatch '\A0\.0\.0-commit\.[0-9a-fA-F]{40}\z' + if (-not $isCommitPreview -and $SupportedProvenancePredicateTypes -notcontains $predicateType) { Write-Error-Exit "Refusing to install ${PackageName}@${Version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." } diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 1cf9e498be..2af45208ce 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -47,6 +47,7 @@ RED='\033[0;31m' BLUE='\033[0;34m' NC='\033[0m' PACKAGE_METADATA="" +PLATFORM_TARBALL_URL="" # Legacy is published beside this bootstrap; preview builds rewrite this origin. LEGACY_INSTALLER_URL="${VP_LEGACY_INSTALLER_URL:-https://viteplus.dev/install-legacy.sh}" INSTALLER_PATH="${BASH_SOURCE[0]:-}" @@ -221,8 +222,6 @@ check_requirements() { # Fetch package metadata from npm registry (cached for reuse) # Uses VP_VERSION to fetch the correct version's metadata -PACKAGE_METADATA="" -PLATFORM_TARBALL_URL="" fetch_package_metadata() { if [ -z "$PACKAGE_METADATA" ]; then local version_path metadata_url @@ -472,11 +471,8 @@ parse_platform_distribution_metadata() { ' } -# Fetch exact platform package metadata and admit only npm provenance predicate -# types supported by Vite+. `dist.signatures` is deliberately insufficient: it -# authenticates registry metadata, while provenance binds this release binary -# to the build that produced it. Any missing or unrecognized evidence is denied -# before the tarball URL is used. +# Resolve the exact platform tarball and check release provenance before download. +# Registry signatures alone do not satisfy this check. resolve_platform_distribution() { local package_name="$1" local package_version="$2" diff --git a/packages/tools/src/local-npm-registry.ts b/packages/tools/src/local-npm-registry.ts index 2e94694265..209a2ba0d2 100644 --- a/packages/tools/src/local-npm-registry.ts +++ b/packages/tools/src/local-npm-registry.ts @@ -288,11 +288,8 @@ if (packagesDir) { // that verifies it (npm, pnpm, yarn, bun) gets a match. integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`, shasum: createHash('sha1').update(bytes).digest('hex'), - // npm provenance is registry metadata, not part of the packed - // package.json. Published Vite+ platform packages carry this - // attestation, so synthesize it for their local test tarballs. - // Other local packages remain unchanged, allowing provenance - // rejection tests to serve packages without an attestation. + // Supply the provenance metadata that npm adds to published platform + // packages so local test tarballs pass the installer check. ...(pkg.name.startsWith(VITE_PLUS_PLATFORM_PACKAGE_PREFIX) && { attestations: { provenance: { From 72b4732adadea4f68ae4d26b32c644cf772c59d1 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 18 Sep 2026 22:30:01 +0800 Subject: [PATCH 10/10] docs: omit preview provenance implementation details --- docs/guide/upgrade.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/guide/upgrade.md b/docs/guide/upgrade.md index c08a11fdf0..a838d6732b 100644 --- a/docs/guide/upgrade.md +++ b/docs/guide/upgrade.md @@ -99,8 +99,6 @@ Each commit on an eligible pull request is published to the [registry bridge](ht Both `vite-plus` and `@voidzero-dev/vite-plus-core` publish under the same `0.0.0-commit.` version. Each pull request carries a comment listing the exact version for its latest commit, along with ready-to-copy install steps. -Installers and `vp upgrade` skip the npm provenance requirement for versions that match `0.0.0-commit.`, where `` is a full 40-character hexadecimal commit SHA. This exception applies to any registry. Other versions still require supported npm provenance metadata. - You can find preview builds in pull requests that automatically update upstream dependencies. For examples, search the merged pull requests for [upstream dependency updates](https://github.com/voidzero-dev/vite-plus/pulls?q=is%3Apr+is%3Amerged+upgrade+upstream+dependencies). Preview builds are addressed by pull request number or commit SHA. They are not a stable version range, and you should avoid leaving them in long-lived branches unless a maintainer asks you to.