Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 120 additions & 64 deletions crates/cli/src/plugins/lifecycle/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,38 +344,111 @@ pub(super) fn write_environment_attestation(
pub(super) fn environment_tree_digest(environment: &Path) -> Result<String, String> {
#[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<String, String> {
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<String, String> {
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)]
pub(super) fn test_environment_tree_digest_with_entry_limit(
environment: &Path,
max_entries: usize,
) -> Result<String, String> {
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<String, String> {
environment_tree_digest_with_budget(environment, max_entries, max_bytes)
}

#[cfg(test)]
Expand All @@ -392,10 +465,8 @@ fn digest_environment_directory(
directory: &Path,
relative_directory: &Path,
ancestors: &mut Vec<PathBuf>,
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!(
Expand All @@ -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(())
Expand All @@ -447,10 +504,8 @@ fn digest_environment_entry(
child: std::fs::DirEntry,
relative_directory: &Path,
ancestors: &mut Vec<PathBuf>,
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());
Expand All @@ -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!(
Expand All @@ -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<bool, String> {
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__")
Expand All @@ -510,15 +575,6 @@ fn resolve_environment_entry(path: &Path) -> Result<PathBuf, String> {
}
}

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<u8> {
use std::os::unix::ffi::OsStrExt;
Expand Down
48 changes: 48 additions & 0 deletions crates/cli/src/plugins/lifecycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1164,6 +1171,47 @@ fn copy_snapshot_entry(
)
}

#[cfg(unix)]
fn preserve_python_lib64_alias(
source: &Path,
destination: &Path,
metadata: &fs::Metadata,
) -> Result<bool, CliError> {
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<bool, CliError> {
Ok(false)
}

fn resolve_snapshot_entry(path: &Path, metadata: &fs::Metadata) -> Result<PathBuf, CliError> {
if metadata.file_type().is_symlink() {
fs::canonicalize(path).map_err(|error| {
Expand Down
Loading
Loading