From f85675fefc33da0fb047773213e4b1bf5211a8df Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 17 Sep 2026 22:00:22 +0200 Subject: [PATCH 1/5] fix(cli): preserve Linux venv lib64 aliases Signed-off-by: Alex Fournier --- .../cli/src/plugins/lifecycle/environment.rs | 99 ++++++++++++++----- crates/cli/src/plugins/lifecycle/mod.rs | 48 +++++++++ .../shared/plugins_lifecycle_tests.rs | 87 ++++++++++++++++ 3 files changed, 208 insertions(+), 26 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 0298f25bf..1e44518e3 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -350,18 +350,33 @@ pub(super) fn environment_tree_digest(environment: &Path) -> Result Result { + environment_tree_digest_with_limits( + environment, + max_entries, + crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES, + ) +} + +fn environment_tree_digest_with_limits( + environment: &Path, + max_entries: usize, + max_bytes: u64, ) -> Result { let mut digest = Sha256::new(); - let mut total = 0_u64; - let mut entries = 0_usize; + let mut budget = EnvironmentDigestBudget { + total_bytes: 0, + entries: 0, + max_entries, + max_bytes, + }; digest_environment_directory( environment, Path::new(""), &mut Vec::new(), &mut digest, - &mut total, - &mut entries, - max_entries, + &mut budget, + true, )?; Ok(digest .finalize() @@ -370,6 +385,13 @@ fn environment_tree_digest_with_limit( .collect()) } +struct EnvironmentDigestBudget { + total_bytes: u64, + entries: usize, + max_entries: usize, + max_bytes: u64, +} + #[cfg(test)] pub(super) fn test_environment_tree_digest_with_entry_limit( environment: &Path, @@ -378,6 +400,15 @@ pub(super) fn test_environment_tree_digest_with_entry_limit( environment_tree_digest_with_limit(environment, max_entries) } +#[cfg(test)] +pub(super) fn test_environment_tree_digest_with_limits( + environment: &Path, + max_entries: usize, + max_bytes: u64, +) -> Result { + environment_tree_digest_with_limits(environment, max_entries, max_bytes) +} + #[cfg(test)] pub(super) fn reset_environment_tree_digest_calls() { ENVIRONMENT_TREE_DIGEST_CALLS.store(0, Ordering::Relaxed); @@ -393,9 +424,8 @@ fn digest_environment_directory( relative_directory: &Path, ancestors: &mut Vec, digest: &mut Sha256, - total: &mut u64, - entries: &mut usize, - max_entries: usize, + budget: &mut EnvironmentDigestBudget, + charge_bytes: bool, ) -> Result<(), String> { if ancestors.len() >= MAX_ENVIRONMENT_DEPTH { return Err(format!( @@ -416,10 +446,11 @@ fn digest_environment_directory( for child in std::fs::read_dir(directory) .map_err(|error| format!("failed to read {}: {error}", directory.display()))? { - *entries = entries.saturating_add(1); - if *entries > max_entries { + budget.entries = budget.entries.saturating_add(1); + if budget.entries > budget.max_entries { return Err(format!( - "managed Python environment exceeds the {max_entries}-entry attestation budget at {}", + "managed Python environment exceeds the {}-entry attestation budget at {}", + budget.max_entries, directory.display() )); } @@ -434,9 +465,8 @@ fn digest_environment_directory( relative_directory, ancestors, digest, - total, - entries, - max_entries, + budget, + charge_bytes, )?; } ancestors.pop(); @@ -448,9 +478,8 @@ fn digest_environment_entry( relative_directory: &Path, ancestors: &mut Vec, digest: &mut Sha256, - total: &mut u64, - entries: &mut usize, - max_entries: usize, + budget: &mut EnvironmentDigestBudget, + charge_bytes: bool, ) -> Result<(), String> { let path = child.path(); let relative = relative_directory.join(child.file_name()); @@ -462,14 +491,16 @@ fn digest_environment_entry( .map_err(|error| format!("failed to inspect {}: {error}", source.display()))?; if metadata.is_dir() { update_tree_digest(digest, b'd', &relative, &[]); + // Linux venvs expose the same installed tree through `lib` and `lib64 -> lib`. + // Keep hashing the alias for digest compatibility, but charge its bytes through `lib` only. + let charge_bytes = charge_bytes && !is_python_lib64_alias(&path, &relative)?; return digest_environment_directory( &source, &relative, ancestors, digest, - total, - entries, - max_entries, + budget, + charge_bytes, ); } if !metadata.is_file() { @@ -482,17 +513,33 @@ fn digest_environment_entry( &source, "managed Python environment file", )?; - *total = total.saturating_add(bytes.len() as u64); - if *total > crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES { - return Err(format!( - "managed Python environment exceeds the {}-byte attestation budget", - crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES - )); + if charge_bytes { + budget.total_bytes = budget.total_bytes.saturating_add(bytes.len() as u64); + if budget.total_bytes > budget.max_bytes { + return Err(format!( + "managed Python environment exceeds the {}-byte attestation budget", + budget.max_bytes + )); + } } update_tree_digest(digest, b'f', &relative, &bytes); Ok(()) } +fn is_python_lib64_alias(path: &Path, relative: &Path) -> Result { + if relative != Path::new("lib64") { + return Ok(false); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|error| format!("failed to inspect {}: {error}", path.display()))?; + if !metadata.file_type().is_symlink() { + return Ok(false); + } + std::fs::read_link(path) + .map(|target| target == Path::new("lib")) + .map_err(|error| format!("failed to read Python venv lib64 symlink: {error}")) +} + fn environment_entry_is_ignored(path: &Path, relative: &Path) -> bool { relative == Path::new(ENVIRONMENT_ATTESTATION_FILE) || path.file_name().and_then(|name| name.to_str()) == Some("__pycache__") diff --git a/crates/cli/src/plugins/lifecycle/mod.rs b/crates/cli/src/plugins/lifecycle/mod.rs index 8d458d9ce..aa4438c9c 100644 --- a/crates/cli/src/plugins/lifecycle/mod.rs +++ b/crates/cli/src/plugins/lifecycle/mod.rs @@ -1131,6 +1131,13 @@ fn copy_snapshot_entry( let resolved_metadata = fs::metadata(&resolved).map_err(|error| CliError::Config(error.to_string()))?; if resolved_metadata.is_dir() { + // Expanding the standard Linux venv alias would duplicate all installed packages in the + // activation snapshot and can exhaust the snapshot byte budget. + if skip_python_cache + && preserve_python_lib64_alias(&source_path, &destination_path, &metadata)? + { + return Ok(()); + } return copy_snapshot_directory_contents( &resolved, &destination_path, @@ -1164,6 +1171,47 @@ fn copy_snapshot_entry( ) } +#[cfg(unix)] +fn preserve_python_lib64_alias( + source: &Path, + destination: &Path, + metadata: &fs::Metadata, +) -> Result { + if !metadata.file_type().is_symlink() + || source.file_name() != Some(std::ffi::OsStr::new("lib64")) + || source + .parent() + .is_none_or(|root| !root.join("pyvenv.cfg").is_file()) + { + return Ok(false); + } + let target = fs::read_link(source).map_err(|error| { + CliError::Config(format!( + "failed to read Python venv lib64 symlink {}: {error}", + source.display() + )) + })?; + if target != Path::new("lib") { + return Ok(false); + } + std::os::unix::fs::symlink(&target, destination).map_err(|error| { + CliError::Config(format!( + "failed to preserve Python venv lib64 symlink {}: {error}", + destination.display() + )) + })?; + Ok(true) +} + +#[cfg(not(unix))] +fn preserve_python_lib64_alias( + _source: &Path, + _destination: &Path, + _metadata: &fs::Metadata, +) -> Result { + Ok(false) +} + fn resolve_snapshot_entry(path: &Path, metadata: &fs::Metadata) -> Result { if metadata.file_type().is_symlink() { fs::canonicalize(path).map_err(|error| { diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index cc8ceac14..8f6d5e5ed 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -1914,6 +1914,93 @@ fn python_environment_entry_budget_counts_skipped_cache_entries() { assert!(error.contains("2-entry attestation budget"), "{error}"); } +#[cfg(unix)] +#[test] +fn python_environment_byte_budget_counts_internal_directory_alias_once() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let environment_path = temp.path().join("environment"); + let site_packages = environment_path.join("lib/python3.11/site-packages"); + std::fs::create_dir_all(&site_packages).unwrap(); + let payload = b"installed package"; + let installed = site_packages.join("package.bin"); + std::fs::write(&installed, payload).unwrap(); + symlink("lib", environment_path.join("lib64")).unwrap(); + + let original = environment::test_environment_tree_digest_with_limits( + &environment_path, + 16, + payload.len() as u64, + ) + .expect("lib64 -> lib must not charge installed files twice"); + + std::fs::write(&installed, b"changed package!").unwrap(); + let changed = environment::test_environment_tree_digest_with_limits( + &environment_path, + 16, + payload.len() as u64, + ) + .unwrap(); + assert_ne!(original, changed, "aliased content must remain attested"); + + symlink("lib", environment_path.join("other-alias")).unwrap(); + let error = environment::test_environment_tree_digest_with_limits( + &environment_path, + 32, + payload.len() as u64, + ) + .expect_err("non-standard aliases must remain inside the byte budget"); + assert!(error.contains("byte attestation budget"), "{error}"); +} + +#[cfg(unix)] +#[test] +fn python_activation_snapshot_preserves_internal_directory_alias() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let environment_path = temp.path().join("environment"); + let site_packages = environment_path.join("lib/python3.11/site-packages"); + std::fs::create_dir_all(&site_packages).unwrap(); + std::fs::write(environment_path.join("pyvenv.cfg"), b"home = /usr/bin\n").unwrap(); + std::fs::write(site_packages.join("package.bin"), b"installed package").unwrap(); + symlink("lib", environment_path.join("lib64")).unwrap(); + let source_digest = "sha256:fixture-source-artifact"; + environment::write_environment_attestation(&environment_path, source_digest).unwrap(); + + let snapshot_path = temp.path().join("snapshot"); + copy_snapshot_directory( + &environment_path, + &snapshot_path, + &mut HashMap::new(), + &mut SnapshotBudget::default(), + true, + &mut Vec::new(), + ) + .unwrap(); + + assert!( + std::fs::symlink_metadata(snapshot_path.join("lib64")) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!( + std::fs::read_link(snapshot_path.join("lib64")).unwrap(), + Path::new("lib") + ); + environment::verify_environment_attestation(&snapshot_path, source_digest).unwrap(); + + std::fs::write( + snapshot_path.join("lib/python3.11/site-packages/package.bin"), + b"tampered package", + ) + .unwrap(); + assert!(environment::verify_environment_attestation(&snapshot_path, source_digest).is_err()); +} + #[test] fn python_activation_snapshot_is_attested_copied_and_tamper_evident() { let temp = tempfile::tempdir().unwrap(); From ec4d9db4ae675cc7d27c65d85507d8090b4471df Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 23 Sep 2026 11:08:17 -0400 Subject: [PATCH 2/5] fix(cli): gate venv alias test helper on Unix Signed-off-by: Alex Fournier --- crates/cli/src/plugins/lifecycle/environment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 1e44518e3..83ddf7028 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -400,7 +400,7 @@ pub(super) fn test_environment_tree_digest_with_entry_limit( environment_tree_digest_with_limit(environment, max_entries) } -#[cfg(test)] +#[cfg(all(test, unix))] pub(super) fn test_environment_tree_digest_with_limits( environment: &Path, max_entries: usize, From fab370c7496ed85aea81a684ced7d01663989791 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 23 Sep 2026 11:25:42 -0400 Subject: [PATCH 3/5] refactor(cli): encapsulate environment digest state Signed-off-by: Alex Fournier --- .../cli/src/plugins/lifecycle/environment.rs | 123 ++++++++++-------- .../shared/plugins_lifecycle_tests.rs | 6 +- 2 files changed, 73 insertions(+), 56 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 83ddf7028..1a8a508d7 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -344,69 +344,112 @@ pub(super) fn write_environment_attestation( pub(super) fn environment_tree_digest(environment: &Path) -> Result { #[cfg(test)] ENVIRONMENT_TREE_DIGEST_CALLS.fetch_add(1, Ordering::Relaxed); - environment_tree_digest_with_limit(environment, MAX_ENVIRONMENT_FILES) + environment_tree_digest_with_entry_limit(environment, MAX_ENVIRONMENT_FILES) } -fn environment_tree_digest_with_limit( +fn environment_tree_digest_with_entry_limit( environment: &Path, max_entries: usize, ) -> Result { - environment_tree_digest_with_limits( + environment_tree_digest_with_budget( environment, max_entries, crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES, ) } -fn environment_tree_digest_with_limits( +fn environment_tree_digest_with_budget( environment: &Path, max_entries: usize, max_bytes: u64, ) -> Result { - let mut digest = Sha256::new(); - let mut budget = EnvironmentDigestBudget { - total_bytes: 0, - entries: 0, - max_entries, - max_bytes, - }; + let mut digest = EnvironmentDigest::new(max_entries, max_bytes); digest_environment_directory( environment, Path::new(""), &mut Vec::new(), &mut digest, - &mut budget, true, )?; - Ok(digest - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) + Ok(digest.finalize()) } -struct EnvironmentDigestBudget { +struct EnvironmentDigest { + hasher: Sha256, total_bytes: u64, entries: usize, max_entries: usize, max_bytes: u64, } +impl EnvironmentDigest { + fn new(max_entries: usize, max_bytes: u64) -> Self { + Self { + hasher: Sha256::new(), + total_bytes: 0, + entries: 0, + max_entries, + max_bytes, + } + } + + fn charge_entry(&mut self, directory: &Path) -> Result<(), String> { + self.entries = self.entries.saturating_add(1); + if self.entries > self.max_entries { + return Err(format!( + "managed Python environment exceeds the {}-entry attestation budget at {}", + self.max_entries, + directory.display() + )); + } + Ok(()) + } + + fn charge_bytes(&mut self, bytes: usize) -> Result<(), String> { + self.total_bytes = self.total_bytes.saturating_add(bytes as u64); + if self.total_bytes > self.max_bytes { + return Err(format!( + "managed Python environment exceeds the {}-byte attestation budget", + self.max_bytes + )); + } + Ok(()) + } + + fn update(&mut self, entry_type: u8, path: &Path, payload: &[u8]) { + let path = raw_path_bytes(path); + self.hasher.update([entry_type]); + self.hasher.update((path.len() as u64).to_le_bytes()); + self.hasher.update(&path); + self.hasher + .update((payload.len() as u64).to_le_bytes()); + self.hasher.update(payload); + } + + fn finalize(self) -> String { + self.hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } +} + #[cfg(test)] pub(super) fn test_environment_tree_digest_with_entry_limit( environment: &Path, max_entries: usize, ) -> Result { - environment_tree_digest_with_limit(environment, max_entries) + environment_tree_digest_with_entry_limit(environment, max_entries) } #[cfg(all(test, unix))] -pub(super) fn test_environment_tree_digest_with_limits( +pub(super) fn test_environment_tree_digest_with_budget( environment: &Path, max_entries: usize, max_bytes: u64, ) -> Result { - environment_tree_digest_with_limits(environment, max_entries, max_bytes) + environment_tree_digest_with_budget(environment, max_entries, max_bytes) } #[cfg(test)] @@ -423,8 +466,7 @@ fn digest_environment_directory( directory: &Path, relative_directory: &Path, ancestors: &mut Vec, - digest: &mut Sha256, - budget: &mut EnvironmentDigestBudget, + digest: &mut EnvironmentDigest, charge_bytes: bool, ) -> Result<(), String> { if ancestors.len() >= MAX_ENVIRONMENT_DEPTH { @@ -446,14 +488,7 @@ fn digest_environment_directory( for child in std::fs::read_dir(directory) .map_err(|error| format!("failed to read {}: {error}", directory.display()))? { - budget.entries = budget.entries.saturating_add(1); - if budget.entries > budget.max_entries { - return Err(format!( - "managed Python environment exceeds the {}-entry attestation budget at {}", - budget.max_entries, - directory.display() - )); - } + digest.charge_entry(directory)?; children.push( child.map_err(|error| format!("failed to read {}: {error}", directory.display()))?, ); @@ -465,7 +500,6 @@ fn digest_environment_directory( relative_directory, ancestors, digest, - budget, charge_bytes, )?; } @@ -477,8 +511,7 @@ fn digest_environment_entry( child: std::fs::DirEntry, relative_directory: &Path, ancestors: &mut Vec, - digest: &mut Sha256, - budget: &mut EnvironmentDigestBudget, + digest: &mut EnvironmentDigest, charge_bytes: bool, ) -> Result<(), String> { let path = child.path(); @@ -490,7 +523,7 @@ fn digest_environment_entry( let metadata = std::fs::metadata(&source) .map_err(|error| format!("failed to inspect {}: {error}", source.display()))?; if metadata.is_dir() { - update_tree_digest(digest, b'd', &relative, &[]); + digest.update(b'd', &relative, &[]); // Linux venvs expose the same installed tree through `lib` and `lib64 -> lib`. // Keep hashing the alias for digest compatibility, but charge its bytes through `lib` only. let charge_bytes = charge_bytes && !is_python_lib64_alias(&path, &relative)?; @@ -499,7 +532,6 @@ fn digest_environment_entry( &relative, ancestors, digest, - budget, charge_bytes, ); } @@ -514,15 +546,9 @@ fn digest_environment_entry( "managed Python environment file", )?; if charge_bytes { - budget.total_bytes = budget.total_bytes.saturating_add(bytes.len() as u64); - if budget.total_bytes > budget.max_bytes { - return Err(format!( - "managed Python environment exceeds the {}-byte attestation budget", - budget.max_bytes - )); - } + digest.charge_bytes(bytes.len())?; } - update_tree_digest(digest, b'f', &relative, &bytes); + digest.update(b'f', &relative, &bytes); Ok(()) } @@ -557,15 +583,6 @@ fn resolve_environment_entry(path: &Path) -> Result { } } -fn update_tree_digest(digest: &mut Sha256, entry_type: u8, path: &Path, payload: &[u8]) { - let path = raw_path_bytes(path); - digest.update([entry_type]); - digest.update((path.len() as u64).to_le_bytes()); - digest.update(&path); - digest.update((payload.len() as u64).to_le_bytes()); - digest.update(payload); -} - #[cfg(unix)] fn raw_path_bytes(path: &Path) -> Vec { use std::os::unix::ffi::OsStrExt; diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 8f6d5e5ed..4ce814a20 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -1928,7 +1928,7 @@ fn python_environment_byte_budget_counts_internal_directory_alias_once() { std::fs::write(&installed, payload).unwrap(); symlink("lib", environment_path.join("lib64")).unwrap(); - let original = environment::test_environment_tree_digest_with_limits( + let original = environment::test_environment_tree_digest_with_budget( &environment_path, 16, payload.len() as u64, @@ -1936,7 +1936,7 @@ fn python_environment_byte_budget_counts_internal_directory_alias_once() { .expect("lib64 -> lib must not charge installed files twice"); std::fs::write(&installed, b"changed package!").unwrap(); - let changed = environment::test_environment_tree_digest_with_limits( + let changed = environment::test_environment_tree_digest_with_budget( &environment_path, 16, payload.len() as u64, @@ -1945,7 +1945,7 @@ fn python_environment_byte_budget_counts_internal_directory_alias_once() { assert_ne!(original, changed, "aliased content must remain attested"); symlink("lib", environment_path.join("other-alias")).unwrap(); - let error = environment::test_environment_tree_digest_with_limits( + let error = environment::test_environment_tree_digest_with_budget( &environment_path, 32, payload.len() as u64, From 2fcdf693f2b7cf56ff5e3b368dc53d40d9a4059d Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 23 Sep 2026 11:41:08 -0400 Subject: [PATCH 4/5] fix(cli): align venv alias budget rules Signed-off-by: Alex Fournier --- .../cli/src/plugins/lifecycle/environment.rs | 25 ++++------ .../shared/plugins_lifecycle_tests.rs | 46 ++++++++++++++----- 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 1a8a508d7..3445a8f69 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -421,8 +421,7 @@ impl EnvironmentDigest { self.hasher.update([entry_type]); self.hasher.update((path.len() as u64).to_le_bytes()); self.hasher.update(&path); - self.hasher - .update((payload.len() as u64).to_le_bytes()); + self.hasher.update((payload.len() as u64).to_le_bytes()); self.hasher.update(payload); } @@ -495,13 +494,7 @@ fn digest_environment_directory( } children.sort_by_key(std::fs::DirEntry::file_name); for child in children { - digest_environment_entry( - child, - relative_directory, - ancestors, - digest, - charge_bytes, - )?; + digest_environment_entry(child, relative_directory, ancestors, digest, charge_bytes)?; } ancestors.pop(); Ok(()) @@ -527,13 +520,7 @@ fn digest_environment_entry( // Linux venvs expose the same installed tree through `lib` and `lib64 -> lib`. // Keep hashing the alias for digest compatibility, but charge its bytes through `lib` only. let charge_bytes = charge_bytes && !is_python_lib64_alias(&path, &relative)?; - return digest_environment_directory( - &source, - &relative, - ancestors, - digest, - charge_bytes, - ); + return digest_environment_directory(&source, &relative, ancestors, digest, charge_bytes); } if !metadata.is_file() { return Err(format!( @@ -553,7 +540,11 @@ fn digest_environment_entry( } fn is_python_lib64_alias(path: &Path, relative: &Path) -> Result { - if relative != Path::new("lib64") { + if relative != Path::new("lib64") + || path + .parent() + .is_none_or(|root| !root.join("pyvenv.cfg").is_file()) + { return Ok(false); } let metadata = std::fs::symlink_metadata(path) diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 4ce814a20..e3c7b330e 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -1928,29 +1928,51 @@ fn python_environment_byte_budget_counts_internal_directory_alias_once() { std::fs::write(&installed, payload).unwrap(); symlink("lib", environment_path.join("lib64")).unwrap(); - let original = environment::test_environment_tree_digest_with_budget( + let error = environment::test_environment_tree_digest_with_budget( &environment_path, 16, payload.len() as u64, ) - .expect("lib64 -> lib must not charge installed files twice"); + .expect_err("a non-venv lib64 alias must remain inside the byte budget"); + assert!(error.contains("byte attestation budget"), "{error}"); - std::fs::write(&installed, b"changed package!").unwrap(); - let changed = environment::test_environment_tree_digest_with_budget( - &environment_path, + let pyvenv = b"home = /usr/bin\n"; + std::fs::write(environment_path.join("pyvenv.cfg"), pyvenv).unwrap(); + let byte_budget = (payload.len() + pyvenv.len()) as u64; + + let original = + environment::test_environment_tree_digest_with_budget(&environment_path, 16, byte_budget) + .expect("lib64 -> lib must not charge installed files twice"); + + let physical_environment = temp.path().join("physical-environment"); + let physical_site_packages = physical_environment.join("lib/python3.11/site-packages"); + let physical_lib64_site_packages = physical_environment.join("lib64/python3.11/site-packages"); + std::fs::create_dir_all(&physical_site_packages).unwrap(); + std::fs::create_dir_all(&physical_lib64_site_packages).unwrap(); + std::fs::write(physical_environment.join("pyvenv.cfg"), pyvenv).unwrap(); + std::fs::write(physical_site_packages.join("package.bin"), payload).unwrap(); + std::fs::write(physical_lib64_site_packages.join("package.bin"), payload).unwrap(); + let physical_digest = environment::test_environment_tree_digest_with_budget( + &physical_environment, 16, - payload.len() as u64, + byte_budget + payload.len() as u64, ) .unwrap(); + assert_eq!( + original, physical_digest, + "the venv alias must retain its logical lib64 digest entries" + ); + + std::fs::write(&installed, b"changed package!").unwrap(); + let changed = + environment::test_environment_tree_digest_with_budget(&environment_path, 16, byte_budget) + .unwrap(); assert_ne!(original, changed, "aliased content must remain attested"); symlink("lib", environment_path.join("other-alias")).unwrap(); - let error = environment::test_environment_tree_digest_with_budget( - &environment_path, - 32, - payload.len() as u64, - ) - .expect_err("non-standard aliases must remain inside the byte budget"); + let error = + environment::test_environment_tree_digest_with_budget(&environment_path, 32, byte_budget) + .expect_err("non-standard aliases must remain inside the byte budget"); assert!(error.contains("byte attestation budget"), "{error}"); } From 0b642da5471f55c5933a486ea53a41bb9b3310b7 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 23 Sep 2026 12:07:36 -0400 Subject: [PATCH 5/5] fix(cli): limit venv alias exemption to Unix Signed-off-by: Alex Fournier --- crates/cli/src/plugins/lifecycle/environment.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 3445a8f69..07a017c9a 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -540,7 +540,8 @@ fn digest_environment_entry( } fn is_python_lib64_alias(path: &Path, relative: &Path) -> Result { - if relative != Path::new("lib64") + if !cfg!(unix) + || relative != Path::new("lib64") || path .parent() .is_none_or(|root| !root.join("pyvenv.cfg").is_file())