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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/zpm-config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,10 @@
}
}
},
"switchVersionRequirement": {
"type": ["zpm_semver::Range", "null"],
"description": "When set, validates that the Yarn Switch binary version satisfies this semver range before executing any command."
},
"catalog": {
"type": "object",
"description": "Default catalog containing package name to version range mappings. This is an alias for catalogs.default.",
Expand Down
3 changes: 3 additions & 0 deletions packages/zpm-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,9 @@ merge_optional_settings!(zpm_primitives::PeerRange);
merge_optional_settings!(zpm_primitives::Range);
merge_optional_settings!(zpm_primitives::Reference);

merge_settings!(zpm_semver::Range, |s: &str| FromFileString::from_file_string(s).unwrap());
merge_optional_settings!(zpm_semver::Range);

merge_settings!(zpm_semver::RangeKind, |s: &str| FromFileString::from_file_string(s).unwrap());
merge_optional_settings!(zpm_semver::RangeKind);

Expand Down
1 change: 1 addition & 0 deletions packages/zpm-switch/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ futures = { workspace = true }
open = { workspace = true }
uuid = { version = "1.21.0", features = ["v4"] }
zpm-allocator = { workspace = true }
serde_yaml = { workspace = true }
zpm-formats = { workspace = true }
zpm-macro-enum = { workspace = true }
zpm-parsers = { workspace = true }
Expand Down
6 changes: 5 additions & 1 deletion packages/zpm-switch/src/commands/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use clipanion::cli;
use clipanion::core::{Completion, CompletionContext};
use zpm_utils::{DataType, Note, ToFileString};

use crate::{cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, links::{LinkTarget, get_link, unset_link}, manifest::{LocalPackageManagerReference, PackageManagerField, PackageManagerReference, find_closest_package_manager}, yarn::get_default_yarn_version, yarn_enums::ReleaseLine};
use crate::{config::validate_yarn_version, cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, links::{LinkTarget, get_link, unset_link}, manifest::{LocalPackageManagerReference, PackageManagerField, PackageManagerReference, find_closest_package_manager}, yarn::get_default_yarn_version, yarn_enums::ReleaseLine};

use super::switch::explicit::ExplicitCommand;

Expand Down Expand Up @@ -74,6 +74,10 @@ async fn proxy_completer_async(ctx: &CompletionContext<'_>) -> Vec<Completion> {

let mut binary = match &reference {
PackageManagerReference::Version(params) => {
let Ok(()) = validate_yarn_version(&params.version) else {
return vec![];
};

let Ok(cmd) = install_package_manager(params).await else {
return vec![];
};
Expand Down
3 changes: 3 additions & 0 deletions packages/zpm-switch/src/commands/switch/daemon_open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use zpm_semver::Version;
use zpm_utils::{Path, ToFileString};

use crate::{
config::validate_yarn_version,
cwd::get_final_cwd,
daemons::{self, DaemonEntry},
errors::Error,
Expand Down Expand Up @@ -77,6 +78,8 @@ impl DaemonOpenCommand {

match &reference {
PackageManagerReference::Version(version_ref) => {
validate_yarn_version(&version_ref.version)?;

let mut binary
= install_package_manager(version_ref).await?;

Expand Down
6 changes: 5 additions & 1 deletion packages/zpm-switch/src/commands/switch/explicit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::{process::{Command, ExitStatus, Stdio}, sync::Arc};
use clipanion::cli;
use zpm_utils::ToFileString;

use crate::{cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, manifest::{find_closest_package_manager, PackageManagerReference, VersionPackageManagerReference}, yarn::resolve_selector, yarn_enums::Selector};
use crate::{config::validate_yarn_version, cwd::{get_fake_cwd, get_final_cwd}, errors::Error, install::install_package_manager, ipc::YARNSW_PATH_ENV, manifest::{find_closest_package_manager, PackageManagerReference, VersionPackageManagerReference}, yarn::resolve_selector, yarn_enums::Selector};

/// Call a custom Yarn binary for the current project
#[cli::command(proxy)]
Expand All @@ -17,6 +17,10 @@ pub struct ExplicitCommand {

impl ExplicitCommand {
pub async fn run(reference: &PackageManagerReference, args: &[String]) -> Result<ExitStatus, Error> {
if let PackageManagerReference::Version(params) = reference {
validate_yarn_version(&params.version)?;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

let mut binary = match reference {
PackageManagerReference::Version(params)
=> install_package_manager(params).await?,
Expand Down
57 changes: 57 additions & 0 deletions packages/zpm-switch/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use serde::Deserialize;
use zpm_semver::{Range, Version};
use zpm_utils::{IoResultExt, Path};

use crate::errors::Error;

#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct PartialRcSettings {
#[serde(default)]
switch_version_requirement: Option<Range>,
}

fn load_switch_version_requirement() -> Result<Option<Range>, Error> {
let home_dir
= Path::home_dir()?
.ok_or(Error::MissingHomeFolder)?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing home directory causes unnecessary validation failure

Medium Severity

When no home directory exists (common in Docker containers, some CI environments), load_switch_version_requirement returns Error::MissingHomeFolder instead of Ok(None). Since switchVersionRequirement can only be set in the home folder, the absence of a home folder inherently means no restriction is configured — it's logically equivalent to "file not found" and the function could safely return Ok(None). Instead, it breaks all yarn commands in these environments.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d79eeee. Configure here.


let rc_filename
= std::env::var("YARN_RC_FILENAME")
.unwrap_or_else(|_| ".yarnrc.yml".to_string());

let rc_path
= home_dir.with_join_str(&rc_filename);

let text
= rc_path.fs_read_text()
.ok_missing()?;

let Some(text) = text else {
return Ok(None);
};

if text.is_empty() {
return Ok(None);
}

let partial: PartialRcSettings
= serde_yaml::from_str(&text)?;
Comment thread
cursor[bot] marked this conversation as resolved.

Ok(partial.switch_version_requirement)
}

pub fn validate_yarn_version(version: &Version) -> Result<(), Error> {
let Some(requirement) = load_switch_version_requirement()? else {
return Ok(());
};

if !requirement.check_ignore_rc(version) {
return Err(Error::SwitchVersionMismatch {
requirement,
actual: version.clone(),
});
}

Ok(())
}
15 changes: 15 additions & 0 deletions packages/zpm-switch/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub enum Error {
#[error(transparent)]
JsonError(#[from] zpm_parsers::Error),

#[error(transparent)]
YamlError(#[from] Arc<serde_yaml::Error>),

#[error("Internal error: Join failed ({0})")]
JoinFailed(#[from] Arc<JoinError>),

Expand Down Expand Up @@ -93,6 +96,12 @@ pub enum Error {
#[error("Failed to start daemon: {0}")]
FailedToStartDaemon(Arc<std::io::Error>),

#[error("The resolved Yarn version ({actual}) does not satisfy the required range ({requirement})", actual = .actual.to_print_string(), requirement = .requirement.to_print_string())]
SwitchVersionMismatch {
requirement: zpm_semver::Range,
actual: zpm_semver::Version,
},

#[error("No daemon is running for this project")]
DaemonNotRunning,

Expand Down Expand Up @@ -141,3 +150,9 @@ impl From<std::io::Error> for Error {
Error::from(Arc::new(value))
}
}

impl From<serde_yaml::Error> for Error {
fn from(value: serde_yaml::Error) -> Self {
Error::from(Arc::new(value))
}
}
1 change: 1 addition & 0 deletions packages/zpm-switch/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod config;
pub mod daemons;
mod errors;
mod http;
Expand Down
1 change: 1 addition & 0 deletions packages/zpm-switch/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::process::ExitCode;

mod cache;
mod commands;
mod config;
mod cwd;
mod daemons;
mod errors;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {Filename, ppath, xfs} from '@yarnpkg/fslib';

describe(`Features`, () => {
describe(`Yarn Switch`, () => {
describe(`switchVersionRequirement`, () => {
test(
`it should succeed when no config file exists`,
makeTemporaryEnv({
packageManager: `yarn@6.0.0`,
}, async ({path, runSwitch}) => {
await expect(runSwitch(`--version`)).resolves.toMatchObject({
code: 0,
});
}),
);

test(
`it should succeed when the config file is empty`,
makeTemporaryEnv({
packageManager: `yarn@6.0.0`,
}, async ({path, runSwitch}) => {
const homePath = ppath.dirname(path);

await xfs.writeFilePromise(ppath.join(homePath, Filename.rc), ``);

await expect(runSwitch(`--version`)).resolves.toMatchObject({
code: 0,
});
}),
);

test(
`it should succeed when the version matches the requirement`,
makeTemporaryEnv({
packageManager: `yarn@6.0.0`,
}, async ({path, runSwitch}) => {
const homePath = ppath.dirname(path);

await xfs.writeJsonPromise(ppath.join(homePath, Filename.rc), {
switchVersionRequirement: `>=6.0.0`,
});

await expect(runSwitch(`--version`)).resolves.toMatchObject({
code: 0,
});
}),
);

test(
`it should fail when the version does not match the requirement`,
makeTemporaryEnv({
packageManager: `yarn@6.0.0`,
}, async ({path, runSwitch}) => {
const homePath = ppath.dirname(path);

await xfs.writeJsonPromise(ppath.join(homePath, Filename.rc), {
switchVersionRequirement: `>=99.0.0`,
});

await expect(runSwitch(`--version`)).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining(`does not satisfy the required range`),
});
}),
);

test(
`it should fail to start a daemon when the version does not match the requirement`,
makeTemporaryEnv({
packageManager: `yarn@6.0.0`,
}, async ({path, runSwitch}) => {
const homePath = ppath.dirname(path);

await xfs.writeJsonPromise(ppath.join(homePath, Filename.rc), {
switchVersionRequirement: `>=99.0.0`,
});

await expect(runSwitch(`switch`, `daemon`, `--start`)).rejects.toMatchObject({
code: 1,
stdout: expect.stringContaining(`does not satisfy the required range`),
});
}),
);
});
});
});
Loading