diff --git a/crates/cli/src/plugins/lifecycle/environment.rs b/crates/cli/src/plugins/lifecycle/environment.rs index 0298f25bf..07a017c9a 100644 --- a/crates/cli/src/plugins/lifecycle/environment.rs +++ b/crates/cli/src/plugins/lifecycle/environment.rs @@ -344,30 +344,94 @@ 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 { - let mut digest = Sha256::new(); - let mut total = 0_u64; - let mut entries = 0_usize; + environment_tree_digest_with_budget( + environment, + max_entries, + crate::filesystem::bounded::MAX_BOUNDED_FILE_BYTES, + ) +} + +fn environment_tree_digest_with_budget( + environment: &Path, + max_entries: usize, + max_bytes: u64, +) -> Result { + let mut digest = EnvironmentDigest::new(max_entries, max_bytes); digest_environment_directory( environment, Path::new(""), &mut Vec::new(), &mut digest, - &mut total, - &mut entries, - max_entries, + true, )?; - Ok(digest - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) + Ok(digest.finalize()) +} + +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)] @@ -375,7 +439,16 @@ 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_budget( + environment: &Path, + max_entries: usize, + max_bytes: u64, +) -> Result { + environment_tree_digest_with_budget(environment, max_entries, max_bytes) } #[cfg(test)] @@ -392,10 +465,8 @@ fn digest_environment_directory( directory: &Path, relative_directory: &Path, ancestors: &mut Vec, - digest: &mut Sha256, - total: &mut u64, - entries: &mut usize, - max_entries: usize, + digest: &mut EnvironmentDigest, + charge_bytes: bool, ) -> Result<(), String> { if ancestors.len() >= MAX_ENVIRONMENT_DEPTH { return Err(format!( @@ -416,28 +487,14 @@ 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 { - return Err(format!( - "managed Python environment exceeds the {max_entries}-entry attestation budget at {}", - directory.display() - )); - } + digest.charge_entry(directory)?; children.push( child.map_err(|error| format!("failed to read {}: {error}", directory.display()))?, ); } children.sort_by_key(std::fs::DirEntry::file_name); for child in children { - digest_environment_entry( - child, - relative_directory, - ancestors, - digest, - total, - entries, - max_entries, - )?; + digest_environment_entry(child, relative_directory, ancestors, digest, charge_bytes)?; } ancestors.pop(); Ok(()) @@ -447,10 +504,8 @@ fn digest_environment_entry( child: std::fs::DirEntry, relative_directory: &Path, ancestors: &mut Vec, - digest: &mut Sha256, - total: &mut u64, - entries: &mut usize, - max_entries: usize, + digest: &mut EnvironmentDigest, + charge_bytes: bool, ) -> Result<(), String> { let path = child.path(); let relative = relative_directory.join(child.file_name()); @@ -461,16 +516,11 @@ 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, &[]); - return digest_environment_directory( - &source, - &relative, - ancestors, - digest, - total, - entries, - max_entries, - ); + 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)?; + return digest_environment_directory(&source, &relative, ancestors, digest, charge_bytes); } if !metadata.is_file() { return Err(format!( @@ -482,17 +532,32 @@ 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 { + digest.charge_bytes(bytes.len())?; } - update_tree_digest(digest, b'f', &relative, &bytes); + digest.update(b'f', &relative, &bytes); Ok(()) } +fn is_python_lib64_alias(path: &Path, relative: &Path) -> Result { + if !cfg!(unix) + || 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) + .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__") @@ -510,15 +575,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/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..e3c7b330e 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -1914,6 +1914,115 @@ 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 error = environment::test_environment_tree_digest_with_budget( + &environment_path, + 16, + payload.len() as u64, + ) + .expect_err("a non-venv lib64 alias must remain inside the byte budget"); + assert!(error.contains("byte attestation budget"), "{error}"); + + 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, + 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, byte_budget) + .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();