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
81 changes: 75 additions & 6 deletions crates/turborepo-lib/src/commands/prune.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use globwalk::{ValidatedGlob, WalkType};
use miette::Diagnostic;
use tracing::trace;
use turbopath::{
AbsoluteSystemPathBuf, AnchoredSystemPath, AnchoredSystemPathBuf, RelativeUnixPath,
RelativeUnixPathBuf,
AbsoluteSystemPath, AbsoluteSystemPathBuf, AnchoredSystemPath, AnchoredSystemPathBuf,
RelativeUnixPath, RelativeUnixPathBuf,
};
use turborepo_repository::{
package_graph::{self, PackageGraph, PackageName, PackageNode},
Expand Down Expand Up @@ -54,6 +54,10 @@ pub enum Error {
NoWorkspaceSpecified,
#[error("Invalid scope. Package with name {0} in `package.json` not found.")]
MissingWorkspace(PackageName),
#[error(
"Invalid patched dependency path `{0}`: path escapes the repository or output directory"
)]
InvalidPatchPath(RelativeUnixPathBuf),
#[error("Cannot prune without parsed lockfile.")]
MissingLockfile,
#[error("Unable to read config: {0}")]
Expand Down Expand Up @@ -304,10 +308,7 @@ pub async fn prune(

if !original_patches.is_empty() {
for patch in &pruned_patches {
prune.copy_file(
&patch.to_anchored_system_path_buf(),
Some(CopyDestination::Docker),
)?;
prune.copy_patch_file(patch)?;
}

// Prune pnpm-workspace.yaml's patchedDependencies so it only
Expand Down Expand Up @@ -392,9 +393,33 @@ fn collect_patch_paths(

patches.sort();
patches.dedup();
validate_patch_source_paths(repo_root, &patches)?;
Ok(patches)
}

fn validate_patch_source_paths(
repo_root: &AbsoluteSystemPath,
patches: &[RelativeUnixPathBuf],
) -> Result<(), Error> {
let repo_root_realpath = repo_root.to_realpath()?;

for patch in patches {
let patch_path = repo_root.join_unix_path(patch);
if !patch_path.starts_with(repo_root.as_std_path()) {
return Err(Error::InvalidPatchPath(patch.clone()));
}

if patch_path.try_exists()? {
let patch_realpath = patch_path.to_realpath()?;
if !patch_realpath.starts_with(repo_root_realpath.as_std_path()) {
return Err(Error::InvalidPatchPath(patch.clone()));
}
}
}

Ok(())
}

fn package_json_patch_paths(
package_json: &PackageJson,
patch_keys: &[String],
Expand Down Expand Up @@ -569,6 +594,50 @@ impl<'a> Prune<'a> {
Ok(())
}

fn copy_patch_file(&self, patch: &RelativeUnixPathBuf) -> Result<(), Error> {
self.validate_patch_destination_path(patch, &self.full_directory)?;
if self.docker {
self.validate_patch_destination_path(patch, &self.docker_directory())?;
}

self.copy_file(
&patch.to_anchored_system_path_buf(),
Some(CopyDestination::Docker),
)
}

fn validate_patch_destination_path(
&self,
patch: &RelativeUnixPathBuf,
destination_root: &AbsoluteSystemPath,
) -> Result<(), Error> {
let destination_root_realpath = destination_root.to_realpath()?;
let patch_path = destination_root.join_unix_path(patch);

if !patch_path.starts_with(destination_root.as_std_path()) {
return Err(Error::InvalidPatchPath(patch.clone()));
}

if patch_path.symlink_metadata().is_ok() {
let patch_realpath = patch_path.to_realpath()?;
if !patch_realpath.starts_with(destination_root_realpath.as_std_path()) {
return Err(Error::InvalidPatchPath(patch.clone()));
}
}

for ancestor in patch_path.ancestors().skip(1) {
if ancestor.try_exists()? {
let ancestor_realpath = ancestor.to_realpath()?;
if !ancestor_realpath.starts_with(destination_root_realpath.as_std_path()) {
return Err(Error::InvalidPatchPath(patch.clone()));
}
break;
}
}

Ok(())
}

fn copy_directory(
&self,
path: &AnchoredSystemPath,
Expand Down
92 changes: 91 additions & 1 deletion crates/turborepo/tests/prune_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ mod common;

use std::{fs, path::Path};

use common::{run_turbo, setup};
use common::{combined_output, run_turbo, setup};

fn ls_dir(dir: &Path) -> Vec<String> {
let mut entries: Vec<String> = fs::read_dir(dir)
Expand Down Expand Up @@ -81,6 +81,96 @@ fn test_prune_docker() {
);
}

#[test]
fn test_prune_rejects_patch_paths_with_parent_dir() {
let tempdir = tempfile::tempdir().unwrap();
setup::copy_fixture("monorepo_with_root_dep", tempdir.path()).unwrap();

let outside_dir = tempfile::tempdir_in(tempdir.path().parent().unwrap()).unwrap();
let outside_dir_name = outside_dir
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
let outside_patch_name = "outside.patch";
let malicious_patch_path = format!("../{outside_dir_name}/{outside_patch_name}");
fs::write(
outside_dir.path().join(outside_patch_name),
"outside repo\n",
)
.unwrap();

let lockfile_path = tempdir.path().join("pnpm-lock.yaml");
let lockfile = fs::read_to_string(&lockfile_path).unwrap();
fs::write(
&lockfile_path,
lockfile.replace("patches/is-number@7.0.0.patch", &malicious_patch_path),
)
.unwrap();

let package_json_path = tempdir.path().join("package.json");
let package_json = fs::read_to_string(&package_json_path).unwrap();
fs::write(
&package_json_path,
package_json.replace("patches/is-number@7.0.0.patch", &malicious_patch_path),
)
.unwrap();

let output = run_turbo(tempdir.path(), &["prune", "web"]);
let combined_output = combined_output(&output);

assert!(!output.status.success());
assert!(
combined_output.contains("Invalid patched dependency path"),
"unexpected output: {combined_output}"
);
assert!(
!tempdir
.path()
.join(&outside_dir_name)
.join(outside_patch_name)
.exists()
);
}

#[test]
fn test_prune_allows_patch_paths_with_non_escaping_parent_dir() {
let tempdir = tempfile::tempdir().unwrap();
setup::copy_fixture("monorepo_with_root_dep", tempdir.path()).unwrap();

let patch_path = "patches/../patches/is-number@7.0.0.patch";
let lockfile_path = tempdir.path().join("pnpm-lock.yaml");
let lockfile = fs::read_to_string(&lockfile_path).unwrap();
fs::write(
&lockfile_path,
lockfile.replace("patches/is-number@7.0.0.patch", patch_path),
)
.unwrap();

let package_json_path = tempdir.path().join("package.json");
let package_json = fs::read_to_string(&package_json_path).unwrap();
fs::write(
&package_json_path,
package_json.replace("patches/is-number@7.0.0.patch", patch_path),
)
.unwrap();

let output = run_turbo(tempdir.path(), &["prune", "web"]);

assert!(
output.status.success(),
"prune failed: {}",
combined_output(&output)
);
assert!(
tempdir
.path()
.join("out/patches/is-number@7.0.0.patch")
.exists()
);
}

#[test]
fn test_prune_docker_creates_bin_stubs() {
let tempdir = tempfile::tempdir().unwrap();
Expand Down
Loading