Skip to content

Commit 767db08

Browse files
authored
fix: regression in Version::starts_with (#1920)
1 parent 93801ac commit 767db08

File tree

3 files changed

+132
-42
lines changed

3 files changed

+132
-42
lines changed

crates/rattler_conda_types/src/version/mod.rs

Lines changed: 107 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -535,36 +535,53 @@ fn segments_starts_with<
535535
a: A,
536536
b: B, // the prefix we're looking for in 'a'
537537
) -> bool {
538+
let mut had_extra_left = false;
538539
for ranges in a.zip_longest(b) {
539540
let (left, right) = match ranges {
540-
EitherOrBoth::Both(left, right) => (left, right),
541+
EitherOrBoth::Both(left, right) => {
542+
// Previous segment had extra left components, but there are more
543+
// prefix segments - this is a structural mismatch.
544+
// E.g., "1.1c.1" does NOT start with "1.1.1"
545+
if had_extra_left {
546+
return false;
547+
}
548+
(left, right)
549+
}
550+
// Extra segments in version after prefix is exhausted - OK
551+
// E.g., "1.0.1.2" starts with "1.0.1"
541552
EitherOrBoth::Left(_) => return true,
542553
EitherOrBoth::Right(segment) => {
543-
// If the segment is zero we can skip it. As long as there are
544-
// only zeros, the version is still considered to start with
545-
// the other version.
554+
// Prefix has more segments. Zero segments are OK (implicit zeros).
555+
// E.g., "1.0" starts with "1.0.0"
546556
if segment.is_zero() {
547557
continue;
548558
}
549559
return false;
550560
}
551561
};
562+
had_extra_left = false;
552563
for values in left.components().zip_longest(right.components()) {
553-
if !match values {
554-
EitherOrBoth::Both(a, b) => a == b,
555-
556-
EitherOrBoth::Left(component) => {
557-
// If the component is zero we can skip it. If there are
558-
// no more right components and there are still left components,
559-
// we should consider the right component to be 0.
564+
match values {
565+
EitherOrBoth::Both(a, b) => {
566+
if a != b {
567+
return false;
568+
}
569+
}
570+
// Extra components in version segment. Only OK if this is the last
571+
// prefix segment (checked on next outer iteration).
572+
// E.g., "1.0.1c" starts with "1.0.1"
573+
EitherOrBoth::Left(_) => {
574+
had_extra_left = true;
575+
break;
576+
}
577+
// Missing component in version. Zero components are OK (implicit zeros).
578+
// E.g., "1.1c.1" starts with "1.1c0.1"
579+
EitherOrBoth::Right(component) => {
560580
if component.is_zero() {
561581
continue;
562582
}
563583
return false;
564584
}
565-
EitherOrBoth::Right(_) => return false,
566-
} {
567-
return false;
568585
}
569586
}
570587
}
@@ -1289,17 +1306,90 @@ mod test {
12891306
}
12901307

12911308
#[test]
1292-
fn starts_with_matches_extra_component_against_zero() {
1309+
fn starts_with_extra_components() {
1310+
// For glob matching (e.g., "1.0.0_version*"), versions with extra
1311+
// components should match the prefix.
12931312
let version = Version::from_str("1.0.0_version").unwrap();
1294-
assert!(!Version::from_str("1.0.0_version1")
1313+
// "1.0.0_version1" starts with "1.0.0_version" (extra component "1")
1314+
assert!(Version::from_str("1.0.0_version1")
12951315
.unwrap()
12961316
.starts_with(&version));
1317+
// "1.0.0_version0" starts with "1.0.0_version" (extra component "0")
12971318
assert!(Version::from_str("1.0.0_version0")
12981319
.unwrap()
12991320
.starts_with(&version));
1300-
assert!(!Version::from_str("1.0.0_version0foo")
1321+
// "1.0.0_version0foo" starts with "1.0.0_version" (extra components "0", "foo")
1322+
assert!(Version::from_str("1.0.0_version0foo")
1323+
.unwrap()
1324+
.starts_with(&version));
1325+
1326+
// But different base components should NOT match
1327+
assert!(!Version::from_str("1.0.0_other")
1328+
.unwrap()
1329+
.starts_with(&version));
1330+
assert!(!Version::from_str("1.0.0_ver")
1331+
.unwrap()
1332+
.starts_with(&version));
1333+
1334+
// Different segment structure should NOT match (PR #1791)
1335+
// "1.0.0_version1" has segment [version, 1]
1336+
// "1.0.0_version_2" has segments [version], [2]
1337+
// This ensures "1.0.0_version_2*" does NOT match "1.0.0_version1"
1338+
let version_with_extra_segment = Version::from_str("1.0.0_version_2").unwrap();
1339+
assert!(!Version::from_str("1.0.0_version1")
1340+
.unwrap()
1341+
.starts_with(&version_with_extra_segment));
1342+
1343+
// Different component values should NOT match
1344+
let version1 = Version::from_str("1.0.0_version1").unwrap();
1345+
assert!(!Version::from_str("1.0.0_version0")
1346+
.unwrap()
1347+
.starts_with(&version1));
1348+
1349+
// Extra components after matching prefix should match
1350+
assert!(Version::from_str("1.0.0_version1a")
1351+
.unwrap()
1352+
.starts_with(&version1));
1353+
assert!(Version::from_str("1.0.0_version0a")
13011354
.unwrap()
13021355
.starts_with(&version));
1356+
1357+
// Extra components in INTERMEDIATE segments should NOT match
1358+
// "1.1c.1" has segment [1, c] where "1.1.1" has segment [1]
1359+
// This is a structure mismatch, not just extra components at the end
1360+
assert!(!Version::from_str("1.1c.1")
1361+
.unwrap()
1362+
.starts_with(&Version::from_str("1.1.1").unwrap()));
1363+
assert!(!Version::from_str("1.1c1.1")
1364+
.unwrap()
1365+
.starts_with(&Version::from_str("1.1c.1").unwrap()));
1366+
1367+
// BUT zero components in prefix are treated as "no component" (implicit zeros)
1368+
// So "1.1c.1" starts with "1.1c0.1" because c0 == c (trailing zero)
1369+
assert!(Version::from_str("1.1c.1")
1370+
.unwrap()
1371+
.starts_with(&Version::from_str("1.1c0.1").unwrap()));
1372+
}
1373+
1374+
/// Test for <https://github.com/conda/rattler/issues/1914>
1375+
/// Versions with letter suffixes (like openssl 1.0.1c) should match
1376+
/// prefix patterns (like 1.0.1*).
1377+
#[test]
1378+
fn starts_with_letter_suffix() {
1379+
// openssl versions like 1.0.1c, 1.0.1g, etc. should start with 1.0.1
1380+
let prefix = Version::from_str("1.0.1").unwrap();
1381+
assert!(Version::from_str("1.0.1c").unwrap().starts_with(&prefix));
1382+
assert!(Version::from_str("1.0.1g").unwrap().starts_with(&prefix));
1383+
assert!(Version::from_str("1.0.1k").unwrap().starts_with(&prefix));
1384+
1385+
// Also test 1.0.2 series
1386+
let prefix_2 = Version::from_str("1.0.2").unwrap();
1387+
assert!(Version::from_str("1.0.2a").unwrap().starts_with(&prefix_2));
1388+
assert!(Version::from_str("1.0.2l").unwrap().starts_with(&prefix_2));
1389+
1390+
// Negative cases - these should NOT match
1391+
assert!(!Version::from_str("1.0.2a").unwrap().starts_with(&prefix));
1392+
assert!(!Version::from_str("1.0.1c").unwrap().starts_with(&prefix_2));
13031393
}
13041394

13051395
fn get_hash(spec: &impl Hash) -> u64 {

pixi.lock

Lines changed: 24 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pixi.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ scripts = ["scripts/activate_linux.sh"]
4747
ruff = ">=0.14.1,<0.15"
4848
typos = ">=1.23.1,<2"
4949
dprint = ">=0.50.0,<0.51"
50-
lefthook = ">=1.12.2,<2"
50+
lefthook = ">=2.0.9,<3"
5151
actionlint = ">=1.7.7,<2"
5252
shellcheck = ">=0.10.0,<0.11"
5353

0 commit comments

Comments
 (0)