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
6 changes: 3 additions & 3 deletions src/aqua/aqua_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ use crate::backend::aqua;
use crate::backend::aqua::{arch, os};
use crate::duration::{DAILY, WEEKLY};
use crate::git::{CloneOptions, Git};
use crate::semver::split_version_prefix;
use crate::{aqua::aqua_template, config::Settings};
use crate::{dirs, file, hashmap, http};
use expr::{Context, Program, Value};
use eyre::{ContextCompat, Result, eyre};
use indexmap::IndexSet;
use itertools::Itertools;
use regex::Regex;
use serde_derive::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
Expand Down Expand Up @@ -462,8 +462,8 @@ impl AquaPackage {
}

fn expr_parser(&self, v: &str) -> expr::Environment<'_> {
let prefix = Regex::new(r"^[^0-9.]+").unwrap();
let ver = versions::Versioning::new(prefix.replace(v, ""));
let (_, v) = split_version_prefix(v);
let ver = versions::Versioning::new(v);
let mut env = expr::Environment::new();
env.add_function("semver", move |c| {
if c.args.len() != 1 {
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ mod redactions;
mod registry;
pub(crate) mod result;
mod runtime_symlinks;
mod semver;
mod shell;
mod shims;
mod shorthands;
Expand Down
11 changes: 3 additions & 8 deletions src/runtime_symlinks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ use crate::backend::Backend;
use crate::config::{Alias, Config};
use crate::file::make_symlink_or_file;
use crate::plugins::VERSION_REGEX;
use crate::semver::split_version_prefix;
use crate::{backend, file};
use eyre::Result;
use indexmap::IndexMap;
use itertools::Itertools;
use versions::Versioning;
use xx::regex;

pub async fn rebuild(config: &Config) -> Result<()> {
for backend in backend::list() {
Expand Down Expand Up @@ -39,14 +39,9 @@ fn list_symlinks(config: &Config, backend: Arc<dyn Backend>) -> IndexMap<String,
// TODO: make this a pure function and add test cases
let mut symlinks = IndexMap::new();
let rel_path = |x: &String| PathBuf::from(".").join(x.clone());
let re = regex!(r"^[a-zA-Z0-9]+-");
for v in installed_versions(&backend) {
let prefix = re
.find(&v)
.map(|s| s.as_str().to_string())
.unwrap_or_default();
let sans_prefix = v.trim_start_matches(&prefix);
let versions = Versioning::new(sans_prefix).unwrap_or_default();
let (prefix, version) = split_version_prefix(&v);
let versions = Versioning::new(version).unwrap_or_default();
let mut partial = vec![];
while versions.nth(partial.len()).is_some() && versions.nth(partial.len() + 1).is_some() {
let version = versions.nth(partial.len()).unwrap();
Expand Down
98 changes: 98 additions & 0 deletions src/semver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use versions::{Mess, Versioning};

/// splits a version number into an optional prefix and the remaining version string
pub fn split_version_prefix(version: &str) -> (String, String) {
version
.char_indices()
.find_map(|(i, c)| {
if c.is_ascii_digit() {
if i == 0 {
return Some(i);
}
// If the previous char is a delimiter or 'v', we found a split point.
let prev_char = version.chars().nth(i - 1).unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: Unicode Indexing Mismatch Causes Panic

The split_version_prefix function incorrectly mixes byte indices from char_indices() with character indices for chars().nth(). When char_indices() returns a byte index i for a digit, version.chars().nth(i - 1) attempts to access the character at character position i - 1. For strings containing multi-byte Unicode characters, this can cause unwrap() to panic because i - 1 (a byte index) may exceed the actual character count, leading chars().nth() to return None. It can also result in incorrect prefix detection if a panic does not occur.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I don't think this is a concern, version numbers probably don't support unicode anyways

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, and also I think it's incorrect.
char_indices doesn't return byte indices.

fn main() {
    let str = "😀1㐂";
    println!("{:?}", str.char_indices());
    println!("{:?}", str.chars());
}
CharIndices { front_offset: 0, iter: Chars(['😀', '1', '㐂']) }
Chars(['😀', '1', '㐂'])

if ['-', '_', '/', '.', 'v', 'V'].contains(&prev_char) {
return Some(i);
}
}
None
})
.map_or_else(
|| ("".into(), version.into()),
|i| {
let (prefix, version) = version.split_at(i);
(prefix.into(), version.into())
},
)
}

/// split a version number into chunks
/// given v: "1.2-3a4" return ["1", ".2", "-3a4"]

Copilot AI Aug 13, 2025

Copy link

Choose a reason for hiding this comment

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

The comment example shows incorrect output. According to the test on line 90, the actual output for "1.2-3a4" is ["1", ".2", "-3a4"], but the comment shows "-3a4" as the last element instead of "-3a4".

Copilot uses AI. Check for mistakes.
pub fn chunkify_version(v: &str) -> Vec<String> {
fn chunkify(m: &Mess, sep0: &str, chunks: &mut Vec<String>) {
for (i, chunk) in m.chunks.iter().enumerate() {
let sep = if i == 0 { sep0 } else { "." };
chunks.push(format!("{sep}{chunk}"));
}
if let Some((next_sep, next_mess)) = &m.next {
chunkify(next_mess, next_sep.to_string().as_ref(), chunks)
}
}

let mut chunks = vec![];
// don't parse "latest", otherwise bump from latest to any version would have one chunk only
if v != "latest" {
if let Some(v) = Versioning::new(v) {
let m = match v {
Versioning::Ideal(sem_ver) => sem_ver.to_mess(),
Versioning::General(version) => version.to_mess(),
Versioning::Complex(mess) => mess,
};
chunkify(&m, "", &mut chunks);
}
}
chunks
}

#[cfg(test)]
mod tests {
use super::{chunkify_version, split_version_prefix};

#[test]
fn test_split_version_prefix() {
assert_eq!(split_version_prefix("latest"), ("".into(), "latest".into()));
assert_eq!(split_version_prefix("v1.2.3"), ("v".into(), "1.2.3".into()));
assert_eq!(
split_version_prefix("mountpoint-s3-v1.2.3-5_beta.5"),
("mountpoint-s3-v".into(), "1.2.3-5_beta.5".into())
);
assert_eq!(
split_version_prefix("cli/1.2.3"),
("cli/".into(), "1.2.3".into())
);
assert_eq!(
split_version_prefix("temurin-17.0.7+7"),
("temurin-".into(), "17.0.7+7".into())
);
assert_eq!(split_version_prefix("1.2"), ("".into(), "1.2".into()));
assert_eq!(
split_version_prefix("2:1.2.1"),
("".into(), "2:1.2.1".into())
);
assert_eq!(
split_version_prefix("2025-05-17"),
("".into(), "2025-05-17".into())
);
}

#[test]
fn test_chunkify_version() {
assert_eq!(chunkify_version("1.2-3a4"), vec!["1", ".2", "-3a4"]);
assert_eq!(chunkify_version("latest"), Vec::<String>::new());
assert_eq!(chunkify_version("1.0.0"), vec!["1", ".0", ".0"]);
assert_eq!(
chunkify_version("2.3.4-beta"),
vec!["2", ".3", ".4", "-beta"]
);
}
}
39 changes: 5 additions & 34 deletions src/toolset/outdated_info.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::semver::{chunkify_version, split_version_prefix};
use crate::toolset;
use crate::toolset::{ToolRequest, ToolSource, ToolVersion};
use crate::{Result, config::Config};
Expand All @@ -7,7 +8,7 @@ use std::{
sync::Arc,
};
use tabled::Tabled;
use versions::{Mess, Version, Versioning};
use versions::Version;

#[derive(Debug, Serialize, Clone, Tabled)]
pub struct OutdatedInfo {
Expand Down Expand Up @@ -55,11 +56,10 @@ impl OutdatedInfo {
) -> eyre::Result<Option<Self>> {
let t = tv.backend()?;
// prefix is something like "temurin-" or "corretto-"
let prefix = xx::regex!(r"^[a-zA-Z-]+-")
.find(&tv.request.version())
.map(|m| m.as_str().to_string());
let (prefix, _) = split_version_prefix(&tv.request.version());
let latest_result = if bump {
t.latest_version(config, prefix.clone()).await
t.latest_version(config, Some(prefix.clone()).filter(|s| !s.is_empty()))
.await
} else {
tv.latest_version(config).await.map(Option::from)
};
Expand All @@ -84,7 +84,6 @@ impl OutdatedInfo {
return Ok(None);
}
if bump {
let prefix = prefix.unwrap_or_default();
let old = oi.tool_version.request.version();
let old = old.strip_prefix(&prefix).unwrap_or_default();
let new = oi.latest.strip_prefix(&prefix).unwrap_or_default();
Expand Down Expand Up @@ -198,34 +197,6 @@ fn check_semver_bump(old: &str, new: &str) -> Option<String> {
}
}

/// split a version number into chunks
/// given v: "1.2-3a4" return ["1", ".2", "-3", "a4"]
fn chunkify_version(v: &str) -> Vec<String> {
fn chunkify(m: &Mess, sep0: &str, chunks: &mut Vec<String>) {
for (i, chunk) in m.chunks.iter().enumerate() {
let sep = if i == 0 { sep0 } else { "." };
chunks.push(format!("{sep}{chunk}"));
}
if let Some((next_sep, next_mess)) = &m.next {
chunkify(next_mess, next_sep.to_string().as_ref(), chunks)
}
}

let mut chunks = vec![];
// don't parse "latest", otherwise bump from latest to any version would have one chunk only
if v != "latest" {
if let Some(v) = Versioning::new(v) {
let m = match v {
Versioning::Ideal(sem_ver) => sem_ver.to_mess(),
Versioning::General(version) => version.to_mess(),
Versioning::Complex(mess) => mess,
};
chunkify(&m, "", &mut chunks);
}
}
chunks
}

pub fn is_outdated_version(current: &str, latest: &str) -> bool {
if let (Some(c), Some(l)) = (Version::new(current), Version::new(latest)) {
c.lt(&l)
Expand Down
Loading