From a7b3dee404cac87cd018b9ce874a71ac805fbe18 Mon Sep 17 00:00:00 2001 From: Pablo Garcia Date: Mon, 17 Aug 2026 22:57:35 +0200 Subject: [PATCH 1/2] join: report the field number the user gave Two ways the 'incompatible join fields' message diverged from its input. parse_field_number clamps an out-of-range field to usize::MAX, and get_field_number rendered it one-based with k + 1: an abort under overflow-checks, a wrap to 0 otherwise. Saturate instead. Separately, translate! stringifies a value and re-parses it as i64 or f64 before handing it to Fluent, whose number type is f64-backed. Every integer above 2^53 was silently rounded. Route those through as exact decimal strings, keeping the number path for values f64 holds exactly so plural rules and number formatting are unaffected. Fixes #13376 --- src/uu/join/src/join.rs | 14 ++++++++- src/uucore/src/lib/mods/locale.rs | 50 ++++++++++++++++++++++++++++++- tests/by-util/test_join.rs | 27 +++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/uu/join/src/join.rs b/src/uu/join/src/join.rs index 9351e8e5372..85e71d76202 100644 --- a/src/uu/join/src/join.rs +++ b/src/uu/join/src/join.rs @@ -1104,6 +1104,14 @@ fn exec( Ok(()) } +/// Render a zero-based field index as its one-based equivalent. +/// +/// `parse_field_number` clamps an out-of-range field to `usize::MAX`, so the +/// increment has to saturate rather than overflow. +fn one_based(k: usize) -> usize { + k.saturating_add(1) +} + /// Check that keys for both files and for a particular file are not /// contradictory and return the key index. fn get_field_number(keys: Option, key: Option) -> UResult { @@ -1111,7 +1119,11 @@ fn get_field_number(keys: Option, key: Option) -> UResult { // Show zero-based field numbers as one-based. (Some(k1), Some(k2)) if k1 != k2 => Err(USimpleError::new( 1, - translate!("join-error-incompatible-fields", "field1" => (k1 + 1), "field2" => (k2 + 1)), + translate!( + "join-error-incompatible-fields", + "field1" => one_based(k1), + "field2" => one_based(k2) + ), )), (Some(k), _) | (_, Some(k)) => Ok(k), (None, None) => Ok(0), diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 358088e04b8..fa01e361038 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -426,6 +426,28 @@ pub fn get_message(id: &str) -> String { /// let message = get_message_with_args("notification", args); /// println!("{message}"); /// ``` +/// The value as an `i64` when Fluent can represent it exactly, else `None`. +#[doc(hidden)] +pub fn exact_fluent_integer(s: &str) -> Option { + // Fluent stores numbers as f64, which represents integers exactly only up + // to 2^53. Anything beyond that has to travel as a string. + const MAX_EXACT: i64 = 1 << 53; + match s.parse::() { + Ok(n) if (-MAX_EXACT..=MAX_EXACT).contains(&n) => Some(n), + _ => None, + } +} + +/// Whether `s` is a plain decimal integer literal, with an optional sign. +/// +/// Used by [`translate!`] to tell an integer that Fluent cannot hold exactly +/// from a genuine float, so the former can bypass Fluent's number type. +#[doc(hidden)] +pub fn is_integer_literal(s: &str) -> bool { + let digits = s.strip_prefix(['-', '+']).unwrap_or(s); + !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) +} + pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String { get_message_internal(id, Some(ftl_args)) } @@ -659,8 +681,13 @@ macro_rules! translate { let mut args = fluent::FluentArgs::new(); $( let value_str = $value.to_string(); - if let Ok(num_val) = value_str.parse::() { + if let Some(num_val) = $crate::locale::exact_fluent_integer(&value_str) { args.set($key, num_val); + } else if $crate::locale::is_integer_literal(&value_str) { + // An integer Fluent cannot hold exactly. Its number type is + // f64-backed, so setting it as a number would round it; keep + // the exact decimal string instead. + args.set($key, value_str); } else if let Ok(float_val) = value_str.parse::() { args.set($key, float_val); } else { @@ -678,6 +705,27 @@ pub use translate; #[cfg(test)] mod tests { + #[test] + fn integers_beyond_f64_precision_stay_exact() { + // Fluent's number type is f64-backed, so values past 2^53 must travel + // as strings to survive intact. + assert_eq!(exact_fluent_integer("3"), Some(3)); + assert_eq!(exact_fluent_integer("-3"), Some(-3)); + assert_eq!(exact_fluent_integer("9007199254740992"), Some(1 << 53)); + assert_eq!(exact_fluent_integer("9007199254740993"), None); + assert_eq!(exact_fluent_integer("-9007199254740993"), None); + assert_eq!(exact_fluent_integer("18446744073709551615"), None); + assert_eq!(exact_fluent_integer("1.5"), None); + + assert!(is_integer_literal("18446744073709551615")); + assert!(is_integer_literal("-7")); + assert!(is_integer_literal("+7")); + assert!(!is_integer_literal("1.5")); + assert!(!is_integer_literal("")); + assert!(!is_integer_literal("-")); + assert!(!is_integer_literal("12a")); + } + use super::*; use std::env; use std::fs; diff --git a/tests/by-util/test_join.rs b/tests/by-util/test_join.rs index f7934dbbf6a..0caa3962d24 100644 --- a/tests/by-util/test_join.rs +++ b/tests/by-util/test_join.rs @@ -662,3 +662,30 @@ fn test_locale_collation() { .stdout_contains("abc:d 2 y") .stdout_contains("ab:d 1 x"); } + +#[test] +fn test_incompatible_fields_reports_exact_field_number() { + // An out-of-range field clamps to usize::MAX, which used to overflow the + // one-based increment. Field numbers past 2^53 also used to be rounded on + // their way through the localization layer. + for (field, expected) in [ + ("3", "incompatible join fields 3, 5"), + ( + "9007199254740993", + "incompatible join fields 9007199254740993, 5", + ), + ( + "18446744073709551615", + "incompatible join fields 18446744073709551615, 5", + ), + ( + "99999999999999999999999", + "incompatible join fields 18446744073709551615, 5", + ), + ] { + new_ucmd!() + .args(&["-j", field, "-1", "5", "/dev/null", "/dev/null"]) + .fails() + .stderr_contains(expected); + } +} From bfd55ff200235b9663b4fee6b1d6698e331d4f7a Mon Sep 17 00:00:00 2001 From: Pablo Garcia Date: Wed, 19 Aug 2026 17:14:33 +0200 Subject: [PATCH 2/2] test(join): make the exact-field-number test pointer-width portable parse_field_number uses usize (matching GNU join's size_t), so a field value at or above usize::MAX saturates to it. The previous test hard-coded 64-bit saturation ceilings (u64::MAX and 2^53+1), which failed on 32-bit targets (i686, i686-musl, i686-windows) where usize::MAX is 4294967295. Build the expected text from usize::MAX and gate the sub-usize::MAX above-f64-precision case on target_pointer_width. --- tests/by-util/test_join.rs | 48 +++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/tests/by-util/test_join.rs b/tests/by-util/test_join.rs index 0caa3962d24..9fbc267ebb5 100644 --- a/tests/by-util/test_join.rs +++ b/tests/by-util/test_join.rs @@ -668,24 +668,40 @@ fn test_incompatible_fields_reports_exact_field_number() { // An out-of-range field clamps to usize::MAX, which used to overflow the // one-based increment. Field numbers past 2^53 also used to be rounded on // their way through the localization layer. - for (field, expected) in [ - ("3", "incompatible join fields 3, 5"), - ( - "9007199254740993", - "incompatible join fields 9007199254740993, 5", - ), - ( - "18446744073709551615", - "incompatible join fields 18446744073709551615, 5", - ), - ( - "99999999999999999999999", - "incompatible join fields 18446744073709551615, 5", - ), - ] { + // + // `parse_field_number` uses `usize` (matching GNU join's `size_t`), so a + // value at or above `usize::MAX` saturates to it. The saturation ceiling is + // therefore pointer-width dependent, and the expected text is built from + // `usize::MAX` rather than a hard-coded 64-bit literal. + let max_field = usize::MAX.to_string(); + + // A small field number takes the i64 number path through the localization + // layer and is platform-independent. + new_ucmd!() + .args(&["-j", "3", "-1", "5", "/dev/null", "/dev/null"]) + .fails() + .stderr_contains("incompatible join fields 3, 5"); + + // Values at or above `usize::MAX` saturate to `usize::MAX` on every + // platform; the localization layer must carry that ceiling as an exact + // decimal string rather than rounding it through Fluent's f64-backed number + // type. + for field in ["18446744073709551615", "99999999999999999999999"] { new_ucmd!() .args(&["-j", field, "-1", "5", "/dev/null", "/dev/null"]) .fails() - .stderr_contains(expected); + .stderr_contains(&format!("incompatible join fields {max_field}, 5")); } + + // A value above f64 precision (2^53) but below `usize::MAX` is reported + // exactly on 64-bit (where `usize` holds it); on 32-bit it saturates to + // `usize::MAX` like the cases above. + #[cfg(target_pointer_width = "64")] + let expected_above: String = "9007199254740993".to_string(); + #[cfg(not(target_pointer_width = "64"))] + let expected_above: String = max_field.clone(); + new_ucmd!() + .args(&["-j", "9007199254740993", "-1", "5", "/dev/null", "/dev/null"]) + .fails() + .stderr_contains(&format!("incompatible join fields {expected_above}, 5")); }