diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20af4e2..19d7e18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@8641a17e25bf5b40c118d48fe0f81e8655731839 # 1.85.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: 1.85.0 - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: key: msrv @@ -23,8 +25,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@f133eefe930d61f0d9371efd474daf0125ed3dd1 # 1.95.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: + toolchain: 1.95.0 components: clippy, rustfmt - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 - name: Install screenshot narrative scanner @@ -44,13 +47,19 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@f133eefe930d61f0d9371efd474daf0125ed3dd1 # 1.95.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: + toolchain: 1.95.0 components: clippy - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: key: windows + - name: Create protected Windows test root + run: ./scripts/setup-windows-test-volume.ps1 - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - run: cargo test --test phase4_product f050_default_json_and_private_content_outputs_remain_isolated --locked - run: cargo test --test phase5_scale f055_windows_timestamp_preserving_rewrite_invalidates_store --locked - run: cargo test --test phase5_scale f056_store_is_private_corruption_is_explicit_and_rebuild_is_source_safe --locked + - name: Remove protected Windows test volume + if: always() + run: ./scripts/setup-windows-test-volume.ps1 -Detach diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0ef66e..dde91e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,15 +38,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@8641a17e25bf5b40c118d48fe0f81e8655731839 # 1.85.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master + with: + toolchain: 1.85.0 - run: cargo check --all-targets --locked verify-current: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@f133eefe930d61f0d9371efd474daf0125ed3dd1 # 1.95.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: + toolchain: 1.95.0 components: clippy, rustfmt - name: Install repository verification tools run: | @@ -64,16 +67,22 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@f133eefe930d61f0d9371efd474daf0125ed3dd1 # 1.95.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: + toolchain: 1.95.0 components: clippy - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: key: release-windows + - name: Create protected Windows test root + run: ./scripts/setup-windows-test-volume.ps1 - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - run: cargo test --test phase4_product f050_default_json_and_private_content_outputs_remain_isolated --locked - run: cargo test --test phase5_scale f055_windows_timestamp_preserving_rewrite_invalidates_store --locked - run: cargo test --test phase5_scale f056_store_is_private_corruption_is_explicit_and_rebuild_is_source_safe --locked + - name: Remove protected Windows test volume + if: always() + run: ./scripts/setup-windows-test-volume.ps1 -Detach build: needs: [verify-tag, verify-msrv, verify-current, verify-windows] @@ -96,8 +105,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: dtolnay/rust-toolchain@f133eefe930d61f0d9371efd474daf0125ed3dd1 # 1.95.0 + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master with: + toolchain: 1.95.0 targets: ${{ matrix.target }} - run: cargo build --release --locked --target ${{ matrix.target }} diff --git a/scripts/setup-windows-test-volume.ps1 b/scripts/setup-windows-test-volume.ps1 new file mode 100644 index 0000000..012cde4 --- /dev/null +++ b/scripts/setup-windows-test-volume.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [switch]$Detach +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-DiskPart { + param( + [Parameter(Mandatory)] + [string[]]$Commands + ) + + $scriptPath = Join-Path $env:RUNNER_TEMP ( + "ccwrapped-diskpart-" + [Guid]::NewGuid().ToString("N") + ".txt" + ) + try { + [System.IO.File]::WriteAllLines( + $scriptPath, + $Commands, + [System.Text.Encoding]::ASCII + ) + $output = & diskpart.exe /s $scriptPath + $exitCode = $LASTEXITCODE + $output | Write-Host + if ($exitCode -ne 0) { + throw "diskpart exited with status $exitCode" + } + } + finally { + Remove-Item -LiteralPath $scriptPath -Force -ErrorAction SilentlyContinue + } +} + +if ($Detach) { + $vhdPath = $env:CCWRAPPED_WINDOWS_TEST_VHD + if ([string]::IsNullOrWhiteSpace($vhdPath)) { + return + } + if (Test-Path -LiteralPath $vhdPath) { + Invoke-DiskPart -Commands @( + "select vdisk file=`"$vhdPath`"", + "detach vdisk" + ) + Remove-Item -LiteralPath $vhdPath -Force -ErrorAction SilentlyContinue + } + return +} + +if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP) -or + [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) { + throw "the Windows test volume requires RUNNER_TEMP and GITHUB_ENV" +} + +$usedLetters = @( + Get-PSDrive -PSProvider FileSystem | ForEach-Object { $_.Name } +) +$driveLetter = @("W", "V", "U", "T", "S", "R") | + Where-Object { $usedLetters -notcontains $_ } | + Select-Object -First 1 +if ([string]::IsNullOrWhiteSpace($driveLetter)) { + throw "no drive letter is available for the Windows test volume" +} + +$vhdPath = Join-Path $env:RUNNER_TEMP ( + "ccwrapped-tests-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT.vhdx" +) +"CCWRAPPED_WINDOWS_TEST_VHD=$vhdPath" | Add-Content -Path $env:GITHUB_ENV + +Invoke-DiskPart -Commands @( + "create vdisk file=`"$vhdPath`" maximum=256 type=expandable", + "select vdisk file=`"$vhdPath`"", + "attach vdisk", + "create partition primary", + "format fs=ntfs quick label=CCWRAPPED_TEST", + "assign letter=$driveLetter" +) + +$volumeRoot = "${driveLetter}:\" +if (-not (Test-Path -LiteralPath $volumeRoot)) { + throw "diskpart did not mount the Windows test volume at $volumeRoot" +} + +$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value +$grant = "*" + $currentSid + ":(OI)(CI)F" +& icacls.exe $volumeRoot /inheritance:r /grant:r $grant +if ($LASTEXITCODE -ne 0) { + throw "failed to protect the Windows test volume root" +} + +$testRoot = Join-Path $volumeRoot "fixtures" +New-Item -ItemType Directory -Path $testRoot | Out-Null +"CCWRAPPED_WINDOWS_TEST_ROOT=$testRoot" | Add-Content -Path $env:GITHUB_ENV diff --git a/tests/phase0_contracts.rs b/tests/phase0_contracts.rs index 69b9a4f..e1484d0 100644 --- a/tests/phase0_contracts.rs +++ b/tests/phase0_contracts.rs @@ -117,8 +117,10 @@ fn public_api_baseline_has_signatures_and_traits() { } #[test] -fn compatibility_capture_ci_uses_the_pinned_toolchain() { - assert!(CI.contains("dtolnay/rust-toolchain@f133eefe930d61f0d9371efd474daf0125ed3dd1")); +fn compatibility_capture_ci_separates_the_action_pin_from_toolchain_versions() { + assert!(CI.contains("dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c")); + assert_eq!(CI.matches("toolchain: 1.85.0").count(), 1); + assert_eq!(CI.matches("toolchain: 1.95.0").count(), 2); assert!(CI.contains("cargo test --all-targets")); assert!(!CI.contains("dtolnay/rust-toolchain@stable")); } diff --git a/tests/phase4_product.rs b/tests/phase4_product.rs index 261c472..5f7ee6e 100644 --- a/tests/phase4_product.rs +++ b/tests/phase4_product.rs @@ -26,7 +26,14 @@ impl TestWorkspace { .expect("system clock must be after Unix epoch") .as_nanos(); let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( + #[cfg(windows)] + let base = std::env::var_os("CCWRAPPED_WINDOWS_TEST_ROOT") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .expect("Windows product tests require a test root or USERPROFILE"); + #[cfg(not(windows))] + let base = std::env::temp_dir(); + let root = base.join(format!( "ccwrapped-phase4-{label}-{}-{nonce}-{sequence}", std::process::id() )); @@ -796,17 +803,29 @@ fn f050_default_json_and_private_content_outputs_remain_isolated() { let default_dir = workspace.output_dir("default"); let default = run_ccwrapped_with_data(&default_dir, &source, &[]); - assert!(default.status.success()); + assert!( + default.status.success(), + "default output failed: {}", + String::from_utf8_lossy(&default.stderr) + ); assert!(visible_output_entries(&default_dir).is_empty()); let json_dir = workspace.output_dir("json"); let json = run_ccwrapped_with_data(&json_dir, &source, &["--json"]); - assert!(json.status.success()); + assert!( + json.status.success(), + "JSON output failed: {}", + String::from_utf8_lossy(&json.stderr) + ); assert!(visible_output_entries(&json_dir).is_empty()); let all_dir = workspace.output_dir("all"); let all = run_ccwrapped_with_data(&all_dir, &source, &["--all"]); - assert!(all.status.success()); + assert!( + all.status.success(), + "all-formats output failed: {}", + String::from_utf8_lossy(&all.stderr) + ); assert_eq!( visible_output_entries(&all_dir), vec![ @@ -819,7 +838,11 @@ fn f050_default_json_and_private_content_outputs_remain_isolated() { let archive_dir = workspace.output_dir("archive"); let archive = run_ccwrapped_with_data(&archive_dir, &source, &["--archive"]); - assert!(archive.status.success()); + assert!( + archive.status.success(), + "private archive output failed: {}", + String::from_utf8_lossy(&archive.stderr) + ); assert_eq!( visible_output_entries(&archive_dir), vec!["wrapped-archive".to_string()] diff --git a/tests/phase5_scale.rs b/tests/phase5_scale.rs index 6233df3..5ef19ba 100644 --- a/tests/phase5_scale.rs +++ b/tests/phase5_scale.rs @@ -25,7 +25,14 @@ impl Scratch { .expect("clock follows epoch") .as_nanos(); let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( + #[cfg(windows)] + let base = std::env::var_os("CCWRAPPED_WINDOWS_TEST_ROOT") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .expect("Windows store tests require a test root or USERPROFILE"); + #[cfg(not(windows))] + let base = std::env::temp_dir(); + let path = base.join(format!( "ccwrapped-phase5-{label}-{}-{nonce}-{id}", std::process::id() )); @@ -969,10 +976,15 @@ fn f055_windows_timestamp_preserving_rewrite_invalidates_store() { .modified() .expect("read original Windows last-write time"); let mut replacement = fs::read(&changed).expect("read Windows transcript"); - let token_digit = replacement - .windows(b"\"input_tokens\":1".len()) - .position(|window| window == b"\"input_tokens\":1") - .expect("generated input token") + let accepted_record = replacement + .windows(b"\"requestId\":\"request-00000-00072\"".len()) + .position(|window| window == b"\"requestId\":\"request-00000-00072\"") + .expect("generated accepted request"); + let token_digit = accepted_record + + replacement[accepted_record..] + .windows(b"\"input_tokens\":1".len()) + .position(|window| window == b"\"input_tokens\":1") + .expect("generated input token") + b"\"input_tokens\":".len(); replacement[token_digit] = b'9'; fs::write(&changed, replacement).expect("write equal-length Windows replacement"); @@ -982,6 +994,8 @@ fn f055_windows_timestamp_preserving_rewrite_invalidates_store() { .expect("open Windows replacement for timestamp restore"); file.set_times(std::fs::FileTimes::new().set_modified(original_modified)) .expect("restore Windows last-write time"); + // Windows finalizes file timestamps when the writing handle closes. + drop(file); assert_eq!( fs::metadata(&changed) .expect("read restored Windows metadata") @@ -1000,8 +1014,8 @@ fn f055_windows_timestamp_preserving_rewrite_invalidates_store() { ); assert!(clean.status.success()); assert_ne!( - stored.stdout, first.stdout, - "the rewritten token did not change the stored report" + clean.stdout, first.stdout, + "the completed rewrite did not change the clean report" ); assert_eq!( stored.stdout, clean.stdout,