@@ -403,7 +403,10 @@ async fn scan_output(root: &Path, server: &MockServer, extra: &[&str]) -> std::p |
403 | 403 | ] { |
404 | 404 | cmd.env_remove(key); |
405 | 405 | } |
| 406 | + // Hermetic against the runner's own Python user site, which a |
| 407 | + // `--system-site-packages` venv reaches (#409). |
406 | 408 | cmd.env("SOCKET_TELEMETRY_DISABLED", "1") |
| 409 | + .env("PYTHONNOUSERSITE", "1") |
407 | 410 | .args(["scan", "--mode", "hosted", "--yes", "--cwd"]) |
408 | 411 | .arg(root) |
409 | 412 | .args([ |
@@ -490,6 +493,84 @@ async fn stale_python_install_warns_and_cannot_attest_even_on_rescan() { |
490 | 493 | } |
491 | 494 | } |
492 | 495 |
|
| 496 | +/// A `.venv` created with `--system-site-packages` whose base interpreter |
| 497 | +/// lives at `root/base`; `bytes` is installed in the BASE interpreter only, |
| 498 | +/// which is where pip leaves a release it finds already satisfied there. |
| 499 | +/// Returns the base copy's file. |
| 500 | +fn install_behind_system_site_venv(root: &Path, bytes: &[u8]) -> std::path::PathBuf { |
| 501 | + let base = root.join("base"); |
| 502 | + let (home, stdlib) = if cfg!(windows) { |
| 503 | + (base.clone(), base.join("Lib")) |
| 504 | + } else { |
| 505 | + (base.join("bin"), base.join("lib").join("python3.12")) |
| 506 | + }; |
| 507 | + std::fs::create_dir_all(&home).unwrap(); |
| 508 | + std::fs::create_dir_all(&stdlib).unwrap(); |
| 509 | + std::fs::write(stdlib.join("os.py"), "").unwrap(); |
| 510 | + let file = install_package(root, "base", bytes); |
| 511 | + // The project venv (`write_project` made its empty site-packages) |
| 512 | + // reaches the base interpreter. |
| 513 | + std::fs::write( |
| 514 | + root.join(".venv").join("pyvenv.cfg"), |
| 515 | + format!( |
| 516 | + "home = {}\ninclude-system-site-packages = true\nversion = 3.12.4\n", |
| 517 | + home.display() |
| 518 | + ), |
| 519 | + ) |
| 520 | + .unwrap(); |
| 521 | + file |
| 522 | +} |
| 523 | + |
| 524 | +/// #409: pip leaves a release the base interpreter already has where it |
| 525 | +/// is, so behind a `--system-site-packages` venv the unpatched base copy is |
| 526 | +/// the one the build imports. It must get the stale-install warning, and |
| 527 | +/// `--vex` must not attest from the lock pin as if nothing were installed. |
| 528 | +#[tokio::test] |
| 529 | +async fn stale_base_copy_behind_system_site_venv_warns_and_cannot_attest() { |
| 530 | + let server = MockServer::start().await; |
| 531 | + mock_api(&server).await; |
| 532 | + let tmp = tempfile::tempdir().unwrap(); |
| 533 | + write_project(tmp.path()); |
| 534 | + let installed = install_behind_system_site_venv(tmp.path(), UPSTREAM); |
| 535 | + let vex = tmp.path().join("out.vex.json"); |
| 536 | + let out = scan_output( |
| 537 | + tmp.path(), |
| 538 | + &server, |
| 539 | + &["--json", "--vex", vex.to_str().unwrap()], |
| 540 | + ) |
| 541 | + .await; |
| 542 | + let json = envelope(&out); |
| 543 | + assert_eq!(out.status.code(), Some(1), "{json}"); |
| 544 | + assert!(stale_warning(&json), "{json}"); |
| 545 | + assert_eq!(json["error"]["code"], "no_applicable_patches", "{json}"); |
| 546 | + assert!(!vex.exists(), "stale base bytes cannot produce a VEX file"); |
| 547 | + assert_eq!(std::fs::read(installed).unwrap(), UPSTREAM); |
| 548 | +} |
| 549 | + |
| 550 | +/// #409: once the venv holds its own patched copy (`pip install |
| 551 | +/// --force-reinstall` leaves the base copy in place), the base copy is |
| 552 | +/// shadowed: no stale warning, and the venv's verified bytes attest. |
| 553 | +#[tokio::test] |
| 554 | +async fn venv_copy_shadows_stale_base_copy_behind_system_site_venv() { |
| 555 | + let server = MockServer::start().await; |
| 556 | + mock_api(&server).await; |
| 557 | + let tmp = tempfile::tempdir().unwrap(); |
| 558 | + write_project(tmp.path()); |
| 559 | + install_behind_system_site_venv(tmp.path(), UPSTREAM); |
| 560 | + install_package(tmp.path(), ".venv", PATCHED); |
| 561 | + let vex = tmp.path().join("out.vex.json"); |
| 562 | + let out = scan_output( |
| 563 | + tmp.path(), |
| 564 | + &server, |
| 565 | + &["--json", "--vex", vex.to_str().unwrap()], |
| 566 | + ) |
| 567 | + .await; |
| 568 | + let json = envelope(&out); |
| 569 | + assert!(out.status.success(), "{json}"); |
| 570 | + assert!(!stale_warning(&json), "{json}"); |
| 571 | + assert_eq!(json["vex"]["statements"], 1, "{json}"); |
| 572 | +} |
| 573 | + |
493 | 574 | #[tokio::test] |
494 | 575 | async fn patched_python_install_attests_without_a_stale_warning() { |
495 | 576 | let server = MockServer::start().await; |
|