From 86286da63b5368db7bf235c1f25e003ebc9a5a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Wed, 20 May 2026 00:51:49 +0200 Subject: [PATCH 1/4] Implements the concept of trusted project --- package.json | 1 + packages/zpm-switch/src/commands/mod.rs | 2 + .../src/commands/switch/cache_list.rs | 68 +++++---- .../zpm-switch/src/commands/switch/mod.rs | 1 + .../zpm-switch/src/commands/switch/trust.rs | 80 +++++++++++ packages/zpm-switch/src/errors.rs | 3 + packages/zpm-switch/src/links.rs | 131 ++++++++++++++---- packages/zpm/src/build.rs | 1 + packages/zpm/src/error.rs | 6 + packages/zpm/src/report.rs | 15 +- packages/zpm/src/script.rs | 126 ++++++++++++++++- .../sources/commands/switch/trust.test.ts | 96 +++++++++++++ yarn.lock | 17 ++- 13 files changed, 487 insertions(+), 60 deletions(-) create mode 100644 packages/zpm-switch/src/commands/switch/trust.rs create mode 100644 tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts diff --git a/package.json b/package.json index 2d8a6baf..fb1de4ec 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dependencies": { "@yarnpkg/eslint-config": "^3.1.0", "clipanion": "^4.0.0-rc.4", + "demo-postinstall-test": "^1.3.0", "eslint": "^9.36.0" }, "resolutions": { diff --git a/packages/zpm-switch/src/commands/mod.rs b/packages/zpm-switch/src/commands/mod.rs index 383f118b..7d4ac8b9 100644 --- a/packages/zpm-switch/src/commands/mod.rs +++ b/packages/zpm-switch/src/commands/mod.rs @@ -29,6 +29,8 @@ enum SwitchExecCli { LinkMigrationCommand(switch::link_migration::LinkMigrationCommand), LinkCommand(switch::link::LinkCommand), PostinstallCommand(switch::postinstall::PostinstallCommand), + TrustCheckCommand(switch::trust::TrustCheckCommand), + TrustSetCommand(switch::trust::TrustSetCommand), UnlinkCommand(switch::unlink::UnlinkCommand), UpCommand(switch::up::UpCommand), UpdateCommand(switch::update::UpdateCommand), diff --git a/packages/zpm-switch/src/commands/switch/cache_list.rs b/packages/zpm-switch/src/commands/switch/cache_list.rs index 47562677..e0bad892 100644 --- a/packages/zpm-switch/src/commands/switch/cache_list.rs +++ b/packages/zpm-switch/src/commands/switch/cache_list.rs @@ -1,7 +1,7 @@ use clipanion::cli; use zpm_utils::{tree, AbstractValue, IoResultExt, Path, TimeAgo}; -use crate::{cache, errors::Error}; +use crate::{cache, errors::Error, links::list_configs}; /// List all cached Yarn binaries #[cli::command] @@ -19,41 +19,57 @@ impl CacheListCommand { let cache_dir = cache::cache_dir()?; - let Some(cache_entries) = cache_dir.fs_read_dir().ok_missing()? else { - return Ok(()); - }; + if let Some(cache_entries) = cache_dir.fs_read_dir().ok_missing()? { + for entry in cache_entries { + let entry + = entry?; - for entry in cache_entries { - let entry - = entry?; + let entry_path + = Path::try_from(entry.path())?; + let entry_meta + = cache::cache_metadata(&entry_path); + let entry_age + = cache::cache_last_used(&entry_path); - let entry_path - = Path::try_from(entry.path())?; - let entry_meta - = cache::cache_metadata(&entry_path); - let entry_age - = cache::cache_last_used(&entry_path); + let Ok(entry_meta) = entry_meta else { + continue; + }; - let Ok(entry_meta) = entry_meta else { - continue; - }; + let Ok(entry_age) = entry_age else { + continue; + }; - let Ok(entry_age) = entry_age else { + nodes.push(tree::Node { + label: None, + value: Some(AbstractValue::new(entry_meta.version)), + children: Some(tree::TreeNodeChildren::Map(tree::Map::from([ + ("path".to_string(), tree::Node { + label: Some("Path".to_string()), + value: Some(AbstractValue::new(entry_path)), + children: None, + }), + ("age".to_string(), tree::Node { + label: Some("Age".to_string()), + value: Some(AbstractValue::new(TimeAgo::new(entry_age.elapsed().unwrap()))), + children: None, + }), + ]))), + }); + } + } + + for config in list_configs()? { + let Some(trusted) = config.trusted else { continue; }; nodes.push(tree::Node { label: None, - value: Some(AbstractValue::new(entry_meta.version)), + value: Some(AbstractValue::new(config.project_cwd)), children: Some(tree::TreeNodeChildren::Map(tree::Map::from([ - ("path".to_string(), tree::Node { - label: Some("Path".to_string()), - value: Some(AbstractValue::new(entry_path)), - children: None, - }), - ("age".to_string(), tree::Node { - label: Some("Age".to_string()), - value: Some(AbstractValue::new(TimeAgo::new(entry_age.elapsed().unwrap()))), + ("trusted".to_string(), tree::Node { + label: Some("Trusted".to_string()), + value: Some(AbstractValue::new(trusted)), children: None, }), ]))), diff --git a/packages/zpm-switch/src/commands/switch/mod.rs b/packages/zpm-switch/src/commands/switch/mod.rs index 326df8c7..18cbea74 100644 --- a/packages/zpm-switch/src/commands/switch/mod.rs +++ b/packages/zpm-switch/src/commands/switch/mod.rs @@ -13,6 +13,7 @@ pub mod links_list; pub mod link_migration; pub mod link; pub mod postinstall; +pub mod trust; pub mod unlink; pub mod up; pub mod update; diff --git a/packages/zpm-switch/src/commands/switch/trust.rs b/packages/zpm-switch/src/commands/switch/trust.rs new file mode 100644 index 00000000..eb1379e8 --- /dev/null +++ b/packages/zpm-switch/src/commands/switch/trust.rs @@ -0,0 +1,80 @@ +use std::process::ExitCode; + +use clipanion::cli; +use zpm_macro_enum::zpm_enum; +use zpm_utils::Path; + +use crate::{errors::Error, links::{get_trusted, set_trusted}}; + +#[zpm_enum(or_else = |s| Err(Error::InvalidTrustLevel(s.to_string())))] +#[derive(Debug, Copy, Clone)] +enum TrustLevel { + #[literal("true")] + Trusted, + + #[literal("false")] + Untrusted, + + #[literal("null")] + Unknown, +} + +impl TrustLevel { + fn as_option(self) -> Option { + match self { + Self::Trusted => Some(true), + Self::Untrusted => Some(false), + Self::Unknown => None, + } + } +} + +/// Check whether a project has been trusted +#[cli::command] +#[cli::path("switch", "trust")] +#[cli::category("Project trust")] +#[derive(Debug)] +pub struct TrustCheckCommand { + #[cli::option("--check")] + _check: bool, + + path: Path, +} + +impl TrustCheckCommand { + pub async fn execute(&self) -> Result { + let path + = self.path.fs_canonicalize() + .unwrap_or_else(|_| self.path.clone()); + + match get_trusted(&path)? { + Some(true) => Ok(ExitCode::SUCCESS), + Some(false) => Ok(ExitCode::from(2)), + None => Ok(ExitCode::from(3)), + } + } +} + +/// Set whether a project is trusted +#[cli::command] +#[cli::path("switch", "trust")] +#[cli::category("Project trust")] +#[derive(Debug)] +pub struct TrustSetCommand { + #[cli::option("--set")] + trusted: TrustLevel, + + path: Path, +} + +impl TrustSetCommand { + pub async fn execute(&self) -> Result<(), Error> { + let path + = self.path.fs_canonicalize() + .unwrap_or_else(|_| self.path.clone()); + + set_trusted(&path, self.trusted.as_option())?; + + Ok(()) + } +} diff --git a/packages/zpm-switch/src/errors.rs b/packages/zpm-switch/src/errors.rs index 122eff3e..913cfd2f 100644 --- a/packages/zpm-switch/src/errors.rs +++ b/packages/zpm-switch/src/errors.rs @@ -48,6 +48,9 @@ pub enum Error { #[error("Invalid version selector: {0}")] InvalidVersionSelector(String), + #[error("Invalid trust level: {0}; expected true, false, or null")] + InvalidTrustLevel(String), + #[error("Failed to parse manifest: {0}")] FailedToParseManifest(zpm_parsers::Error), diff --git a/packages/zpm-switch/src/links.rs b/packages/zpm-switch/src/links.rs index 806ee11c..77607832 100644 --- a/packages/zpm-switch/src/links.rs +++ b/packages/zpm-switch/src/links.rs @@ -15,6 +15,39 @@ pub struct Link { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "camelCase")] +pub struct FolderConfig { + pub project_cwd: Path, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_target: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted: Option, +} + +impl FolderConfig { + pub fn new(project_cwd: Path) -> Self { + Self { + project_cwd, + link_target: None, + trusted: None, + } + } + + pub fn into_link(self) -> Option { + Some(Link { + project_cwd: self.project_cwd, + link_target: self.link_target?, + }) + } + + pub fn is_empty(&self) -> bool { + self.link_target.is_none() && self.trusted.is_none() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] #[serde(tag = "type")] pub enum LinkTarget { Local { @@ -44,34 +77,66 @@ pub fn links_dir() -> Result { Ok(links_dir) } -pub fn set_link(link: &Link) -> Result<(), Error> { +fn config_path(project_cwd: &Path) -> Result { let hash - = Hash64::from_data(link.project_cwd.to_file_string().as_bytes()); + = Hash64::from_data(project_cwd.to_file_string().as_bytes()); + + Ok(links_dir()? + .with_join_str(format!("{}.json", hash.short()))) +} - let link_path = links_dir()? - .with_join_str(format!("{}.json", hash.short())); +fn save_config(config: &FolderConfig) -> Result<(), Error> { + let config_path + = config_path(&config.project_cwd)?; - link_path + if config.is_empty() { + config_path + .fs_rm() + .ok_missing()?; + + return Ok(()); + } + + config_path .fs_create_parent()? - .fs_write(JsonDocument::to_string(link)?)?; + .fs_write(JsonDocument::to_string(config)?)?; Ok(()) } -pub fn unset_link(project_cwd: &Path) -> Result<(), Error> { - let hash - = Hash64::from_data(project_cwd.to_file_string().as_bytes()); +pub fn get_config(project_cwd: &Path) -> Result, Error> { + let config_path + = config_path(project_cwd)?; - let link_path = links_dir()? - .with_join_str(format!("{}.json", hash.short())); + let config = config_path + .fs_read_text() + .ok_missing()? + .and_then(|config| JsonDocument::hydrate_from_str::(&config).ok()); + + Ok(config) +} - link_path - .fs_rm()?; +pub fn set_link(link: &Link) -> Result<(), Error> { + let mut config + = get_config(&link.project_cwd)? + .unwrap_or_else(|| FolderConfig::new(link.project_cwd.clone())); - Ok(()) + config.link_target = Some(link.link_target.clone()); + + save_config(&config) } -pub fn list_links() -> Result, Error> { +pub fn unset_link(project_cwd: &Path) -> Result<(), Error> { + let mut config + = get_config(project_cwd)? + .unwrap_or_else(|| FolderConfig::new(project_cwd.clone())); + + config.link_target = None; + + save_config(&config) +} + +pub fn list_configs() -> Result, Error> { let links_dir = links_dir()?; @@ -79,28 +144,42 @@ pub fn list_links() -> Result, Error> { return Ok(BTreeSet::new()); }; - let links = dir_entries + let configs = dir_entries .filter_map(|entry| entry.ok()) .filter(|entry| entry.file_type().map_or(false, |f| f.is_file())) .filter_map(|link_path| Path::try_from(link_path.path()).ok()) .filter_map(|link_path| link_path.fs_read_text().ok()) - .filter_map(|contents| JsonDocument::hydrate_from_str::(&contents).ok()) + .filter_map(|contents| JsonDocument::hydrate_from_str::(&contents).ok()) + .collect::>(); + + Ok(configs) +} + +pub fn list_links() -> Result, Error> { + let links = list_configs()? + .into_iter() + .filter_map(FolderConfig::into_link) .collect::>(); Ok(links) } pub fn get_link(path: &Path) -> Result, Error> { - let hash - = Hash64::from_data(path.to_file_string().as_bytes()); + Ok(get_config(path)? + .and_then(FolderConfig::into_link)) +} + +pub fn get_trusted(path: &Path) -> Result, Error> { + Ok(get_config(path)? + .and_then(|config| config.trusted)) +} - let link_path = links_dir()? - .with_join_str(format!("{}.json", hash.short())); +pub fn set_trusted(project_cwd: &Path, trusted: Option) -> Result<(), Error> { + let mut config + = get_config(project_cwd)? + .unwrap_or_else(|| FolderConfig::new(project_cwd.clone())); - let link = link_path - .fs_read_text() - .ok_missing()? - .and_then(|link| JsonDocument::hydrate_from_str::(&link).ok()); + config.trusted = trusted; - Ok(link) + save_config(&config) } diff --git a/packages/zpm/src/build.rs b/packages/zpm/src/build.rs index 5c4963b8..a314621c 100644 --- a/packages/zpm/src/build.rs +++ b/packages/zpm/src/build.rs @@ -65,6 +65,7 @@ impl BuildRequest { let mut script_env = ScriptEnvironment::new()? .with_project(project) + .enable_trust_check() .with_package(project, &self.locator)? .with_env_variable("INIT_CWD", cwd_abs.as_str()) .with_cwd(cwd_abs.clone()); diff --git a/packages/zpm/src/error.rs b/packages/zpm/src/error.rs index 3be36407..2a79d344 100644 --- a/packages/zpm/src/error.rs +++ b/packages/zpm/src/error.rs @@ -418,6 +418,12 @@ pub enum Error { #[error("Binary failed to spawn: {error} ({}, in {})", DataType::Code.colorize(name), path.to_print_string())] SpawnFailed { name: String, path: Path, error: Arc> }, + #[error("The project at {} must be trusted before Yarn can run install scripts; run {} to trust it.", .0.to_print_string(), DataType::Code.colorize(&format!("yarn switch trust --set true {}", .0.to_print_string())))] + ProjectTrustRequired(Path), + + #[error("The project at {} isn't trusted, so Yarn won't run install scripts.", .0.to_print_string())] + ProjectNotTrusted(Path), + #[error("No binaries available in the dlx context")] MissingBinariesDlxContent, diff --git a/packages/zpm/src/report.rs b/packages/zpm/src/report.rs index c55d6d1a..40ba819e 100644 --- a/packages/zpm/src/report.rs +++ b/packages/zpm/src/report.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, future::Future, io::{self, Write}, sync::{Arc, LazyLock, atomic::AtomicU32, mpsc}, thread::JoinHandle, time::{Duration, SystemTime}}; use colored::{Color, Colorize}; -use dialoguer::{Input, Password}; +use dialoguer::{Confirm, Input, Password}; use itertools::Itertools; use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; use zpm_config::Configuration; @@ -185,6 +185,7 @@ impl Severity { #[derive(Debug)] pub enum PromptType { + Confirm(String), Input(String), Password(String), } @@ -396,6 +397,18 @@ impl Reporter { self.last_message_type = Some(LastMessageType::Prompt); match prompt { + PromptType::Confirm(prompt) => { + let label + = self.format_prompt(&prompt); + + let confirmed = Confirm::new() + .with_prompt(label) + .interact() + .unwrap(); + + self.prompt_tx.send(confirmed.to_string()).unwrap(); + }, + PromptType::Input(prompt) => { let label = self.format_prompt(&prompt); diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index bd9abb69..b1a86661 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -3,19 +3,21 @@ use std::{collections::BTreeMap, ffi::OsStr, fs::Permissions, io::Read, os::unix use serde::{Deserialize, Serialize}; use zpm_parsers::JsonDocument; use zpm_primitives::Locator; -use zpm_utils::{shell_escape, to_shell_line, FromFileString, Hash64, Path, ToFileString}; +use zpm_utils::{DataType, FromFileString, Hash64, Path, ToFileString, shell_escape, to_shell_line}; use itertools::Itertools; use regex::Regex; -use tokio::process::Command; +use tokio::{process::Command, sync::Mutex}; use crate::{ error::Error, project::Project, + report::{current_report, PromptType}, }; static CJS_LOADER_MATCHER: LazyLock = LazyLock::new(|| regex::Regex::new(r"\s*--require\s+\S*\.pnp\.c?js\s*").unwrap()); static ESM_LOADER_MATCHER: LazyLock = LazyLock::new(|| regex::Regex::new(r"\s*--experimental-loader\s+\S*\.pnp\.loader\.mjs\s*").unwrap()); static JS_EXTENSION: LazyLock = LazyLock::new(|| regex::Regex::new(r"\.[cm]?[jt]sx?$").unwrap()); +static TRUST_PROMPT_RESULT: LazyLock>> = LazyLock::new(|| Mutex::new(None)); fn make_python_entry_point_snippet(binary_name: &str, package_path: &Path, module: &str, object: &str) -> String { let binary_name @@ -134,6 +136,12 @@ fn get_self_path() -> Result { Ok(self_path) } +fn get_switch_path() -> Option { + std::env::var(zpm_switch::YARNSW_PATH_ENV) + .ok() + .and_then(|path| Path::from_file_string(&path).ok()) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum BinaryKind { Default, @@ -405,6 +413,8 @@ pub struct ScriptEnvironment { target_output: TargetOutput, stdin: Option, signal_delegation: bool, + trust_check_enabled: bool, + trust_check_project_cwd: Option, } impl ScriptEnvironment { @@ -417,6 +427,8 @@ impl ScriptEnvironment { target_output: TargetOutput::default(), stdin: None, signal_delegation: false, + trust_check_enabled: false, + trust_check_project_cwd: None, }; if let Ok(val) = std::env::var("YARNSW_DETECTED_ROOT") { @@ -504,6 +516,11 @@ impl ScriptEnvironment { self } + pub fn enable_trust_check(mut self) -> Self { + self.trust_check_enabled = true; + self + } + pub fn with_project(mut self, project: &Project) -> Self { self.remove_pnp_loader(); @@ -523,6 +540,7 @@ impl ScriptEnvironment { self.env.insert("PROJECT_CWD".to_string(), Some(project.project_cwd.to_file_string())); self.env.insert("INIT_CWD".to_string(), Some(project.project_cwd.with_join(&project.shell_cwd).to_file_string())); self.env.insert("CACHE_CWD".to_string(), Some(project.preferred_cache_path().to_file_string())); + self.trust_check_project_cwd = Some(project.project_cwd.clone()); self } @@ -551,6 +569,104 @@ impl ScriptEnvironment { } } + async fn check_project_trust(switch_path: &Path, project_cwd: &Path) -> Result, Error> { + let status + = Command::new(switch_path.to_file_string()) + .args(["switch", "trust", "--check", project_cwd.as_str()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + + match status.code() { + Some(0) => Ok(Some(true)), + Some(2) => Ok(Some(false)), + Some(3) => Ok(None), + _ => Err(Error::ChildProcessFailed("yarn switch trust --check".to_string())), + } + } + + async fn set_project_trust(switch_path: &Path, project_cwd: &Path, trusted: bool) -> Result<(), Error> { + let trusted_arg + = trusted.to_string(); + + let status + = Command::new(switch_path.to_file_string()) + .args(["switch", "trust", "--set", &trusted_arg, project_cwd.as_str()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await?; + + if status.success() { + Ok(()) + } else { + Err(Error::ChildProcessFailed("yarn switch trust --set".to_string())) + } + } + + async fn prompt_project_trust(project_cwd: &Path) -> Result { + if !zpm_utils::is_terminal() { + return Err(Error::ProjectTrustRequired(project_cwd.clone())); + } + + let report_guard + = current_report().await; + + let report + = report_guard.as_ref() + .ok_or_else(|| Error::ProjectTrustRequired(project_cwd.clone()))?; + + let answer = report.prompt(PromptType::Confirm(format!( + "Yarn needs to run potentially dangerous commands in {}. Do you trust this project?", + DataType::Path.colorize(&project_cwd.to_home_string()), + ))).await; + + Ok(answer == "true") + } + + async fn ensure_trusted(&self) -> Result<(), Error> { + if !self.trust_check_enabled { + return Ok(()); + } + + let Some(project_cwd) = &self.trust_check_project_cwd else { + return Ok(()); + }; + + let Some(switch_path) = get_switch_path() else { + return Ok(()); + }; + + match Self::check_project_trust(&switch_path, project_cwd).await? { + Some(true) => return Ok(()), + Some(false) => return Err(Error::ProjectNotTrusted(project_cwd.clone())), + None => (), + } + + let mut prompt_result + = TRUST_PROMPT_RESULT.lock().await; + + let trusted = match *prompt_result { + Some(trusted) => trusted, + None => { + let trusted + = Self::prompt_project_trust(project_cwd).await?; + + *prompt_result = Some(trusted); + + trusted + }, + }; + + Self::set_project_trust(&switch_path, project_cwd, trusted).await?; + + match trusted { + true => Ok(()), + false => Err(Error::ProjectNotTrusted(project_cwd.clone())), + } + } + pub fn with_standard_binaries(mut self) -> Self { self.binaries = ScriptBinaries::new().with_standard().unwrap(); self @@ -680,6 +796,8 @@ impl ScriptEnvironment { } pub async fn run_exec(&mut self, program: &str, args: I) -> Result where I: IntoIterator, S: AsRef { + self.ensure_trusted().await?; + let args = args.into_iter() .map(|arg| arg.as_ref().to_string()) .collect::>(); @@ -735,6 +853,8 @@ impl ScriptEnvironment { /// Spawns a command and returns the running process with piped stdout/stderr. /// Use this when you need to read output incrementally (e.g., for interlaced task output). pub async fn spawn_exec(&mut self, program: &str, args: I) -> Result where I: IntoIterator, S: AsRef { + self.ensure_trusted().await?; + let args = args.into_iter() .map(|arg| arg.as_ref().to_string()) .collect::>(); @@ -819,6 +939,8 @@ impl ScriptEnvironment { /// Runs a script with inherited stdio (output goes directly to terminal). /// Use this when you want the script's output to go directly to the terminal without capturing. pub async fn run_script_inherited(&mut self, script: &str, args: I) -> Result where I: IntoIterator, S: AsRef + ToString { + self.ensure_trusted().await?; + let mut final_script = script.to_string(); diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts new file mode 100644 index 00000000..a932fcdf --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts @@ -0,0 +1,96 @@ +const getSwitchBinaryPath = () => + process.env.TEST_SWITCH_BINARY + ?? require.resolve(`${__dirname}/../../../../../../target/release/yarn`); + +const getYarnBinBinaryPath = () => + process.env.TEST_BINARY + ?? require.resolve(`${__dirname}/../../../../../../target/release/yarn-bin`); + +describe(`Commands`, () => { + describe(`switch trust`, () => { + test( + `it should set and check project trust`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + await expect(runSwitch(`switch`, `trust`, `--check`, path)).rejects.toMatchObject({ + code: 3, + }); + + await expect(runSwitch(`switch`, `trust`, `--set`, `false`, path)).resolves.toMatchObject({ + code: 0, + }); + + await expect(runSwitch(`switch`, `trust`, `--check`, path)).rejects.toMatchObject({ + code: 2, + }); + + await expect(runSwitch(`switch`, `trust`, `--set`, `true`, path)).resolves.toMatchObject({ + code: 0, + }); + + await expect(runSwitch(`switch`, `trust`, `--check`, path)).resolves.toMatchObject({ + code: 0, + }); + + await expect(runSwitch(`switch`, `trust`, `--set`, `null`, path)).resolves.toMatchObject({ + code: 0, + }); + + await expect(runSwitch(`switch`, `trust`, `--check`, path)).rejects.toMatchObject({ + code: 3, + }); + }), + ); + + test( + `it should expose project trust through the cache listing`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + await runSwitch(`switch`, `trust`, `--set`, `true`, path); + + await expect(runSwitch(`switch`, `cache`)).resolves.toMatchObject({ + code: 0, + stdout: expect.stringContaining(`Trusted: true`), + }); + }), + ); + + test( + `it should require trust before running install scripts through Yarn Switch`, + makeTemporaryEnv({ + dependencies: { + [`no-deps-scripted`]: `1.0.0`, + }, + }, async ({path, runSwitch}) => { + await expect(runSwitch(`install`)).rejects.toMatchObject({ + code: 1, + stdout: expect.stringContaining(`must be trusted before Yarn can run install scripts`), + }); + + await runSwitch(`switch`, `trust`, `--set`, `true`, path); + + await expect(runSwitch(`install`)).resolves.toMatchObject({ + code: 0, + }); + }), + ); + + test( + `it should use Yarn Switch rather than the active Yarn binary to check trust`, + makeTemporaryEnv({ + dependencies: { + [`no-deps-scripted`]: `1.0.0`, + }, + }, async ({path, run, runSwitch}) => { + await runSwitch(`switch`, `trust`, `--set`, `true`, path); + + await expect(run(`install`, { + env: { + YARNSW_PATH: getSwitchBinaryPath(), + YARNSW_EXEC_PATH: getYarnBinBinaryPath(), + }, + })).resolves.toMatchObject({ + code: 0, + }); + }), + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3a26e038..cde98a29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,14 +3,14 @@ "version": 9 }, "workspaces": { - "@yarnpkg/documentation": "3a36e375c2f880ca4b3727f2db3a67968d85b7112518ce2cf2fdc9ce2cde237ab1010ab1320a074b1cece29e0c3b79b415b1b48bca4518c0f9157f0a3818e55d", - "@yarnpkg/monorepo": "f5e81432184d8250cce7f479f2ebb436a9b0900c22d0393bf1502e06cdad038193a88a7c6d3d6341963c6426eef186f390b1b44eff3242dc606b9f4a5cdb5846", + "@yarnpkg/documentation": "0d2a49c59415f61833a329d0856a519a3dc2880e05a93fd4261a022f0495553489ba1c8fcf697c89630245b868729f076b0f4ece23f4269bf8b80dd0e43c590f", + "@yarnpkg/monorepo": "4123f81323b3dd92cfddb18dc51565972d9c5d96c5aaba7b5d06ba4892725320d823fe8761c437abd741f434183e78d4381920998dce38772d23355c6782cfba", "@yarnpkg/zpm-constraints": "a602e3cc3ea1dd931ef50091753a9f1de3a06ff9ee0efb29c8222fb3491132ccbac1581220770e74dcf06e2c30189f636b1d7eb691bfdeff36012d3169630bc6", - "@yarnpkg/zpm-daemon-ui": "eeab3988f3f683099d2ac293259ca85accca0b08345680953771cb8aea311d570c309aa1dd19294742d23e95a728c70c96b985684fe454ac4a0415ae333e16ee", - "acceptance-tests": "e3fd8d082d2569f2db5632be90586713161177de887b3baef196e920aa6eb7a15447fb097a93339f72786bb1de1dab65e30bc9954296037e4fc72a9835d3e178", + "@yarnpkg/zpm-daemon-ui": "7c495325ca8b2e78612ebe6fdfd37aaa867ebbc20a5ea9edeb78c8ff94ef5259f977d9f2031179710836353f9eb346e636bf5df5bd17141df9c0ae3b2c1c5f18", + "acceptance-tests": "1cdaf10ee16b946ba65e51a762d007c8aeebdf14ece19af987654ab58fcadae0688cec3fc6a495e9ffb2005c27d39cce44b637de7bb9e562a2d8568e62367e71", "pkg-tests-core": "27dc1794f148dca7d11639e6ea0877781dcf88daf2e38af45bae8a0f9f259d4f49cb3f960e27849a3ee1e9d702da717d0060409c37fe6b26b23e509382d945e9", "pkg-tests-fixtures": "634e2d39424349e30ef9ace3cbe374d6c2b404ab29491084908f538e4f9823567caa6d900e0c15e78c1515ed8b1418294c0e3063027fb7475982d0ddd62c2b64", - "pkg-tests-specs": "5cdd5861797fecf735eae28ef1480c96e7cbf11168c7459eaef247e8e9c8ab19a9940ba6436d7a982863a08286a2ca01d1a76cc926501a860822dda6f627ff4a" + "pkg-tests-specs": "0c057efce225d6c6b91d4fe5b46a5b8b117176458fd7766224c8e8e8525df58fb10e7b8d701d49d57b48a2c64e5e9111c956b6c90403cc3833183c76d614f10e" }, "entries": { "@ai-sdk/gateway@npm:1.0.33": { @@ -9098,6 +9098,13 @@ "version": "6.1.4" } }, + "demo-postinstall-test@npm:^1.3.0": { + "checksum": "58a2212950ce91ece6750350490fbf590c782e3431994958fcbc3bac03155ce335415c1038ceb6a234beb1a32acb14dc1f0eda35122bbabd0ee32ad604238dc9", + "resolution": { + "resolution": "demo-postinstall-test@npm:1.3.0", + "version": "1.3.0" + } + }, "depd@npm:2.0.0, depd@npm:~2.0.0": { "checksum": "00585fa49f9739eac8b44c8be3c98ecc231893c03451f130a545a9305c5761322233b41cd1aa134a85844d06aeaa4aa577bc2e9efedc19aa1bc781ef17c74af8", "resolution": { From e46e07c1be0532daa5d8cec2fcc089e30cf8ccbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Thu, 21 May 2026 22:37:52 +0200 Subject: [PATCH 2/4] Tweaks --- .github/actions/prepare-node/action.yml | 15 +++++++++++++++ .github/workflows/build.yml | 1 + package.json | 1 - yarn.lock | 17 +++++------------ 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/actions/prepare-node/action.yml b/.github/actions/prepare-node/action.yml index 7cd80413..91e08a00 100644 --- a/.github/actions/prepare-node/action.yml +++ b/.github/actions/prepare-node/action.yml @@ -5,11 +5,26 @@ inputs: link: description: 'The link to the local path' required: false + switch: + description: 'Path to a locally built yarn-switch binary to use in place of the released one' + required: false runs: using: composite steps: - uses: yarnpkg/setup-action@main + if: inputs.switch == '' + + - name: Install the provided Yarn Switch binary + if: inputs.switch != '' + shell: bash + run: | + set -euo pipefail + chmod +x "${{inputs.switch}}" + install_dir="${RUNNER_TEMP}/yarn-switch-bin" + mkdir -p "${install_dir}" + cp -f "${{inputs.switch}}" "${install_dir}/yarn" + echo "${install_dir}" >> "${GITHUB_PATH}" - name: Add link to the local path if: inputs.link != '' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d19512c..76bf092a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -101,6 +101,7 @@ jobs: uses: ./.github/actions/prepare-node with: link: artifacts/yarn-bin + switch: artifacts/yarn - name: Generate the test report run: | diff --git a/package.json b/package.json index fb1de4ec..2d8a6baf 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "dependencies": { "@yarnpkg/eslint-config": "^3.1.0", "clipanion": "^4.0.0-rc.4", - "demo-postinstall-test": "^1.3.0", "eslint": "^9.36.0" }, "resolutions": { diff --git a/yarn.lock b/yarn.lock index cde98a29..3a26e038 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,14 +3,14 @@ "version": 9 }, "workspaces": { - "@yarnpkg/documentation": "0d2a49c59415f61833a329d0856a519a3dc2880e05a93fd4261a022f0495553489ba1c8fcf697c89630245b868729f076b0f4ece23f4269bf8b80dd0e43c590f", - "@yarnpkg/monorepo": "4123f81323b3dd92cfddb18dc51565972d9c5d96c5aaba7b5d06ba4892725320d823fe8761c437abd741f434183e78d4381920998dce38772d23355c6782cfba", + "@yarnpkg/documentation": "3a36e375c2f880ca4b3727f2db3a67968d85b7112518ce2cf2fdc9ce2cde237ab1010ab1320a074b1cece29e0c3b79b415b1b48bca4518c0f9157f0a3818e55d", + "@yarnpkg/monorepo": "f5e81432184d8250cce7f479f2ebb436a9b0900c22d0393bf1502e06cdad038193a88a7c6d3d6341963c6426eef186f390b1b44eff3242dc606b9f4a5cdb5846", "@yarnpkg/zpm-constraints": "a602e3cc3ea1dd931ef50091753a9f1de3a06ff9ee0efb29c8222fb3491132ccbac1581220770e74dcf06e2c30189f636b1d7eb691bfdeff36012d3169630bc6", - "@yarnpkg/zpm-daemon-ui": "7c495325ca8b2e78612ebe6fdfd37aaa867ebbc20a5ea9edeb78c8ff94ef5259f977d9f2031179710836353f9eb346e636bf5df5bd17141df9c0ae3b2c1c5f18", - "acceptance-tests": "1cdaf10ee16b946ba65e51a762d007c8aeebdf14ece19af987654ab58fcadae0688cec3fc6a495e9ffb2005c27d39cce44b637de7bb9e562a2d8568e62367e71", + "@yarnpkg/zpm-daemon-ui": "eeab3988f3f683099d2ac293259ca85accca0b08345680953771cb8aea311d570c309aa1dd19294742d23e95a728c70c96b985684fe454ac4a0415ae333e16ee", + "acceptance-tests": "e3fd8d082d2569f2db5632be90586713161177de887b3baef196e920aa6eb7a15447fb097a93339f72786bb1de1dab65e30bc9954296037e4fc72a9835d3e178", "pkg-tests-core": "27dc1794f148dca7d11639e6ea0877781dcf88daf2e38af45bae8a0f9f259d4f49cb3f960e27849a3ee1e9d702da717d0060409c37fe6b26b23e509382d945e9", "pkg-tests-fixtures": "634e2d39424349e30ef9ace3cbe374d6c2b404ab29491084908f538e4f9823567caa6d900e0c15e78c1515ed8b1418294c0e3063027fb7475982d0ddd62c2b64", - "pkg-tests-specs": "0c057efce225d6c6b91d4fe5b46a5b8b117176458fd7766224c8e8e8525df58fb10e7b8d701d49d57b48a2c64e5e9111c956b6c90403cc3833183c76d614f10e" + "pkg-tests-specs": "5cdd5861797fecf735eae28ef1480c96e7cbf11168c7459eaef247e8e9c8ab19a9940ba6436d7a982863a08286a2ca01d1a76cc926501a860822dda6f627ff4a" }, "entries": { "@ai-sdk/gateway@npm:1.0.33": { @@ -9098,13 +9098,6 @@ "version": "6.1.4" } }, - "demo-postinstall-test@npm:^1.3.0": { - "checksum": "58a2212950ce91ece6750350490fbf590c782e3431994958fcbc3bac03155ce335415c1038ceb6a234beb1a32acb14dc1f0eda35122bbabd0ee32ad604238dc9", - "resolution": { - "resolution": "demo-postinstall-test@npm:1.3.0", - "version": "1.3.0" - } - }, "depd@npm:2.0.0, depd@npm:~2.0.0": { "checksum": "00585fa49f9739eac8b44c8be3c98ecc231893c03451f130a545a9305c5761322233b41cd1aa134a85844d06aeaa4aa577bc2e9efedc19aa1bc781ef17c74af8", "resolution": { From 28c8352090a95916ade4a8cc73e9d7c8edd61cea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 21 May 2026 20:48:26 +0000 Subject: [PATCH 3/4] Avoid redundant project trust persistence --- packages/zpm/src/script.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/zpm/src/script.rs b/packages/zpm/src/script.rs index b1a86661..51c086ac 100644 --- a/packages/zpm/src/script.rs +++ b/packages/zpm/src/script.rs @@ -653,14 +653,14 @@ impl ScriptEnvironment { let trusted = Self::prompt_project_trust(project_cwd).await?; + Self::set_project_trust(&switch_path, project_cwd, trusted).await?; + *prompt_result = Some(trusted); trusted }, }; - Self::set_project_trust(&switch_path, project_cwd, trusted).await?; - match trusted { true => Ok(()), false => Err(Error::ProjectNotTrusted(project_cwd.clone())), From 7f2ebf4aa720283a9d77938ba448e58f3a76acdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Thu, 21 May 2026 23:04:12 +0200 Subject: [PATCH 4/4] Trust projects by default on CI --- packages/zpm-switch/src/links.rs | 14 ++++++-- .../sources/commands/switch/trust.test.ts | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/zpm-switch/src/links.rs b/packages/zpm-switch/src/links.rs index 77607832..6d4a316e 100644 --- a/packages/zpm-switch/src/links.rs +++ b/packages/zpm-switch/src/links.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use zpm_parsers::JsonDocument; -use zpm_utils::{DataType, Hash64, IoResultExt, Path, ToFileString, ToHumanString}; +use zpm_utils::{DataType, Hash64, IoResultExt, Path, ToFileString, ToHumanString, is_ci}; use crate::errors::Error; @@ -170,8 +170,16 @@ pub fn get_link(path: &Path) -> Result, Error> { } pub fn get_trusted(path: &Path) -> Result, Error> { - Ok(get_config(path)? - .and_then(|config| config.trusted)) + let trusted = get_config(path)? + .and_then(|config| config.trusted); + + // On CI, projects without an explicit trust setting are implicitly trusted, + // so unattended runs don't get blocked by the install-script trust gate. + if trusted.is_none() && is_ci().is_some() { + return Ok(Some(true)); + } + + Ok(trusted) } pub fn set_trusted(project_cwd: &Path, trusted: Option) -> Result<(), Error> { diff --git a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts index a932fcdf..47b0bf7b 100644 --- a/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts @@ -73,6 +73,40 @@ describe(`Commands`, () => { }), ); + test( + `it should implicitly trust projects when running on CI`, + makeTemporaryEnv({ + dependencies: { + [`no-deps-scripted`]: `1.0.0`, + }, + }, async ({path, runSwitch}) => { + await expect(runSwitch(`switch`, `trust`, `--check`, path, { + env: {CI: `1`}, + })).resolves.toMatchObject({ + code: 0, + }); + + await expect(runSwitch(`install`, { + env: {CI: `1`}, + })).resolves.toMatchObject({ + code: 0, + }); + }), + ); + + test( + `it should still honor an explicit untrust on CI`, + makeTemporaryEnv({}, async ({path, runSwitch}) => { + await runSwitch(`switch`, `trust`, `--set`, `false`, path); + + await expect(runSwitch(`switch`, `trust`, `--check`, path, { + env: {CI: `1`}, + })).rejects.toMatchObject({ + code: 2, + }); + }), + ); + test( `it should use Yarn Switch rather than the active Yarn binary to check trust`, makeTemporaryEnv({