Skip to content

Commit 148627a

Browse files
karthiknadigCopilot
andcommitted
fix: validate contextual executable cache aliases (Fixes #448)
Unify relative and absolute cache identities while preserving short caller-facing aliases. Invalidate missing tracked executables and cover memory, disk, and fast-path behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8bd5fb4 commit 148627a

3 files changed

Lines changed: 244 additions & 53 deletions

File tree

‎crates/pet-python-utils/src/cache.rs‎

Lines changed: 150 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ use std::{
1313

1414
use crate::{
1515
env::ResolvedPythonEnv,
16-
fs_cache::{delete_cache_file, get_cache_from_file, store_cache_in_file},
16+
fs_cache::{
17+
delete_cache_file, executable_cache_key, executable_cache_key_from, get_cache_from_file,
18+
store_cache_in_file,
19+
},
1720
};
1821

1922
lazy_static! {
@@ -22,6 +25,10 @@ lazy_static! {
2225

2326
pub trait CacheEntry: Send + Sync {
2427
fn get(&self) -> Option<ResolvedPythonEnv>;
28+
fn get_for_executable(&self, executable: &std::path::Path) -> Option<ResolvedPythonEnv> {
29+
self.get()
30+
.map(|environment| environment.for_executable_alias(executable))
31+
}
2532
fn store(&self, environment: ResolvedPythonEnv);
2633
fn track_symlinks(&self, symlinks: Vec<PathBuf>);
2734
}
@@ -102,6 +109,7 @@ impl CacheImpl {
102109
}
103110
}
104111
fn create_cache(&self, executable: PathBuf) -> LockableCacheEntry {
112+
let cache_key = executable_cache_key(&executable);
105113
let cache_directory = self
106114
.cache_dir
107115
.lock()
@@ -111,11 +119,11 @@ impl CacheImpl {
111119
.locks
112120
.lock()
113121
.expect("locks mutex poisoned")
114-
.entry(executable.clone())
122+
.entry(cache_key.clone())
115123
{
116124
Entry::Occupied(lock) => lock.get().clone(),
117125
Entry::Vacant(lock) => {
118-
let cache = Box::new(CacheEntryImpl::create(cache_directory.clone(), executable))
126+
let cache = Box::new(CacheEntryImpl::create(cache_directory.clone(), cache_key))
119127
as Box<dyn CacheEntry + 'static>;
120128
lock.insert(Arc::new(Mutex::new(cache))).clone()
121129
}
@@ -129,6 +137,16 @@ impl CacheImpl {
129137
/// See: https://github.com/microsoft/python-environment-tools/issues/223
130138
type FilePathWithMTimeCTime = (PathBuf, SystemTime, Option<SystemTime>);
131139

140+
fn current_dir_for_aliases(aliases: &[PathBuf]) -> Option<PathBuf> {
141+
aliases
142+
.iter()
143+
.any(|alias| alias.is_relative())
144+
.then(std::env::current_dir)
145+
.transpose()
146+
.ok()
147+
.flatten()
148+
}
149+
132150
struct CacheEntryImpl {
133151
cache_directory: Option<PathBuf>,
134152
executable: PathBuf,
@@ -146,37 +164,35 @@ impl CacheEntryImpl {
146164
}
147165
}
148166
pub fn verify_in_memory_cache(&self) {
149-
// Check if any of the exes have changed since we last cached this.
150-
for symlink_info in self
167+
let cache_is_valid = self
151168
.symlinks
152169
.lock()
153170
.expect("symlinks mutex poisoned")
154171
.iter()
155-
{
156-
if let Ok(metadata) = symlink_info.0.metadata() {
157-
let mtime_changed = metadata.modified().ok() != Some(symlink_info.1);
158-
// Only check ctime if we have it stored (may be None on Linux)
159-
let ctime_changed = match symlink_info.2 {
160-
Some(stored_ctime) => metadata.created().ok() != Some(stored_ctime),
161-
None => false, // Can't check ctime if we don't have it
162-
};
163-
if mtime_changed || ctime_changed {
164-
trace!(
165-
"Symlink {:?} has changed since we last cached it. original mtime & ctime {:?}, {:?}, current mtime & ctime {:?}, {:?}",
166-
symlink_info.0,
167-
symlink_info.1,
168-
symlink_info.2,
169-
metadata.modified().ok(),
170-
metadata.created().ok()
171-
);
172-
self.envoronment
173-
.lock()
174-
.expect("envoronment mutex poisoned")
175-
.take();
176-
if let Some(cache_directory) = &self.cache_directory {
177-
delete_cache_file(cache_directory, &self.executable);
178-
}
172+
.all(|symlink_info| {
173+
if let Ok(metadata) = symlink_info.0.metadata() {
174+
let mtime_changed = metadata.modified().ok() != Some(symlink_info.1);
175+
let ctime_changed = match symlink_info.2 {
176+
Some(stored_ctime) => metadata.created().ok() != Some(stored_ctime),
177+
None => false,
178+
};
179+
!mtime_changed && !ctime_changed
180+
} else {
181+
false
179182
}
183+
});
184+
185+
if !cache_is_valid {
186+
trace!(
187+
"Tracked executable changed or disappeared for {:?}",
188+
self.executable
189+
);
190+
self.envoronment
191+
.lock()
192+
.expect("envoronment mutex poisoned")
193+
.take();
194+
if let Some(cache_directory) = &self.cache_directory {
195+
delete_cache_file(cache_directory, &self.executable);
180196
}
181197
}
182198
}
@@ -215,14 +231,17 @@ impl CacheEntry for CacheEntryImpl {
215231

216232
fn store(&self, environment: ResolvedPythonEnv) {
217233
// Get hold of the mtimes and ctimes of the symlinks.
234+
let aliases = environment.symlinks.clone().unwrap_or_default();
235+
let current_dir = current_dir_for_aliases(&aliases);
218236
let mut symlinks = vec![];
219-
for symlink in environment.symlinks.clone().unwrap_or_default().iter() {
237+
for alias in &aliases {
238+
let symlink = executable_cache_key_from(alias, current_dir.as_deref());
220239
if let Ok(metadata) = symlink.metadata() {
221240
// We require mtime, but ctime is optional (not available on all Linux filesystems)
222241
// See: https://github.com/microsoft/python-environment-tools/issues/223
223242
if let Ok(modified) = metadata.modified() {
224243
let created = metadata.created().ok(); // May be None on Linux
225-
symlinks.push((symlink.clone(), modified, created));
244+
symlinks.push((symlink, modified, created));
226245
}
227246
}
228247
}
@@ -259,8 +278,12 @@ impl CacheEntry for CacheEntryImpl {
259278
.iter()
260279
.map(|x| x.0.clone())
261280
.collect();
262-
263-
if symlinks.iter().all(|x| known_symlinks.contains(x)) {
281+
let current_dir = current_dir_for_aliases(&symlinks);
282+
if symlinks
283+
.iter()
284+
.map(|alias| executable_cache_key_from(alias, current_dir.as_deref()))
285+
.all(|key| known_symlinks.contains(&key))
286+
{
264287
return;
265288
}
266289

@@ -283,3 +306,97 @@ impl CacheEntry for CacheEntryImpl {
283306
}
284307
}
285308
}
309+
310+
#[cfg(test)]
311+
mod tests {
312+
use super::*;
313+
use tempfile::tempdir_in;
314+
315+
fn environment(executable: PathBuf, aliases: Vec<PathBuf>) -> ResolvedPythonEnv {
316+
ResolvedPythonEnv {
317+
executable,
318+
prefix: PathBuf::from("prefix"),
319+
version: "3.12.0".to_string(),
320+
is64_bit: true,
321+
symlinks: Some(aliases),
322+
}
323+
}
324+
325+
fn aliases() -> (tempfile::TempDir, PathBuf, PathBuf) {
326+
let current_dir = std::env::current_dir().unwrap();
327+
let temp_dir = tempdir_in(&current_dir).unwrap();
328+
let absolute = temp_dir.path().join("python");
329+
std::fs::write(&absolute, "python").unwrap();
330+
let relative = absolute.strip_prefix(&current_dir).unwrap().to_path_buf();
331+
(temp_dir, relative, absolute)
332+
}
333+
334+
#[test]
335+
fn relative_and_absolute_aliases_share_in_memory_entry() {
336+
let (_temp_dir, relative, absolute) = aliases();
337+
let cache = CacheImpl::new(None);
338+
339+
let relative_entry = cache.create_cache(relative);
340+
let absolute_entry = cache.create_cache(absolute);
341+
342+
assert!(Arc::ptr_eq(&relative_entry, &absolute_entry));
343+
}
344+
345+
#[test]
346+
fn cache_hit_uses_current_alias_and_preserves_shorter_aliases() {
347+
let (_temp_dir, relative, absolute) = aliases();
348+
let cache = CacheImpl::new(None);
349+
let entry = cache.create_cache(relative.clone());
350+
let entry = entry.lock().unwrap();
351+
entry.store(environment(
352+
relative.clone(),
353+
vec![relative.clone(), absolute.clone()],
354+
));
355+
356+
let relative_hit = entry.get_for_executable(&relative).unwrap();
357+
assert_eq!(relative_hit.executable, relative);
358+
359+
let absolute_hit = entry.get_for_executable(&absolute).unwrap();
360+
assert_eq!(absolute_hit.executable, absolute);
361+
let hit_aliases = absolute_hit.symlinks.unwrap();
362+
assert!(hit_aliases.contains(&relative));
363+
assert!(hit_aliases.contains(&absolute));
364+
}
365+
366+
#[test]
367+
fn disk_cache_reuses_relative_entry_for_absolute_alias() {
368+
let (temp_dir, relative, absolute) = aliases();
369+
let cache_directory = temp_dir.path().join("cache");
370+
{
371+
let cache = CacheImpl::new(Some(cache_directory.clone()));
372+
let entry = cache.create_cache(relative.clone());
373+
entry.lock().unwrap().store(environment(
374+
relative.clone(),
375+
vec![relative.clone(), absolute.clone()],
376+
));
377+
}
378+
379+
let cache = CacheImpl::new(Some(cache_directory));
380+
let entry = cache.create_cache(absolute.clone());
381+
let hit = entry.lock().unwrap().get_for_executable(&absolute).unwrap();
382+
383+
assert_eq!(hit.executable, absolute);
384+
let hit_aliases = hit.symlinks.unwrap();
385+
assert!(hit_aliases.contains(&relative));
386+
assert!(hit_aliases.contains(&absolute));
387+
}
388+
389+
#[test]
390+
fn missing_tracked_executable_invalidates_in_memory_entry() {
391+
let (temp_dir, _relative, absolute) = aliases();
392+
let cache = CacheImpl::new(Some(temp_dir.path().join("cache")));
393+
let entry = cache.create_cache(absolute.clone());
394+
let entry = entry.lock().unwrap();
395+
entry.store(environment(absolute.clone(), vec![absolute.clone()]));
396+
assert!(entry.get().is_some());
397+
398+
std::fs::remove_file(&absolute).unwrap();
399+
400+
assert!(entry.get().is_none());
401+
}
402+
}

‎crates/pet-python-utils/src/env.rs‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,25 @@ pub struct ResolvedPythonEnv {
4141
}
4242

4343
impl ResolvedPythonEnv {
44+
pub(crate) fn for_executable_alias(mut self, executable: &Path) -> Self {
45+
let alias_is_current = self.executable == executable
46+
&& self
47+
.symlinks
48+
.as_ref()
49+
.is_some_and(|aliases| aliases.iter().any(|alias| alias == executable));
50+
if alias_is_current {
51+
return self;
52+
}
53+
54+
let mut symlinks = self.symlinks.take().unwrap_or_default();
55+
symlinks.push(executable.to_path_buf());
56+
symlinks.sort();
57+
symlinks.dedup();
58+
self.executable = executable.to_path_buf();
59+
self.symlinks = Some(symlinks);
60+
self
61+
}
62+
4463
pub fn to_python_env(&self) -> PythonEnv {
4564
let mut env = PythonEnv::new(
4665
self.executable.clone(),
@@ -84,7 +103,7 @@ impl ResolvedPythonEnv {
84103
) -> Option<Self> {
85104
let cache = create_cache(executable.to_path_buf());
86105
let entry = cache.lock().expect("cache mutex poisoned");
87-
if let Some(env) = entry.get() {
106+
if let Some(env) = entry.get_for_executable(executable) {
88107
Some(env)
89108
} else if let Some(env) = get_interpreter_details(executable) {
90109
entry.store(env.clone());

0 commit comments

Comments
 (0)