From 0d683e3a75979c29861eee9e777235af96ba0a0c Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Wed, 5 Aug 2026 16:09:04 +0200 Subject: [PATCH 1/6] feat(v2): Make `ResourceNames::ensure_max_length` public --- crates/stackable-operator/CHANGELOG.md | 7 +++++++ .../src/v2/macros/attributed_string_type.rs | 3 +++ crates/stackable-operator/src/v2/role_group_utils.rs | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index e311a9e9d..de856d00d 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- [v2]: Make `ResourceNames::ensure_max_length` public ([#XXXX]). +- [v2]: Add `MAX_ANNOTATION_NAME_LENGTH` constant with a value of `63` ([#XXXX]). + +[#XXXX]: https://github.com/stackabletech/operator-rs/pull/XXXX + ## [0.115.0] - 2026-08-04 ### Changed diff --git a/crates/stackable-operator/src/v2/macros/attributed_string_type.rs b/crates/stackable-operator/src/v2/macros/attributed_string_type.rs index e3cfbd689..b7c457446 100644 --- a/crates/stackable-operator/src/v2/macros/attributed_string_type.rs +++ b/crates/stackable-operator/src/v2/macros/attributed_string_type.rs @@ -6,6 +6,9 @@ use strum::{EnumDiscriminants, IntoStaticStr}; /// Duplicates the private constant [`crate::kvp::LABEL_VALUE_MAX_LEN`] pub const MAX_LABEL_VALUE_LENGTH: usize = 63; +/// Maximum length of annotation names +pub const MAX_ANNOTATION_NAME_LENGTH: usize = 63; + #[derive(Debug, EnumDiscriminants, Snafu)] #[snafu(visibility(pub))] #[strum_discriminants(derive(IntoStaticStr))] diff --git a/crates/stackable-operator/src/v2/role_group_utils.rs b/crates/stackable-operator/src/v2/role_group_utils.rs index 1b25d2266..0c83ff459 100644 --- a/crates/stackable-operator/src/v2/role_group_utils.rs +++ b/crates/stackable-operator/src/v2/role_group_utils.rs @@ -99,7 +99,11 @@ impl ResourceNames { /// `max_length < 1 /* character */ + 1 /* dash */ + hash_length`. /// /// Kubernetes object names cannot contain non-ASCII characters. - fn ensure_max_length(resource_name: String, max_length: usize, hash_length: usize) -> String { + pub fn ensure_max_length( + resource_name: String, + max_length: usize, + hash_length: usize, + ) -> String { assert!(resource_name.is_ascii()); assert!(max_length >= 1 /* character */ + 1 /* dash */ + hash_length); From b7f0f0b53d07bd892941ed92a697a7c1090eff03 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Wed, 5 Aug 2026 16:10:13 +0200 Subject: [PATCH 2/6] changelog --- crates/stackable-operator/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index de856d00d..cb57bf73d 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,10 +6,10 @@ All notable changes to this project will be documented in this file. ### Added -- [v2]: Make `ResourceNames::ensure_max_length` public ([#XXXX]). -- [v2]: Add `MAX_ANNOTATION_NAME_LENGTH` constant with a value of `63` ([#XXXX]). +- [v2]: Make `ResourceNames::ensure_max_length` public ([#1260]). +- [v2]: Add `MAX_ANNOTATION_NAME_LENGTH` constant with a value of `63` ([#1260]). -[#XXXX]: https://github.com/stackabletech/operator-rs/pull/XXXX +[#1260]: https://github.com/stackabletech/operator-rs/pull/1260 ## [0.115.0] - 2026-08-04 From eca4b005e0a8a50aa66e4c65b369d6dab794a5af Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 6 Aug 2026 11:42:58 +0200 Subject: [PATCH 3/6] Rework according to feedback --- crates/stackable-operator/src/kvp/key.rs | 12 ++ .../src/utils/length_enforcement.rs | 103 ++++++++++++++++ crates/stackable-operator/src/utils/mod.rs | 1 + .../src/v2/macros/attributed_string_type.rs | 3 - .../src/v2/role_group_utils.rs | 111 +----------------- 5 files changed, 120 insertions(+), 110 deletions(-) create mode 100644 crates/stackable-operator/src/utils/length_enforcement.rs diff --git a/crates/stackable-operator/src/kvp/key.rs b/crates/stackable-operator/src/kvp/key.rs index 65336e4a0..46503571a 100644 --- a/crates/stackable-operator/src/kvp/key.rs +++ b/crates/stackable-operator/src/kvp/key.rs @@ -3,6 +3,8 @@ use std::{fmt::Display, ops::Deref, str::FromStr, sync::LazyLock}; use regex::Regex; use snafu::{ResultExt, Snafu, ensure}; +use crate::utils::length_enforcement::ensure_max_length; + const KEY_PREFIX_MAX_LEN: usize = 253; const KEY_NAME_MAX_LEN: usize = 63; @@ -135,6 +137,16 @@ impl Deref for Key { } impl Key { + pub fn with_enforced_length( + prefix: impl Into, + name: impl Into, + ) -> Result { + let prefix = ensure_max_length(prefix, KEY_PREFIX_MAX_LEN, 8); + let name = ensure_max_length(name, KEY_NAME_MAX_LEN, 8); + + Self::from_str(&format!("{prefix}/{name}")) + } + /// Retrieves the key's prefix. /// /// ``` diff --git a/crates/stackable-operator/src/utils/length_enforcement.rs b/crates/stackable-operator/src/utils/length_enforcement.rs new file mode 100644 index 000000000..38a2e38a1 --- /dev/null +++ b/crates/stackable-operator/src/utils/length_enforcement.rs @@ -0,0 +1,103 @@ +use sha2::{Digest, Sha256}; + +/// Ensures that the given input does not exceed the given maximum length. +/// If required, the input is truncated and a hex encoded hash is appended with a dash. +/// +/// # Panics +/// +/// Panics if `max_length < 1 /* character */ + 1 /* dash */ + hash_length`. +pub fn ensure_max_length( + original: impl Into, + max_length: usize, + hash_length: usize, +) -> String { + assert!(max_length >= 1 /* character */ + 1 /* dash */ + hash_length); + + let original = original.into(); + if original.len() <= max_length { + original + } else if hash_length == 0 { + let mut truncated_name = original; + truncated_name.truncate(max_length); + truncated_name + } else { + let mut hash = format!("{:x}", Sha256::digest(original.as_bytes())); + hash.truncate(hash_length); + + let mut truncated_name = original; + // Truncate the name so that the hash can be appended without exceeding the maximum + // length. + truncated_name.truncate(max_length - hash_length); + + let last_char = truncated_name + .pop() + .expect("should be guaranteed by the assertion above"); + let second_to_last_char = truncated_name + .pop() + .expect("should be guaranteed by the assertion above"); + + // If the truncated name already ends with a dash then do not add another one, + // otherwise replace the last character with a dash. + if second_to_last_char == '-' && last_char != '-' { + format!("{truncated_name}{second_to_last_char}{hash}") + } else { + format!("{truncated_name}{second_to_last_char}-{hash}") + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_ensure_max_length() { + // empty resource name, no hash length + assert_eq!(String::new(), ensure_max_length(String::new(), 2, 0)); + + // resource_name.len() <= max_length + assert_eq!( + "abcdef".to_owned(), + ensure_max_length("abcdef".to_owned(), 6, 4) + ); + + // hash_length == 0 + assert_eq!( + "abcdef".to_owned(), + ensure_max_length("abcdefg".to_owned(), 6, 0) + ); + + // hash appended with dash + assert_eq!( + "a-7d1a".to_owned(), + ensure_max_length("abcdefg".to_owned(), 6, 4) + ); + + // hash appended without an extra dash + assert_eq!( + "ab-a1b1".to_owned(), + ensure_max_length("ab-defgh".to_owned(), 7, 4) + ); + + // hash appended without an extra dash + // In this case, the result is one character shorter than the maximum length. + assert_eq!( + "a-3951".to_owned(), + ensure_max_length("a-cdefgh".to_owned(), 7, 4) + ); + + // hash appended without an extra dash + // The two dashes in the given resource name are intentionally kept. + assert_eq!( + "a--f7a0".to_owned(), + ensure_max_length("a--defgh".to_owned(), 7, 4) + ); + + // A hash_length longer than the produced hash string may not produce the desired result. + // Just use sensible values! + assert_eq!( + "aaaaaaaaa-d476ce01c3787bcab054a2cf48d6af6dd303a0eb549e21a74125132f79d90c36".to_owned(), + ensure_max_length("a".repeat(1011), 1010, 1000) + ); + } +} diff --git a/crates/stackable-operator/src/utils/mod.rs b/crates/stackable-operator/src/utils/mod.rs index 7f51eda2c..dc94641db 100644 --- a/crates/stackable-operator/src/utils/mod.rs +++ b/crates/stackable-operator/src/utils/mod.rs @@ -2,6 +2,7 @@ pub mod bash; pub mod cluster_info; pub mod crds; pub mod kubelet; +pub mod length_enforcement; pub mod logging; pub mod signal; diff --git a/crates/stackable-operator/src/v2/macros/attributed_string_type.rs b/crates/stackable-operator/src/v2/macros/attributed_string_type.rs index b7c457446..e3cfbd689 100644 --- a/crates/stackable-operator/src/v2/macros/attributed_string_type.rs +++ b/crates/stackable-operator/src/v2/macros/attributed_string_type.rs @@ -6,9 +6,6 @@ use strum::{EnumDiscriminants, IntoStaticStr}; /// Duplicates the private constant [`crate::kvp::LABEL_VALUE_MAX_LEN`] pub const MAX_LABEL_VALUE_LENGTH: usize = 63; -/// Maximum length of annotation names -pub const MAX_ANNOTATION_NAME_LENGTH: usize = 63; - #[derive(Debug, EnumDiscriminants, Snafu)] #[snafu(visibility(pub))] #[strum_discriminants(derive(IntoStaticStr))] diff --git a/crates/stackable-operator/src/v2/role_group_utils.rs b/crates/stackable-operator/src/v2/role_group_utils.rs index 0c83ff459..d6600402b 100644 --- a/crates/stackable-operator/src/v2/role_group_utils.rs +++ b/crates/stackable-operator/src/v2/role_group_utils.rs @@ -1,14 +1,12 @@ use std::str::FromStr; -use sha2::{Digest, Sha256}; - use super::types::{ kubernetes::{ ConfigMapName, DaemonSetName, DeploymentName, ListenerName, ServiceName, StatefulSetName, }, operator::{ClusterName, RoleGroupName, RoleName}, }; -use crate::attributed_string_type; +use crate::{attributed_string_type, utils::length_enforcement::ensure_max_length}; attributed_string_type! { QualifiedRoleGroupName, @@ -80,65 +78,18 @@ impl ResourceNames { self.cluster_name, self.role_name, self.role_group_name, ); // `concatenated_name` contains only ASCII characters. - let sanitized_name = Self::ensure_max_length( + assert!(concatenated_name.is_ascii()); + let sanitized_name = ensure_max_length( concatenated_name, QualifiedRoleGroupName::MAX_LENGTH, HASH_LENGTH, ); + assert!(sanitized_name.len() <= QualifiedRoleGroupName::MAX_LENGTH); QualifiedRoleGroupName::from_str(&sanitized_name) .expect("should be a valid QualifiedRoleGroupName") } - /// Ensures that the given resource name does not exceed the given maximum length. - /// If required, the resource name is truncated and a hex encoded hash is appended with a dash. - /// - /// # Panics - /// - /// Panics if `resource_name` contains non-ASCII characters or if - /// `max_length < 1 /* character */ + 1 /* dash */ + hash_length`. - /// - /// Kubernetes object names cannot contain non-ASCII characters. - pub fn ensure_max_length( - resource_name: String, - max_length: usize, - hash_length: usize, - ) -> String { - assert!(resource_name.is_ascii()); - assert!(max_length >= 1 /* character */ + 1 /* dash */ + hash_length); - - if resource_name.len() <= max_length { - resource_name - } else if hash_length == 0 { - let mut truncated_name = resource_name; - truncated_name.truncate(max_length); - truncated_name - } else { - let mut hash = format!("{:x}", Sha256::digest(resource_name.as_bytes())); - hash.truncate(hash_length); - - let mut truncated_name = resource_name; - // Truncate the name so that the hash can be appended without exceeding the maximum - // length. - truncated_name.truncate(max_length - hash_length); - - let last_char = truncated_name - .pop() - .expect("should be guaranteed by the assertion above"); - let second_to_last_char = truncated_name - .pop() - .expect("should be guaranteed by the assertion above"); - - // If the truncated name already ends with a dash then do not add another one, - // otherwise replace the last character with a dash. - if second_to_last_char == '-' && last_char != '-' { - format!("{truncated_name}{second_to_last_char}{hash}") - } else { - format!("{truncated_name}{second_to_last_char}-{hash}") - } - } - } - pub fn role_group_config_map(&self) -> ConfigMapName { // compile-time check const _: () = assert!( @@ -338,58 +289,4 @@ mod tests { qualified_role_group_name ); } - - #[test] - fn test_ensure_max_length() { - // empty resource name, no hash length - assert_eq!( - String::new(), - ResourceNames::ensure_max_length(String::new(), 2, 0) - ); - - // resource_name.len() <= max_length - assert_eq!( - "abcdef".to_owned(), - ResourceNames::ensure_max_length("abcdef".to_owned(), 6, 4) - ); - - // hash_length == 0 - assert_eq!( - "abcdef".to_owned(), - ResourceNames::ensure_max_length("abcdefg".to_owned(), 6, 0) - ); - - // hash appended with dash - assert_eq!( - "a-7d1a".to_owned(), - ResourceNames::ensure_max_length("abcdefg".to_owned(), 6, 4) - ); - - // hash appended without an extra dash - assert_eq!( - "ab-a1b1".to_owned(), - ResourceNames::ensure_max_length("ab-defgh".to_owned(), 7, 4) - ); - - // hash appended without an extra dash - // In this case, the result is one character shorter than the maximum length. - assert_eq!( - "a-3951".to_owned(), - ResourceNames::ensure_max_length("a-cdefgh".to_owned(), 7, 4) - ); - - // hash appended without an extra dash - // The two dashes in the given resource name are intentionally kept. - assert_eq!( - "a--f7a0".to_owned(), - ResourceNames::ensure_max_length("a--defgh".to_owned(), 7, 4) - ); - - // A hash_length longer than the produced hash string may not produce the desired result. - // Just use sensible values! - assert_eq!( - "aaaaaaaaa-d476ce01c3787bcab054a2cf48d6af6dd303a0eb549e21a74125132f79d90c36".to_owned(), - ResourceNames::ensure_max_length("a".repeat(1011), 1010, 1000) - ); - } } From 9c5b35d137048f2d777abd9155abc32755315286 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 6 Aug 2026 11:47:31 +0200 Subject: [PATCH 4/6] changelog --- crates/stackable-operator/CHANGELOG.md | 3 +-- crates/stackable-operator/src/kvp/key.rs | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index e5526d5b4..025043780 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -7,8 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Add the Cargo feature `kube-cel` that enables the `cel` feature on the `kube` crate ([1259]). -- [v2]: Make `ResourceNames::ensure_max_length` public ([#1260]). -- [v2]: Add `MAX_ANNOTATION_NAME_LENGTH` constant with a value of `63` ([#1260]). +- Add `length_enforcement::ensure_max_length` and `Key::shortened_to_valid_length` helper functions ([#1260]). [#1259]: https://github.com/stackabletech/operator-rs/pull/1259 [#1260]: https://github.com/stackabletech/operator-rs/pull/1260 diff --git a/crates/stackable-operator/src/kvp/key.rs b/crates/stackable-operator/src/kvp/key.rs index 46503571a..ef73fc142 100644 --- a/crates/stackable-operator/src/kvp/key.rs +++ b/crates/stackable-operator/src/kvp/key.rs @@ -137,7 +137,10 @@ impl Deref for Key { } impl Key { - pub fn with_enforced_length( + /// (Optionally) shortens the `prefix` and `name` to make sure they produce a valid [`Key`]. + /// + /// See [`ensure_max_length`] for details on the shortening algorithm. + pub fn shortened_to_valid_length( prefix: impl Into, name: impl Into, ) -> Result { From f0a76b3e90c9c718b1a8b06f3fbb719787cb86bc Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 6 Aug 2026 13:59:39 +0200 Subject: [PATCH 5/6] feat: Also handle UTF-8 --- .../src/utils/length_enforcement.rs | 125 +++++++++++++----- 1 file changed, 93 insertions(+), 32 deletions(-) diff --git a/crates/stackable-operator/src/utils/length_enforcement.rs b/crates/stackable-operator/src/utils/length_enforcement.rs index 38a2e38a1..87b171939 100644 --- a/crates/stackable-operator/src/utils/length_enforcement.rs +++ b/crates/stackable-operator/src/utils/length_enforcement.rs @@ -3,47 +3,59 @@ use sha2::{Digest, Sha256}; /// Ensures that the given input does not exceed the given maximum length. /// If required, the input is truncated and a hex encoded hash is appended with a dash. /// +/// It is recommended to only use ASCII characters, but this function also handles UTF-8: Multi-byte +/// characters are never split up, so the result can be shorter than the maximum length. +/// +/// If the truncation does not leave any character then only the hash is returned. +/// /// # Panics /// -/// Panics if `max_length < 1 /* character */ + 1 /* dash */ + hash_length`. +/// Panics if `max_length_bytes < 1 /* character */ + 1 /* dash */ + hash_length`. pub fn ensure_max_length( original: impl Into, - max_length: usize, + max_length_bytes: usize, hash_length: usize, ) -> String { - assert!(max_length >= 1 /* character */ + 1 /* dash */ + hash_length); + assert!(max_length_bytes >= 1 /* character */ + 1 /* dash */ + hash_length); let original = original.into(); - if original.len() <= max_length { - original - } else if hash_length == 0 { - let mut truncated_name = original; - truncated_name.truncate(max_length); - truncated_name - } else { - let mut hash = format!("{:x}", Sha256::digest(original.as_bytes())); - hash.truncate(hash_length); - - let mut truncated_name = original; - // Truncate the name so that the hash can be appended without exceeding the maximum - // length. - truncated_name.truncate(max_length - hash_length); - - let last_char = truncated_name - .pop() - .expect("should be guaranteed by the assertion above"); - let second_to_last_char = truncated_name - .pop() - .expect("should be guaranteed by the assertion above"); - - // If the truncated name already ends with a dash then do not add another one, - // otherwise replace the last character with a dash. - if second_to_last_char == '-' && last_char != '-' { - format!("{truncated_name}{second_to_last_char}{hash}") - } else { - format!("{truncated_name}{second_to_last_char}-{hash}") - } + if original.len() <= max_length_bytes { + return original; + } + if hash_length == 0 { + return truncate_at_char_boundary(original, max_length_bytes); + } + + let mut hash = format!("{:x}", Sha256::digest(original.as_bytes())); + hash.truncate(hash_length); + + // The result is `-`, so the name must not occupy the bytes which are reserved + // for the hash. + let mut name = truncate_at_char_boundary(original, max_length_bytes - hash_length); + + // Remove one more character to make room for the dash. + let removed_char = name.pop(); + + if name.is_empty() { + return hash; + } + + // A dash at the end of the name is reused as the separator. If the removed character was a + // dash itself then both dashes belong to the name and are kept. + if !name.ends_with('-') || removed_char == Some('-') { + name.push('-'); } + + format!("{name}{hash}") +} + +/// Truncates the given input to at most `max_length_bytes` bytes. +/// +/// The input is only truncated at a character boundary, so a multi-byte character is never split +/// up but dropped entirely. +fn truncate_at_char_boundary(mut input: String, max_length_bytes: usize) -> String { + input.truncate(input.floor_char_boundary(max_length_bytes)); + input } #[cfg(test)] @@ -100,4 +112,53 @@ mod test { ensure_max_length("a".repeat(1011), 1010, 1000) ); } + + /// The maximum length is measured in bytes, so multi-byte characters must not be split up by + /// the truncation. This can make the result shorter than the maximum length. + #[test] + fn test_ensure_max_length_with_multi_byte_characters() { + // The two byte characters fit exactly into the maximum length. + assert_eq!("äöü".to_owned(), ensure_max_length("äöü".to_owned(), 6, 4)); + + // Truncating after 5 bytes would split up the "ü", so it is dropped entirely. + assert_eq!("äö".to_owned(), ensure_max_length("äöü".to_owned(), 5, 0)); + + // The 5 bytes reserved for the name only fit "äö", of which the "ö" is then replaced by + // the dash, so the result is two bytes shorter than the maximum length. + assert_eq!( + "ä-e109".to_owned(), + ensure_max_length("äöüäöü".to_owned(), 9, 4) + ); + + // hash appended with dash, three byte characters + assert_eq!( + "日-9efa".to_owned(), + ensure_max_length("日本語日本語".to_owned(), 10, 4) + ); + + // hash appended with dash, four byte characters + assert_eq!( + "🚀-a13c".to_owned(), + ensure_max_length("🚀🚀🚀🚀".to_owned(), 13, 4) + ); + + // The trailing dash of the truncated name is replaced by the dash which separates the + // hash. + assert_eq!( + "aä-f726".to_owned(), + ensure_max_length("aä-öüb".to_owned(), 8, 4) + ); + + // The truncated name is "aä-ö", so the "ö" is dropped and the existing dash is reused. + assert_eq!( + "aä-ae0c".to_owned(), + ensure_max_length("aä-öüäöü".to_owned(), 10, 4) + ); + + // The truncation does not leave any character, so only the hash is returned. + assert_eq!( + "d24d".to_owned(), + ensure_max_length("🚀🚀🚀".to_owned(), 6, 4) + ); + } } From b2d9da337823851ad4f40952764d498d07630bc6 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 6 Aug 2026 14:01:09 +0200 Subject: [PATCH 6/6] changelog --- crates/stackable-operator/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 025043780..243a48f0f 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. ### Added -- Add the Cargo feature `kube-cel` that enables the `cel` feature on the `kube` crate ([1259]). +- Add the Cargo feature `kube-cel` that enables the `cel` feature on the `kube` crate ([#1259]). - Add `length_enforcement::ensure_max_length` and `Key::shortened_to_valid_length` helper functions ([#1260]). [#1259]: https://github.com/stackabletech/operator-rs/pull/1259