diff --git a/crates/pixi_trampoline/Cargo.toml b/crates/pixi_trampoline/Cargo.toml index a1f3e979b..dc9b107a8 100644 --- a/crates/pixi_trampoline/Cargo.toml +++ b/crates/pixi_trampoline/Cargo.toml @@ -1,12 +1,12 @@ [package] -authors.workspace = true +authors = ["pixi contributors "] description = "Trampoline binary that is used to run binaries instaled by pixi global" -edition.workspace = true -homepage.workspace = true -license.workspace = true +edition = "2021" +homepage = "https://github.com/prefix-dev/pixi" +license = "BSD-3-Clause" name = "pixi_trampoline" -readme.workspace = true -repository.workspace = true +readme = "README.md" +repository = "https://github.com/prefix-dev/pixi" version = "0.1.0" [profile.release] diff --git a/src/global/common.rs b/src/global/common.rs index 59b4f6617..4c1ae7b37 100644 --- a/src/global/common.rs +++ b/src/global/common.rs @@ -1,5 +1,4 @@ -use crate::global::install::extract_executable_from_trampoline_manifest; - +use super::trampoline::{GlobalBin, Trampoline}; use super::{EnvironmentName, ExposedName, Mapping}; use console::StyledObject; use fancy_display::FancyDisplay; @@ -50,14 +49,19 @@ impl BinDir { /// This function reads the directory specified by `self.0` and collects all /// file paths into a vector. It returns a `miette::Result` containing the /// vector of file paths or an error if the directory can't be read. - pub(crate) async fn trampolines(&self) -> miette::Result> { + pub(crate) async fn bins(&self) -> miette::Result> { let mut files = Vec::new(); let mut entries = tokio_fs::read_dir(&self.0).await.into_diagnostic()?; while let Some(entry) = entries.next_entry().await.into_diagnostic()? { let path = entry.path(); + // TODO: should we add a magic number to ensure that it's our trampoline? if path.is_file() && path.is_executable() && is_binary(&path)? { - files.push(path); + let trampoline = Trampoline::from(path); + files.push(GlobalBin::Trampoline(trampoline)); + } else if path.is_file() && path.is_executable() && !is_binary(&path)? { + // If the file is not a binary, it's a script + files.push(GlobalBin::Script(path)); } } @@ -73,12 +77,12 @@ impl BinDir { /// /// This function constructs the path to the executable script by joining the /// `bin_dir` with the provided `exposed_name`. If the target platform is - /// Windows, it sets the file extension to `.bat`. - pub(crate) fn executable_script_path(&self, exposed_name: &ExposedName) -> PathBuf { + /// Windows, it sets the file extension to `.exe`. + pub(crate) fn executable_trampoline_path(&self, exposed_name: &ExposedName) -> PathBuf { // Add .bat to the windows executable let exposed_name = if cfg!(windows) { // Not using `.set_extension()` because it will break the `.` in the name for cases like `python3.9.1` - format!("{}.bat", exposed_name) + format!("{}.exe", exposed_name) } else { exposed_name.to_string() }; @@ -467,18 +471,22 @@ pub(crate) async fn get_expose_scripts_sync_status( bin_dir: &BinDir, env_dir: &EnvDir, mappings: &IndexSet, -) -> miette::Result<(IndexSet, IndexSet)> { +) -> miette::Result<(Vec, IndexSet)> { // Get all paths to the binaries from the scripts in the bin directory. - let locally_exposed = bin_dir.trampolines().await?; - let executable_paths = futures::future::join_all(locally_exposed.iter().map(|path| { - let path = path.clone(); + let locally_exposed = bin_dir.bins().await?; + eprintln!("locally exposed is {:?}", locally_exposed); + let executable_paths = futures::future::join_all(locally_exposed.iter().map(|global_bin| { + let global_bin = global_bin.clone(); + let path = global_bin.path().clone(); async move { - extract_executable_from_trampoline_manifest(&path) + global_bin + .executable() .await .ok() - .map(|exec| (path, exec)) + .map(|exec| (path, exec, global_bin)) } })) + // .await .await .into_iter() .flatten() @@ -487,9 +495,14 @@ pub(crate) async fn get_expose_scripts_sync_status( // Filter out all binaries that are related to the environment let related = executable_paths .into_iter() - .filter(|(_, exec)| exec.starts_with(env_dir.path())) + .filter(|(_, exec, _)| exec.starts_with(env_dir.path())) .collect_vec(); + eprintln!("related {:?}", related); + + eprintln!("++++++"); + eprintln!("{:?}", mappings); + fn match_mapping(mapping: &Mapping, exposed: &Path, executable: &Path) -> bool { exposed .file_name() @@ -506,27 +519,30 @@ pub(crate) async fn get_expose_scripts_sync_status( // Get all related expose scripts not required by the environment manifest let to_remove = related .iter() - .filter_map(|(exposed, executable)| { + // .filter(|(_, _, bin)| bin.is_script()) + .filter_map(|(exposed, executable, bin_type)| { + eprintln!("in filter map {:?} {:?}", exposed, executable); + if mappings .iter() .any(|mapping| match_mapping(mapping, exposed, executable)) + && bin_type.is_trampoline() { None } else { - Some(exposed) + Some(bin_type) } }) .cloned() - .collect::>(); + .collect_vec(); // Get all required exposed binaries that are not yet exposed let to_add = mappings .iter() .filter_map(|mapping| { - if related - .iter() - .any(|(exposed, executable)| match_mapping(mapping, exposed, executable)) - { + if related.iter().any(|(exposed, executable, bin)| { + match_mapping(mapping, exposed, executable) && bin.is_trampoline() + }) { None } else { Some(mapping.exposed_name().clone()) @@ -539,6 +555,8 @@ pub(crate) async fn get_expose_scripts_sync_status( #[cfg(test)] mod tests { + use crate::global::trampoline::ManifestMetadata; + use super::*; use rstest::rstest; use std::str::FromStr; @@ -629,7 +647,7 @@ mod tests { let path = PathBuf::from("/home/user/.pixi/bin"); let bin_dir = BinDir(path.clone()); let exposed_name = ExposedName::from_str(exposed_name).unwrap(); - let executable_script_path = bin_dir.executable_script_path(&exposed_name); + let executable_script_path = bin_dir.executable_trampoline_path(&exposed_name); if cfg!(windows) { let expected = format!("{}.bat", exposed_name); @@ -640,7 +658,7 @@ mod tests { } #[tokio::test] - async fn test_get_expose_scripts_sync_status() { + async fn test_get_expose_scripts_sync_status_for_legacy_scripts() { let tmp_home_dir = tempfile::tempdir().unwrap(); let tmp_home_dir_path = tmp_home_dir.path().to_path_buf(); let env_root = EnvRoot::new(tmp_home_dir_path.clone()).unwrap(); @@ -668,7 +686,10 @@ mod tests { assert!(to_remove.is_empty()); assert_eq!(to_add.len(), 1); - // Add a script to the bin directory + // Add a legacy script to the bin directory + // even if it's should be exposed and it's pointing to correct en + // it is an old script + // we need to remove it and replace with trampoline let script_path = if cfg!(windows) { bin_dir.path().join("test.bat") } else { @@ -709,18 +730,83 @@ mod tests { .unwrap(); }; + let (mut to_remove, mut to_add) = + get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + .await + .unwrap(); + assert!(to_remove.pop().unwrap().exposed_name().to_string() == "test"); + assert!(to_add.pop().unwrap().to_string() == "test"); + + // Test to_remove when nothing should be exposed + let (mut to_remove, to_add) = + get_expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new()) + .await + .unwrap(); + + assert!(to_remove.pop().unwrap().exposed_name().to_string() == "test"); + assert!(to_add.is_empty()); + } + + #[tokio::test] + async fn test_get_expose_scripts_sync_status_for_trampolines() { + let tmp_home_dir = tempfile::tempdir().unwrap(); + let tmp_home_dir_path = tmp_home_dir.path().to_path_buf(); + let env_root = EnvRoot::new(tmp_home_dir_path.clone()).unwrap(); + let env_name = EnvironmentName::from_str("test").unwrap(); + let env_dir = EnvDir::from_env_root(env_root, &env_name).await.unwrap(); + let bin_dir = BinDir::new(tmp_home_dir_path.clone()).unwrap(); + + // Test empty + let exposed = IndexSet::new(); + let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + .await + .unwrap(); + assert!(to_remove.is_empty()); + assert!(to_add.is_empty()); + + // Test with exposed + let mut exposed = IndexSet::new(); + exposed.insert(Mapping::new( + ExposedName::from_str("test").unwrap(), + "test".to_string(), + )); + + let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) + .await + .unwrap(); + assert!(to_remove.is_empty()); + assert_eq!(to_add.len(), 1); + + // add a trampoline + let original_exe = if cfg!(windows) { + env_dir.path().join("bin/test.exe") + } else { + env_dir.path().join("bin/test") + }; + + let manifest = ManifestMetadata::new(original_exe, bin_dir.path().join("bin"), None); + let trampoline = Trampoline::new( + ExposedName::from_str("test").unwrap(), + bin_dir.path().to_path_buf(), + manifest, + ); + + trampoline.save().await.unwrap(); + let (to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &exposed) .await .unwrap(); + assert!(to_remove.is_empty()); assert!(to_add.is_empty()); - // Test to_remove - let (to_remove, to_add) = + // Test to_remove when nothing should be exposed + let (mut to_remove, to_add) = get_expose_scripts_sync_status(&bin_dir, &env_dir, &IndexSet::new()) .await .unwrap(); - assert_eq!(to_remove.len(), 1); + + assert!(to_remove.pop().unwrap().exposed_name().to_string() == "test"); assert!(to_add.is_empty()); } } diff --git a/src/global/install.rs b/src/global/install.rs index 9ce8f4a81..780d62558 100644 --- a/src/global/install.rs +++ b/src/global/install.rs @@ -2,7 +2,7 @@ use super::{EnvDir, EnvironmentName, ExposedName, StateChanges}; use crate::{ global::{ common::is_binary, - trampoline::{ManifestMetadata, TRAMPOLINE_BIN}, + trampoline::{ManifestMetadata, Trampoline}, BinDir, StateChange, }, prefix::Prefix, @@ -14,9 +14,8 @@ use pixi_utils::executable_from_path; use rattler_conda_types::{ MatchSpec, Matches, PackageName, ParseStrictness, Platform, RepoDataRecord, }; +use std::fs; use std::{collections::HashMap, path::PathBuf, str::FromStr}; -use std::{fs, path::Path}; -use tokio::{fs::File, io::AsyncWriteExt}; /// Maps an entry point in the environment to a concrete `ScriptExecMapping`. /// @@ -41,7 +40,7 @@ pub(crate) fn script_exec_mapping<'a>( executables .find(|(executable_name, _)| *executable_name == entry_point) .map(|(_, executable_path)| ScriptExecMapping { - global_script_path: bin_dir.executable_script_path(exposed_name), + global_script_path: bin_dir.executable_trampoline_path(exposed_name), original_executable: executable_path.clone(), }) .ok_or_else(|| { @@ -84,15 +83,11 @@ pub(crate) async fn create_executable_scripts( original_executable, } in mapped_executables { - let metadata = ManifestMetadata { - exe: prefix.root().join(original_executable), - path: prefix - .root() - .join(original_executable.parent().unwrap()) - .to_string_lossy() - .to_string(), - env: activation_variables.clone(), - }; + let exe = prefix.root().join(original_executable); + let path = prefix + .root() + .join(original_executable.parent().expect("should have a parent")); + let metadata = ManifestMetadata::new(exe, path, Some(activation_variables.clone())); let json_path = global_script_path.with_extension("json"); @@ -134,31 +129,20 @@ pub(crate) async fn create_executable_scripts( } }; - let file = fs_err::File::create(json_path).into_diagnostic()?; - - serde_json::to_writer(file, &metadata).into_diagnostic()?; - - // write the content of trampoline bin to the file - #[cfg(windows)] - { - let mut file = File::create(global_script_path.with_extension("exe")) - .await - .into_diagnostic()?; - file.write_all(TRAMPOLINE_BIN).await.into_diagnostic()?; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut file = File::create(global_script_path).await.into_diagnostic()?; - file.write_all(TRAMPOLINE_BIN).await.into_diagnostic()?; - std::fs::set_permissions(global_script_path, std::fs::Permissions::from_mode(0o755)) - .into_diagnostic()?; - } - let executable_name = executable_from_path(global_script_path); let exposed_name = ExposedName::from_str(&executable_name)?; + let global_script_path_parent = global_script_path + .parent() + .expect("global script should have parent"); + + let trampoline = Trampoline::new( + exposed_name.clone(), + global_script_path_parent.to_path_buf(), + metadata, + ); + trampoline.save().await?; + match changed { AddedOrChanged::Unchanged => {} AddedOrChanged::Added => { @@ -172,24 +156,6 @@ pub(crate) async fn create_executable_scripts( Ok(state_changes) } -/// Extracts the executable path from a script file. -/// -/// This function reads the content of the script file and attempts to extract -/// the path of the executable it references. It is used to determine -/// the actual binary path from a wrapper script. -pub(crate) async fn extract_executable_from_trampoline_manifest( - script: &Path, -) -> miette::Result { - // Read the trampoline manifest - let manifest_path = script.with_extension("json"); - - let manifest_file = fs::File::open(&manifest_path).into_diagnostic()?; - let manifest_metadata: ManifestMetadata = - serde_json::from_reader(manifest_file).into_diagnostic()?; - - Ok(manifest_metadata.exe) -} - /// Warn user on dangerous package installations, interactive yes no prompt #[allow(unused)] pub(crate) fn prompt_user_to_continue( @@ -318,7 +284,6 @@ pub(crate) fn local_environment_matches_spec( #[cfg(test)] mod tests { - use fs_err as fs; use rattler_conda_types::{MatchSpec, ParseStrictness, Platform}; use rattler_lock::LockFile; use rstest::{fixture, rstest}; @@ -464,24 +429,32 @@ mod tests { ); } - #[cfg(unix)] - #[tokio::test] - async fn test_extract_executable_from_script_unix() { - let script = r#"#!/bin/sh -export PATH="/home/user/.pixi/envs/nushell/bin:${PATH}" -export CONDA_PREFIX="/home/user/.pixi/envs/nushell" -"/home/user/.pixi/envs/nushell/bin/nu" "$@" -"#; - let script_path = Path::new("nu"); - let tempdir = tempfile::tempdir().unwrap(); - let script_path = tempdir.path().join(script_path); - fs::write(&script_path, script).unwrap(); - let executable_path = extract_executable_from_trampoline_manifest(&script_path) - .await - .unwrap(); - assert_eq!( - executable_path, - Path::new("/home/user/.pixi/envs/nushell/bin/nu") - ); - } + // #[cfg(unix)] + // #[tokio::test] + // async fn test_extract_executable_from_script_unix() { + // let script_path = Path::new("nu"); + // let tempdir = tempfile::tempdir().unwrap(); + // let script_path = tempdir.path().join(script_path); + + // // create manifest that will contain real exe + // let real_script_exe = PathBuf::from("/home/user/.pixi/envs/nushell/bin/nu"); + // let real_script_root = PathBuf::from("/home/user/.pixi/envs/nushell/bin"); + + // let manifest_metadata = ManifestMetadata::new(real_script_exe, real_script_root, None); + + // // write down manifest + // let manifest_path = script_path.with_extension("json"); + // let manifest_file = fs::File::create(&manifest_path).unwrap(); + // serde_json::to_writer(manifest_file, &manifest_metadata).unwrap(); + + // // extract executable from script location + // let executable_path = extract_executable_from_trampoline_manifest(&script_path) + // .await + // .unwrap(); + + // assert_eq!( + // executable_path, + // Path::new("/home/user/.pixi/envs/nushell/bin/nu") + // ); + // } } diff --git a/src/global/project/mod.rs b/src/global/project/mod.rs index 37ae1bf47..7ee9e0064 100644 --- a/src/global/project/mod.rs +++ b/src/global/project/mod.rs @@ -1,4 +1,5 @@ -use super::install::extract_executable_from_trampoline_manifest; +// use super::install::extract_executable_from_trampoline_manifest; +use super::trampoline::GlobalBin; use super::{BinDir, EnvRoot, StateChange, StateChanges}; use crate::global::common::{ channel_url_to_prioritized_channel, find_package_records, get_expose_scripts_sync_status, @@ -102,18 +103,19 @@ struct ExposedData { } impl ExposedData { - /// Constructs an `ExposedData` instance from a exposed script path. + /// Constructs an `ExposedData` instance from a exposed `script` or `trampoline` path. /// /// This function extracts metadata from the exposed script path, including the /// environment name, platform, channel, and package information, by reading /// the associated `conda-meta` directory. + /// or it looks into the trampoline manifest to extract the metadata. pub async fn from_exposed_path( - path: &Path, + bin: &GlobalBin, env_root: &EnvRoot, channel_config: &ChannelConfig, ) -> miette::Result { - let exposed = ExposedName::from_str(executable_from_path(path).as_str())?; - let executable_path = extract_executable_from_trampoline_manifest(path).await?; + let exposed = bin.exposed_name(); + let executable_path = bin.executable().await?; let executable = executable_from_path(&executable_path); let env_path = determine_env_path(&executable_path, env_root.path())?; @@ -301,14 +303,14 @@ impl Project { let config = Config::load(env_root.path()); let exposed_binaries: Vec = bin_dir - .trampolines() + .bins() .await? .into_iter() - .map(|path| { + .map(|bin| { let env_root = env_root.clone(); let config = config.clone(); async move { - ExposedData::from_exposed_path(&path, &env_root, config.global_channel_config()) + ExposedData::from_exposed_path(&bin, &env_root, config.global_channel_config()) .await } }) @@ -559,14 +561,10 @@ impl Project { // Remove all removable binaries for binary_path in to_remove { - tokio_fs::remove_file(&binary_path) - .await - .into_diagnostic()?; + binary_path.remove(); state_changes.insert_change( env_name, - StateChange::RemovedExposed(ExposedName::from_str(&executable_from_path( - &binary_path, - ))?), + StateChange::RemovedExposed(binary_path.exposed_name()), ); } @@ -587,17 +585,15 @@ impl Project { let (to_remove, _to_add) = get_expose_scripts_sync_status(&self.bin_dir, &env_dir, &environment.exposed).await?; + eprintln!("to_remove: {:?}", to_remove); + eprintln!("to_add: {:?}", _to_add); // Remove all removable binaries for exposed_path in to_remove { state_changes.insert_change( env_name, - StateChange::RemovedExposed(ExposedName::from_str(&executable_from_path( - &exposed_path, - ))?), + StateChange::RemovedExposed(exposed_path.exposed_name()), ); - tokio_fs::remove_file(&exposed_path) - .await - .into_diagnostic()?; + exposed_path.remove(); } Ok(state_changes) @@ -788,7 +784,7 @@ impl Project { state_changes |= self.prune_old_environments().await?; // Remove broken scripts - if let Err(err) = self.remove_broken_scripts().await { + if let Err(err) = self.remove_broken_bins().await { tracing::warn!("Couldn't remove broken exposed executables: {err}") } @@ -822,10 +818,11 @@ impl Project { } /// Delete scripts in the bin folder that are broken - pub(crate) async fn remove_broken_scripts(&self) -> miette::Result<()> { - for exposed_path in self.bin_dir.trampolines().await? { - if extract_executable_from_trampoline_manifest(&exposed_path) - .await + pub(crate) async fn remove_broken_bins(&self) -> miette::Result<()> { + for exposed_bin in self.bin_dir.bins().await? { + let executable = exposed_bin.executable().await; + + if executable .and_then(|path| { if path.is_file() { Ok(path) @@ -835,9 +832,20 @@ impl Project { }) .is_err() { - tokio_fs::remove_file(exposed_path) + tokio_fs::remove_file(exposed_bin.path()) .await .into_diagnostic()?; + + if exposed_bin.is_trampoline() { + tokio_fs::remove_file( + exposed_bin + .trampoline() + .expect("we checked it") + .manifest_path(), + ) + .await + .into_diagnostic()?; + } } } @@ -875,9 +883,7 @@ impl Project { // Remove all removable binaries for binary_path in to_remove { - tokio_fs::remove_file(&binary_path) - .await - .into_diagnostic()?; + binary_path.remove(); } state_changes.insert_change(&env_name, StateChange::RemovedEnvironment); } @@ -924,6 +930,8 @@ impl Repodata for Project { mod tests { use std::{collections::HashMap, io::Write}; + use crate::global::trampoline::{ManifestMetadata, Trampoline}; + use super::*; use fake::{faker::filesystem::zh_tw::FilePath, Fake}; use itertools::Itertools; @@ -1011,53 +1019,60 @@ mod tests { let env_name = "test".parse().unwrap(); // Create non-exposed but related binary - let not_python = ExposedName::from_str("not-python").unwrap(); - let non_exposed_bin = project.bin_dir.executable_script_path(¬_python); - let mut file = fs::File::create(&non_exposed_bin).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let path = project.env_root.path().join("test/bin/not-python"); - file.write_all(format!(r#""{}" "$@""#, path.to_string_lossy()).as_bytes()) - .unwrap(); - // Set the file permissions to make it executable - let metadata = tokio_fs::metadata(&non_exposed_bin).await.unwrap(); - let mut permissions = metadata.permissions(); - permissions.set_mode(0o755); // rwxr-xr-x - tokio_fs::set_permissions(&non_exposed_bin, permissions) - .await - .unwrap(); - } - #[cfg(windows)] - { - let path = project.env_root.path().join("test/bin/not-python.exe"); - file.write_all(format!(r#"@"{}" %*"#, path.to_string_lossy()).as_bytes()) - .unwrap(); - } + // let non_exposed_bin_path = project.bin_dir.path().join("test/bin"); + let non_exposed_name = ExposedName::from_str("not-python").unwrap(); + + let non_exposed_env_path = project.env_root.path().join("test/bin/not-python"); + tokio_fs::create_dir_all(non_exposed_env_path.parent().unwrap()) + .await + .unwrap(); + tokio_fs::File::create(&non_exposed_env_path).await.unwrap(); - // Create a file that should be exposed + let non_exposed_manifest = ManifestMetadata::new( + non_exposed_env_path, + project.env_root.path().join("test/bin"), + None, + ); + let non_exposed_trampoline = Trampoline::new( + non_exposed_name.clone(), + project.bin_dir.path().to_path_buf(), + non_exposed_manifest, + ); + + eprintln!("non exposed trampoline {:?}", non_exposed_trampoline); + + eprintln!( + "trmapoline manifest path {:?}", + non_exposed_trampoline.manifest_path() + ); + + // write it's trampline and manifest + non_exposed_trampoline.save().await.unwrap(); + + // Create exposed binary let python = ExposedName::from_str("python").unwrap(); - let bin = project.bin_dir.executable_script_path(&python); - let mut file = fs::File::create(&bin).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let path = project.env_root.path().join("test/bin/python"); - file.write_all(format!(r#""{}" "$@""#, path.to_string_lossy()).as_bytes()) - .unwrap(); + let python_exposed_env_path = project.env_root.path().join("test/bin/python"); + eprintln!("python_exposed_env_path: {:?}", python_exposed_env_path); - // Set the file permissions to make it executable - let metadata = tokio_fs::metadata(&bin).await.unwrap(); - let mut permissions = metadata.permissions(); - permissions.set_mode(0o755); // rwxr-xr-x - tokio_fs::set_permissions(&bin, permissions).await.unwrap(); - } - #[cfg(windows)] - { - let path = project.env_root.path().join("test/bin/python.exe"); - file.write_all(format!(r#"@"{}" %*"#, path.to_string_lossy()).as_bytes()) - .unwrap(); - } + tokio_fs::create_dir_all(python_exposed_env_path.parent().unwrap()) + .await + .unwrap(); + tokio_fs::File::create(&python_exposed_env_path) + .await + .unwrap(); + + let exposed_manifest = ManifestMetadata::new( + python_exposed_env_path, + project.env_root.path().join("test/bin"), + None, + ); + let exposed_trampoline = Trampoline::new( + python, + project.bin_dir.path().to_path_buf(), + exposed_manifest, + ); + + exposed_trampoline.save().await.unwrap(); // Create unrelated file let unrelated = project.bin_dir.path().join("unrelated"); @@ -1069,15 +1084,16 @@ mod tests { state_changes.changes(), std::collections::HashMap::from([( env_name.clone(), - vec![StateChange::RemovedExposed(not_python)] + vec![StateChange::RemovedExposed(non_exposed_name)] )]) ); // Check if the non-exposed file was removed - assert_eq!(fs::read_dir(project.bin_dir.path()).unwrap().count(), 2); - assert!(bin.exists()); + // it should be : exposed binary + it's manifest and non related file + assert_eq!(fs::read_dir(project.bin_dir.path()).unwrap().count(), 3); + assert!(exposed_trampoline.path().exists()); assert!(unrelated.exists()); - assert!(!non_exposed_bin.exists()); + assert!(!non_exposed_trampoline.path().exists()); } #[tokio::test] diff --git a/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__duplicate_exposed.snap b/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__duplicate_exposed.snap index 8063ba668..4f5a60ead 100644 --- a/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__duplicate_exposed.snap +++ b/src/global/project/snapshots/pixi__global__project__parsed_manifest__tests__duplicate_exposed.snap @@ -2,4 +2,4 @@ source: src/global/project/parsed_manifest.rs expression: manifest.unwrap_err() --- -Duplicated exposed names found: python, python3 +Duplicated exposed names found: python, python3 diff --git a/src/global/trampoline.rs b/src/global/trampoline.rs index ab83c87b1..fc8c5f74b 100644 --- a/src/global/trampoline.rs +++ b/src/global/trampoline.rs @@ -1,6 +1,20 @@ -use std::{collections::HashMap, path::PathBuf}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + str::FromStr, +}; +use miette::IntoDiagnostic; +use once_cell::sync::Lazy; +use pixi_utils::executable_from_path; +use regex::Regex; use serde::{Deserialize, Serialize}; +// use tokio::fs::File; +use std::fs::File; + +use fs_err::tokio as tokio_fs; + +use super::ExposedName; #[cfg(target_arch = "aarch64")] #[cfg(target_os = "macos")] @@ -36,42 +50,215 @@ pub const TRAMPOLINE_BIN: &[u8] = include_bytes!( "../../crates/pixi_trampoline/trampolines/pixi-trampoline-x86_64-unknown-linux-musl" ); -#[derive(Serialize, Deserialize, Debug, PartialEq)] +// return file name of the executable +pub(crate) fn file_name(exposed_name: &ExposedName) -> String { + if cfg!(target_os = "windows") { + format!("{}.exe", exposed_name) + } else { + exposed_name.to_string() + } +} + +/// Extracts the executable path from a script file. +/// +/// This function reads the content of the script file and attempts to extract +/// the path of the executable it references. It is used to determine +/// the actual binary path from a wrapper script. +pub(crate) async fn extract_executable_from_script(script: &Path) -> miette::Result { + // Read the script file into a string + let script_content = tokio_fs::read_to_string(script).await.into_diagnostic()?; + + // Compile the regex pattern + #[cfg(unix)] + const PATTERN: &str = r#""([^"]+)" "\$@""#; + // The pattern includes `"?` to also find old pixi global installations. + #[cfg(windows)] + const PATTERN: &str = r#"@"?([^"]+)"? %/*"#; + static RE: Lazy = Lazy::new(|| Regex::new(PATTERN).expect("Failed to compile regex")); + + // Apply the regex to the script content + if let Some(caps) = RE.captures(&script_content) { + if let Some(matched) = caps.get(1) { + return Ok(PathBuf::from(matched.as_str())); + } + } + tracing::debug!( + "Failed to extract executable path from script {}", + script_content + ); + + // Return an error if the executable path couldn't be extracted + miette::bail!( + "Failed to extract executable path from script {}", + script.display() + ) +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub struct ManifestMetadata { pub exe: PathBuf, - pub path: String, + pub path: PathBuf, pub env: HashMap, } +impl ManifestMetadata { + pub fn new(exe: PathBuf, path: PathBuf, env: Option>) -> Self { + ManifestMetadata { + exe, + path, + env: env.unwrap_or_default(), + } + } + + pub fn from_root_path(root_path: PathBuf, exposed_name: &ExposedName) -> Self { + let manifest_path = root_path.join(exposed_name.to_string() + ".json"); + eprintln!("manifest_path: {:?}", manifest_path); + let reader_file = + std::fs::File::open(&manifest_path).expect("should be able to open manifest file"); + serde_json::from_reader(reader_file).unwrap() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GlobalBin { + Trampoline(Trampoline), + Script(PathBuf), +} + +impl GlobalBin { + pub async fn executable(&self) -> miette::Result { + Ok(match self { + GlobalBin::Trampoline(trampoline) => trampoline.original_exe(), + GlobalBin::Script(script) => extract_executable_from_script(script).await?, + }) + } + + pub fn exposed_name(&self) -> ExposedName { + match self { + GlobalBin::Trampoline(trampoline) => trampoline.exposed_name.clone(), + GlobalBin::Script(script) => { + ExposedName::from_str(&executable_from_path(script)).unwrap() + } + } + } + + pub fn path(&self) -> PathBuf { + match self { + GlobalBin::Trampoline(trampoline) => trampoline.path(), + GlobalBin::Script(script) => script.clone(), + } + } + + pub fn is_trampoline(&self) -> bool { + matches!(self, GlobalBin::Trampoline(_)) + } + + pub fn trampoline(&self) -> Option<&Trampoline> { + match self { + GlobalBin::Trampoline(trampoline) => Some(trampoline), + _ => None, + } + } + + pub fn remove(&self) { + match self { + GlobalBin::Trampoline(trampoline) => { + let _ = std::fs::remove_file(trampoline.path()); + let _ = std::fs::remove_file(trampoline.manifest_path()); + } + GlobalBin::Script(script) => { + let _ = std::fs::remove_file(script); + } + } + } +} + #[allow(dead_code)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct Trampoline { - binary_data: &'static [u8], + // Exposed name of the trampoline + exposed_name: ExposedName, + // Root path where the trampoline is stored + root_path: PathBuf, + // Metadata of the trampoline + metadata: ManifestMetadata, } -#[allow(dead_code)] impl Trampoline { - pub fn new() -> Self { - let binary_data = TRAMPOLINE_BIN; - Trampoline { binary_data } + pub fn new(exposed_name: ExposedName, root_path: PathBuf, metadata: ManifestMetadata) -> Self { + Trampoline { + exposed_name, + root_path, + metadata, + } } - pub fn get_binary_size(&self) -> usize { - self.binary_data.len() + pub fn from(trampoline_path: PathBuf) -> Self { + let exposed_name = ExposedName::from_str(&executable_from_path(&trampoline_path)) + .expect("should have a valid exposed name"); + let parent_path = trampoline_path + .parent() + .expect("trampoline should have a parent path") + .to_path_buf(); + + let metadata = ManifestMetadata::from_root_path(parent_path.clone(), &exposed_name); + + Trampoline::new(exposed_name, parent_path, metadata) } - // Add more methods as needed for your specific use case -} + // return the path to the trampoline + pub fn path(&self) -> PathBuf { + self.root_path.join(file_name(&self.exposed_name)) + } -#[cfg(test)] -mod tests { - use super::*; + pub fn original_exe(&self) -> PathBuf { + self.metadata.exe.clone() + } + + // return the path to the trampoline manifest + pub fn manifest_path(&self) -> PathBuf { + self.root_path.join(self.exposed_name.to_string() + ".json") + } + + pub async fn save(&self) -> miette::Result<()> { + self.write_trampoline().await?; + self.write_manifest()?; + Ok(()) + } + + async fn write_trampoline(&self) -> miette::Result<()> { + tokio::fs::write(self.path(), TRAMPOLINE_BIN) + .await + .into_diagnostic()?; - #[test] - fn test_trampoline_creation() { - let trampoline = Trampoline::new(); - assert!( - trampoline.get_binary_size() > 0, - "Binary should not be empty" - ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(self.path(), std::fs::Permissions::from_mode(0o755)) + .into_diagnostic()?; + } + + Ok(()) + } + + fn write_manifest(&self) -> miette::Result<()> { + let manifest_file = File::create(self.manifest_path()).into_diagnostic()?; + serde_json::to_writer_pretty(manifest_file, &self.metadata).into_diagnostic()?; + + Ok(()) } } + +// #[cfg(test)] +// mod tests { +// use super::*; + +// #[test] +// fn test_trampoline_creation() { +// let trampoline = Trampoline::new(); +// assert!( +// trampoline.get_binary_size() > 0, +// "Binary should not be empty" +// ); +// } +// } diff --git a/src/prefix.rs b/src/prefix.rs index 465fa51d7..392d1c8c2 100644 --- a/src/prefix.rs +++ b/src/prefix.rs @@ -41,8 +41,6 @@ impl Prefix { .into_diagnostic() .context("failed to constructor environment activator")?; - eprintln!("activation scripts {:?}", activator.activation_scripts); - activator .run_activation(ActivationVariables::from_env().unwrap_or_default(), None) .into_diagnostic() diff --git a/tests/integration/test_global.py b/tests/integration/test_global.py index 61172077d..9052eab45 100644 --- a/tests/integration/test_global.py +++ b/tests/integration/test_global.py @@ -955,6 +955,7 @@ def test_pixi_install_cleanup(pixi: Path, tmp_path: Path, global_update_channel_ [pixi, "global", "install", "--channel", global_update_channel_1, "package==0.2.0"], env=env, ) + assert not package0_1_0.is_file() assert package0_2_0.is_file()