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
4 changes: 2 additions & 2 deletions crates/pm/src/helper/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::util::config::get_legacy_peer_deps;
use crate::util::json::{load_package_json_from_path, load_package_lock_json_from_path};
use crate::util::logger::{finish_progress_bar, start_progress_bar};
use crate::util::save_type::{PackageAction, SaveType};
use crate::util::{cloner::clone, downloader::download};
use crate::util::{cloner::clone_package, downloader::download};
use utoo_ruborist::lock::{LockPackage, PackageLock};
use utoo_ruborist::manifest::PackageJson;
use utoo_ruborist::registry::resolve_package;
Expand Down Expand Up @@ -263,7 +263,7 @@ pub async fn prepare_global_package_json(npm_spec: &str, prefix: Option<&str>) -
cache_path.display(),
package_path.display()
);
clone(&cache_path, &package_path, true)
clone_package(&cache_path, &package_path, &name, &resolved.version)
.await
.context("Failed to clone package")?;

Expand Down
8 changes: 5 additions & 3 deletions crates/pm/src/service/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::helper::workspace;
use crate::model::package::PackageInfo;
use crate::service::rebuild::RebuildService;
use crate::util::cache::get_cache_dir;
use crate::util::cloner::clone;
use crate::util::cloner::clone_package;
use crate::util::downloader::download;
use crate::util::linker::link;
use crate::util::logger::{PROGRESS_BAR, finish_progress_bar, log_progress, start_progress_bar};
Expand Down Expand Up @@ -337,7 +337,7 @@ pub async fn install_packages(
let name = package.get_name(&path);
let version = package
.version
.as_ref()
.clone()
.ok_or_else(|| anyhow::anyhow!("package {name} missing version"))?;
let cache_path = cache_dir.join(format!("{name}/{version}"));
let cache_flag_path = cache_dir.join(format!("{name}/{version}/_resolved"));
Expand Down Expand Up @@ -375,7 +375,9 @@ pub async fn install_packages(
}

tracing::debug!("{name} clone");
match clone(&cache_path, &cwd_clone.join(&path), true).await {
match clone_package(&cache_path, &cwd_clone.join(&path), &name, &version)
.await
{
Ok(_) => {
tracing::debug!("{name} resolved");
PROGRESS_BAR.inc(1);
Expand Down
175 changes: 173 additions & 2 deletions crates/pm/src/util/cloner.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

use super::json::load_package_json_from_path;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::retry::create_retry_strategy;
use crate::fs;
Expand Down Expand Up @@ -335,7 +336,7 @@ mod windows_clone {
}
}

pub async fn validate_directory(src: &Path, dst: &Path) -> Result<bool> {
async fn validate_directory(src: &Path, dst: &Path) -> Result<bool> {
if !crate::fs::try_exists(dst).await? {
return Ok(false);
}
Expand Down Expand Up @@ -458,7 +459,7 @@ pub async fn find_real_src<P: AsRef<Path>>(src: P) -> Option<PathBuf> {
None
}

pub async fn clone(src: &Path, dst: &Path, find_real: bool) -> Result<()> {
async fn clone(src: &Path, dst: &Path, find_real: bool) -> Result<()> {
let real_src = if find_real {
find_real_src(src)
.await
Expand Down Expand Up @@ -555,6 +556,41 @@ pub async fn clone(src: &Path, dst: &Path, find_real: bool) -> Result<()> {
Ok(())
}

/// Validate that the package.json in dst has matching name and version
async fn validate_name_version(dst: &Path, name: &str, version: &str) -> bool {
let Ok(pkg) = load_package_json_from_path(dst).await else {
return false;
};
pkg.get("name").and_then(|v| v.as_str()) == Some(name)
&& pkg.get("version").and_then(|v| v.as_str()) == Some(version)
}

/// Clone a package from cache to destination with name/version validation
pub async fn clone_package(src: &Path, dst: &Path, name: &str, version: &str) -> Result<()> {
match crate::fs::try_exists(dst).await? {
true if validate_name_version(dst, name, version).await => {
tracing::debug!(
"Package {}@{} already exists at {}, skipping clone",
name,
version,
dst.display()
);
Ok(())
}
true => {
tracing::debug!(
"Package at {} has mismatched name/version, removing and re-cloning",
dst.display()
);
if let Err(e) = fs::remove_dir_all(dst).await {
tracing::warn!("Failed to clean target directory {}: {}", dst.display(), e);
}
clone(src, dst, true).await
}
false => clone(src, dst, true).await,
}
}
Comment thread
elrrrrrrr marked this conversation as resolved.

// Standard directory copy for non-macOS/Linux/Windows platforms
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
async fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
Expand Down Expand Up @@ -785,6 +821,141 @@ mod tests {
Ok(())
}

fn create_package_json(name: &str, version: &str) -> String {
format!(r#"{{"name": "{}", "version": "{}"}}"#, name, version)
}

#[tokio::test]
async fn test_validate_name_version_matching() -> Result<()> {
let temp = TempDir::new()?;
let pkg_dir = temp.path().join("pkg");
fs::create_dir_all(&pkg_dir).await?;

let pkg_json = create_package_json("lodash", "4.17.21");
fs::write(pkg_dir.join("package.json"), pkg_json).await?;

assert!(validate_name_version(&pkg_dir, "lodash", "4.17.21").await);
Ok(())
}

#[tokio::test]
async fn test_validate_name_version_name_mismatch() -> Result<()> {
let temp = TempDir::new()?;
let pkg_dir = temp.path().join("pkg");
fs::create_dir_all(&pkg_dir).await?;

let pkg_json = create_package_json("lodash", "4.17.21");
fs::write(pkg_dir.join("package.json"), pkg_json).await?;

assert!(!validate_name_version(&pkg_dir, "underscore", "4.17.21").await);
Ok(())
}

#[tokio::test]
async fn test_validate_name_version_version_mismatch() -> Result<()> {
let temp = TempDir::new()?;
let pkg_dir = temp.path().join("pkg");
fs::create_dir_all(&pkg_dir).await?;

let pkg_json = create_package_json("lodash", "4.17.21");
fs::write(pkg_dir.join("package.json"), pkg_json).await?;

assert!(!validate_name_version(&pkg_dir, "lodash", "4.17.20").await);
Ok(())
}

#[tokio::test]
async fn test_validate_name_version_no_package_json() -> Result<()> {
let temp = TempDir::new()?;
let pkg_dir = temp.path().join("pkg");
fs::create_dir_all(&pkg_dir).await?;

// No package.json file
assert!(!validate_name_version(&pkg_dir, "lodash", "4.17.21").await);
Ok(())
}

#[tokio::test]
async fn test_clone_package_skip_if_valid() -> Result<()> {
let temp = TempDir::new()?;
// Cache structure: cache_dir/package/ (find_real looks for first subdir)
let cache_dir = temp.path().join("cache/lodash/4.17.21");
let src_dir = cache_dir.join("package");
let dst_dir = temp.path().join("node_modules/lodash");

// Create source (with subdir structure that find_real expects)
fs::create_dir_all(&src_dir).await?;
let pkg_json = create_package_json("lodash", "4.17.21");
fs::write(src_dir.join("package.json"), &pkg_json).await?;
fs::write(src_dir.join("index.js"), "module.exports = {}").await?;

// Create destination with same content
fs::create_dir_all(&dst_dir).await?;
fs::write(dst_dir.join("package.json"), &pkg_json).await?;
fs::write(dst_dir.join("index.js"), "module.exports = {}").await?;

// Add a marker file to verify it wasn't re-cloned
fs::write(dst_dir.join("marker.txt"), "original").await?;

clone_package(&cache_dir, &dst_dir, "lodash", "4.17.21").await?;

// Marker file should still exist (wasn't deleted and re-cloned)
assert!(dst_dir.join("marker.txt").exists());
Ok(())
}

#[tokio::test]
async fn test_clone_package_reclone_if_version_mismatch() -> Result<()> {
let temp = TempDir::new()?;
let cache_dir = temp.path().join("cache/lodash/4.17.21");
let src_dir = cache_dir.join("package");
let dst_dir = temp.path().join("node_modules/lodash");

// Create source with new version
fs::create_dir_all(&src_dir).await?;
let new_pkg_json = create_package_json("lodash", "4.17.21");
fs::write(src_dir.join("package.json"), &new_pkg_json).await?;

// Create destination with old version
fs::create_dir_all(&dst_dir).await?;
let old_pkg_json = create_package_json("lodash", "4.17.20");
fs::write(dst_dir.join("package.json"), &old_pkg_json).await?;
fs::write(dst_dir.join("marker.txt"), "should be deleted").await?;

clone_package(&cache_dir, &dst_dir, "lodash", "4.17.21").await?;

// Marker file should be gone (directory was deleted and re-cloned)
assert!(!dst_dir.join("marker.txt").exists());
// New package.json should have correct version
let content = fs::read_to_string(dst_dir.join("package.json")).await?;
assert!(content.contains("4.17.21"));
Ok(())
}

#[tokio::test]
async fn test_clone_package_fresh_install() -> Result<()> {
let temp = TempDir::new()?;
let cache_dir = temp.path().join("cache/lodash/4.17.21");
let src_dir = cache_dir.join("package");
let dst_dir = temp.path().join("node_modules/lodash");

// Create source
fs::create_dir_all(&src_dir).await?;
let pkg_json = create_package_json("lodash", "4.17.21");
fs::write(src_dir.join("package.json"), &pkg_json).await?;

// Destination doesn't exist
assert!(!dst_dir.exists());

clone_package(&cache_dir, &dst_dir, "lodash", "4.17.21").await?;

// Should be cloned
assert!(dst_dir.join("package.json").exists());
let content = fs::read_to_string(dst_dir.join("package.json")).await?;
assert!(content.contains("lodash"));
Ok(())
}

#[cfg(target_os = "linux")]
mod linux_tests {
use super::*;
Expand Down
Loading