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/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..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; @@ -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()); - let link_path = links_dir()? - .with_join_str(format!("{}.json", hash.short())); + Ok(links_dir()? + .with_join_str(format!("{}.json", hash.short()))) +} - link_path +fn save_config(config: &FolderConfig) -> Result<(), Error> { + let config_path + = config_path(&config.project_cwd)?; + + 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()); - link_path - .fs_rm()?; + Ok(config) +} - Ok(()) +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())); + + 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,50 @@ 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)) +} - let link_path = links_dir()? - .with_join_str(format!("{}.json", hash.short())); +pub fn get_trusted(path: &Path) -> Result, Error> { + let trusted = get_config(path)? + .and_then(|config| config.trusted); - let link = link_path - .fs_read_text() - .ok_missing()? - .and_then(|link| JsonDocument::hydrate_from_str::(&link).ok()); + // 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> { + let mut config + = get_config(project_cwd)? + .unwrap_or_else(|| FolderConfig::new(project_cwd.clone())); + + 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..51c086ac 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?; + + Self::set_project_trust(&switch_path, project_cwd, trusted).await?; + + *prompt_result = Some(trusted); + + trusted + }, + }; + + 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..47b0bf7b --- /dev/null +++ b/tests/acceptance-tests/pkg-tests-specs/sources/commands/switch/trust.test.ts @@ -0,0 +1,130 @@ +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 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({ + 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, + }); + }), + ); + }); +});