Skip to content
Open
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
1 change: 1 addition & 0 deletions crates/vt_bin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ impl vt::CommandHandler for CommandHandler {
let program = find_executable(get_path_env(&command.envs), &command.cwd, "vtt")?;
Ok(HandledCommand::Synthesized(SyntheticPlanRequest {
program,
tool_entry: None,
args: args.into_iter().filter(|a| a.as_str() != "--").collect(),
cache_config: UserCacheConfig::with_config(EnabledCacheConfig {
env: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/vt_plan/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ pub struct PathFingerprintError {
/// Errors that can occur when planning a specific execution from a task.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Tool entry path is not valid UTF-8: {path}")]
NonUtf8ToolEntry { path: Arc<AbsolutePath> },

#[error("Failed to plan tasks from `{command}` in task {task_display}")]
NestPlan {
task_display: TaskDisplay,
Expand Down
1 change: 1 addition & 0 deletions crates/vt_plan/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod plan;
pub mod plan_request;
mod ps1_shim;
pub mod remote_cache;
mod tool_entry;

use std::{collections::BTreeMap, ffi::OsStr, fmt::Debug, sync::Arc};

Expand Down
10 changes: 9 additions & 1 deletion crates/vt_plan/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,15 @@ pub fn plan_synthetic_request(
parent_cache_config: ParentCacheConfig,
resolved_remote_cache: Option<&ResolvedRemoteCacheConfig>,
) -> Result<SpawnExecution, Error> {
let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request;
let SyntheticPlanRequest { program, args, tool_entry, cache_config, envs } =
synthetic_plan_request;
let args = if let Some(entry) = tool_entry {
std::iter::once(crate::tool_entry::entry_argument(&entry, cwd, workspace_path)?)
.chain(args.iter().cloned())
.collect()
} else {
args
};

let program_path = which(&program, &envs, cwd)?;
let (program_path, args) =
Expand Down
11 changes: 10 additions & 1 deletion crates/vt_plan/src/plan_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ impl ScriptCommand {
SyntheticPlanRequest {
program: Arc::from(OsStr::new(&self.program)),
args: self.args.clone(),
tool_entry: None,
cache_config,
envs: self.envs.clone(),
}
Expand Down Expand Up @@ -84,9 +85,17 @@ pub struct SyntheticPlanRequest {
/// The program to execute
pub program: Arc<OsStr>,

/// The arguments to pass to the program
/// The arguments to pass after `tool_entry`, or directly to the program when absent.
pub args: Arc<[Str]>,

/// A known executable script loaded by `program`, inserted before `args`.
///
/// Producers supply the absolute entry path, not a duplicate in `args`. The planner
/// makes an in-workspace entry relative to the actual task cwd for both execution
/// and caching. External paths and paths containing parent traversal stay absolute.
/// `None` preserves ordinary argument handling; arguments are never inspected for paths.
pub tool_entry: Option<Arc<AbsolutePath>>,

/// The cache config as if it's defined in `vite.config.*`
pub cache_config: UserCacheConfig,

Expand Down
37 changes: 37 additions & 0 deletions crates/vt_plan/src/tool_entry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use std::{path::Component, sync::Arc};

use vt_path::{AbsolutePath, RelativePathBuf};
use vt_str::Str;

use crate::Error;

pub fn entry_argument(
entry: &Arc<AbsolutePath>,
cwd: &AbsolutePath,
workspace: &AbsolutePath,
) -> Result<Str, Error> {
let absolute = entry
.as_path()
.to_str()
.ok_or_else(|| Error::NonUtf8ToolEntry { path: Arc::clone(entry) })?;
// Do not collapse `link/..`: lexical normalization can change the executed file.
if [entry.as_ref(), cwd, workspace]
.iter()
.any(|path| path.as_path().components().any(|component| component == Component::ParentDir))
{
return Ok(Str::from(absolute));
}
if let (Ok(Some(entry_relative)), Ok(Some(cwd_relative))) =
(entry.strip_prefix(workspace), cwd.strip_prefix(workspace))
&& let Some(relative) =
pathdiff::diff_paths(entry_relative.as_path(), cwd_relative.as_path())
&& let Ok(relative) = RelativePathBuf::new(relative)
{
// A leading `./` keeps an entry named `-tool.js` from being read as an option.
if relative.as_str().starts_with("..") {
return Ok(Str::from(relative.as_str()));
}
return Ok(vt_str::format!("./{}", relative.as_str()));
}
Ok(Str::from(absolute))
}
191 changes: 191 additions & 0 deletions crates/vt_plan/tests/tool_entry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
use std::{ffi::OsStr, sync::Arc};

use vt_graph::config::UserCacheConfig;
use vt_path::{AbsolutePath, AbsolutePathBuf};
use vt_plan::{SpawnExecution, plan_request::SyntheticPlanRequest, plan_synthetic};
use vt_str::Str;

fn root() -> Arc<AbsolutePath> {
AbsolutePathBuf::new(std::env::temp_dir()).unwrap().join("tool-entry-workspace").into()
}

fn plan(
workspace: &Arc<AbsolutePath>,
cwd: &Arc<AbsolutePath>,
entry: Option<Arc<AbsolutePath>>,
args: Arc<[Str]>,
) -> SpawnExecution {
let program = std::env::current_exe().unwrap().into_os_string().into();
plan_synthetic(
workspace,
cwd,
SyntheticPlanRequest {
program,
args,
tool_entry: entry,
cache_config: UserCacheConfig::Bool(true),
envs: Arc::default(),
},
Arc::from([Str::from("tool")]),
)
.unwrap()
}

#[test]
fn tool_entry_uses_task_cwd_and_identical_spawn_and_cache_arguments() {
let temp = tempfile::tempdir().unwrap();
let workspace: Arc<AbsolutePath> =
AbsolutePathBuf::new(temp.path().to_path_buf()).unwrap().into();
let entry: Arc<AbsolutePath> = workspace.join("node_modules/tool/bin.js").into();
std::fs::create_dir_all(entry.parent().unwrap()).unwrap();
std::fs::create_dir_all(workspace.join("packages/nested")).unwrap();
std::fs::write(entry.as_path(), "tool entry").unwrap();
for (cwd, expected) in [
(Arc::clone(&workspace), "./node_modules/tool/bin.js"),
(workspace.join("packages/nested").into(), "../../node_modules/tool/bin.js"),
] {
let args: Arc<[Str]> = Arc::from([
Str::from("--config"),
Str::from(entry.as_path().to_str().unwrap()),
Str::from("../untouched"),
]);
let planned = plan(&workspace, &cwd, Some(Arc::clone(&entry)), Arc::clone(&args));
assert_eq!(planned.spawn_command.cwd, cwd);
assert_eq!(planned.spawn_command.args[0], expected);
assert_eq!(
std::fs::read_to_string(cwd.join(planned.spawn_command.args[0].as_str())).unwrap(),
"tool entry"
);
assert_eq!(&planned.spawn_command.args[1..], args.as_ref());
assert_eq!(
planned.cache_metadata.unwrap().spawn_fingerprint.args().as_ref(),
planned.spawn_command.args.as_ref()
);
}
}

#[test]
fn tool_entry_relocation_has_the_same_spawn_fingerprint() {
let a = root();
let b: Arc<AbsolutePath> = a.parent().unwrap().join("other-workspace").into();
let fingerprints: Vec<_> = [Arc::clone(&a), b]
.iter()
.map(|workspace| {
plan(
workspace,
&workspace.join("packages/nested").into(),
Some(workspace.join("node_modules/tool/bin.js").into()),
Arc::from([Str::from("run")]),
)
.cache_metadata
.unwrap()
.spawn_fingerprint
})
.collect();
assert_eq!(fingerprints[0], fingerprints[1]);
for (entry, args) in [
("node_modules/other/bin.js", Arc::from([Str::from("run")])),
("node_modules/tool/bin.js", Arc::from([Str::from("build")])),
] {
let changed = plan(&a, &a.join("packages/nested").into(), Some(a.join(entry).into()), args);
assert_ne!(fingerprints[0], changed.cache_metadata.unwrap().spawn_fingerprint);
}
}

#[test]
fn tool_entry_preserves_external_and_traversal_paths() {
let workspace = root();
for entry in [
workspace.parent().unwrap().join("tool-entry-workspace-other/bin.js"),
workspace.join("../external/bin.js"),
workspace.join("node_modules/link/../bin.js"),
] {
let entry: Arc<AbsolutePath> = entry.into();
let planned = plan(&workspace, &workspace, Some(Arc::clone(&entry)), Arc::default());
assert_eq!(OsStr::new(planned.spawn_command.args[0].as_str()), entry.as_path());
}
}

#[test]
fn tool_entry_absent_preserves_all_arguments() {
let workspace = root();
let args: Arc<[Str]> = Arc::from([
Str::from(workspace.join("node_modules/tool/bin.js").as_path().to_str().unwrap()),
Str::from("--entry=/absolute/opaque.js"),
]);
let planned = plan(&workspace, &workspace, None, Arc::clone(&args));
assert_eq!(planned.spawn_command.args, args);
}

#[test]
fn tool_entry_option_like_filename_stays_a_path() {
let workspace = root();
let planned =
plan(&workspace, &workspace, Some(workspace.join("-tool.js").into()), Arc::default());
assert_eq!(planned.spawn_command.args[0], "./-tool.js");
}

#[test]
fn ordinary_script_conversion_does_not_declare_a_tool_entry() {
let command = vt_plan::plan_request::ScriptCommand {
program: Str::from("node"),
args: Arc::from([Str::from("/opaque/script.js")]),
envs: Arc::default(),
cwd: root(),
};
let request = command.to_synthetic_plan_request(UserCacheConfig::Bool(true));
assert!(request.tool_entry.is_none());
assert_eq!(request.args, command.args);
}

#[test]
fn non_entry_absolute_arguments_remain_location_sensitive() {
let workspace = root();
let fingerprints: Vec<_> = ["first", "second"]
.iter()
.map(|name| {
let entry: Arc<AbsolutePath> = workspace.join(name).join("bin.js").into();
plan(
&workspace,
&workspace,
None,
Arc::from([Str::from(entry.as_path().to_str().unwrap())]),
)
.cache_metadata
.unwrap()
.spawn_fingerprint
})
.collect();
assert_ne!(fingerprints[0], fingerprints[1]);
}

#[test]
fn tool_entry_rejects_non_utf8_without_lossy_conversion() {
#[cfg(unix)]
let invalid = {
use std::os::unix::ffi::OsStringExt as _;
std::ffi::OsString::from_vec(vec![0xff])
};
#[cfg(windows)]
let invalid = {
use std::os::windows::ffi::OsStringExt as _;
std::ffi::OsString::from_wide(&[0xd800])
};
let workspace = root();
let entry: Arc<AbsolutePath> = workspace.join(invalid).into();
for enabled in [false, true] {
let result = plan_synthetic(
&workspace,
&workspace,
SyntheticPlanRequest {
program: std::env::current_exe().unwrap().into_os_string().into(),
args: Arc::default(),
tool_entry: Some(Arc::clone(&entry)),
cache_config: UserCacheConfig::Bool(enabled),
envs: Arc::default(),
},
Arc::from([Str::from("tool")]),
);
assert!(matches!(result, Err(vt_plan::Error::NonUtf8ToolEntry { path }) if path == entry));
}
}