From 0d683e3a75979c29861eee9e777235af96ba0a0c Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Wed, 5 Aug 2026 16:09:04 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 From a9cbd5262a4965c774e965fd39ff6d17975f114a Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Fri, 7 Aug 2026 08:21:56 +0200 Subject: [PATCH 07/10] fix: Don't shorten prefix; Make prefix optional --- crates/stackable-operator/src/kvp/key.rs | 67 ++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/stackable-operator/src/kvp/key.rs b/crates/stackable-operator/src/kvp/key.rs index ef73fc142..059212ec6 100644 --- a/crates/stackable-operator/src/kvp/key.rs +++ b/crates/stackable-operator/src/kvp/key.rs @@ -141,13 +141,20 @@ impl Key { /// /// See [`ensure_max_length`] for details on the shortening algorithm. pub fn shortened_to_valid_length( - prefix: impl Into, + prefix: Option<&str>, name: impl Into, ) -> Result { - let prefix = ensure_max_length(prefix, KEY_PREFIX_MAX_LEN, 8); + // Note that we are *not* shortening the prefix: If it isn't already a valid DNS subdomain + // name, shortening won't make it one. In particular, a prefix must end in a letters-only + // TLD, but the appended hash adds a hyphen and probably digits, very likely being an + // invalid result. let name = ensure_max_length(name, KEY_NAME_MAX_LEN, 8); - Self::from_str(&format!("{prefix}/{name}")) + let key = match prefix { + Some(prefix) => format!("{prefix}/{name}"), + None => name, + }; + Self::from_str(&key) } /// Retrieves the key's prefix. @@ -377,6 +384,60 @@ mod test { assert_eq!(key.to_string(), "vendor"); } + #[test] + fn key_shortened_to_valid_length_with_short_enough_name() { + let key = Key::shortened_to_valid_length(Some("stackable.tech"), "a".repeat(63)).unwrap(); + + assert_eq!(key.prefix, Some(KeyPrefix("stackable.tech".into()))); + assert_eq!(key.name, KeyName("a".repeat(63))); + assert_eq!(key.name.len(), KEY_NAME_MAX_LEN); + assert_eq!( + key.to_string(), + "stackable.tech/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + + #[test] + fn key_shortened_to_valid_length_with_too_long_name() { + let key = Key::shortened_to_valid_length(Some("stackable.tech"), "a".repeat(64)).unwrap(); + + assert_eq!(key.prefix, Some(KeyPrefix("stackable.tech".into()))); + assert_eq!(key.name, KeyName(format!("{}-ffe054fe", "a".repeat(54)))); + assert_eq!(key.name.len(), KEY_NAME_MAX_LEN); + assert_eq!( + key.to_string(), + "stackable.tech/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffe054fe" + ); + } + + #[test] + fn key_shortened_to_valid_length_with_too_long_prefix() { + // The prefix is a valid DNS subdomain name, except for being one character too long. + let prefix = format!("{}.tech", "a".repeat(249)); + let error = Key::shortened_to_valid_length(Some(&prefix), "myname") + .expect_err("the prefix exceeds the maximum length"); + + assert_eq!( + error, + KeyError::KeyPrefixError { + source: KeyPrefixError::PrefixTooLong { length: 254 } + } + ); + } + + #[test] + fn key_shortened_to_valid_length_without_prefix() { + let key = Key::shortened_to_valid_length(None, "a".repeat(64)).unwrap(); + + assert_eq!(key.prefix, None); + assert_eq!(key.name, KeyName(format!("{}-ffe054fe", "a".repeat(54)))); + assert_eq!(key.name.len(), KEY_NAME_MAX_LEN); + assert_eq!( + key.to_string(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffe054fe" + ); + } + #[test] fn prefix_equality() { const EXAMPLE_PREFIX_STR: &str = "stackable.tech"; From 0c8b4fcb357c4ab059126a3f4b780a873be7acda Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Fri, 7 Aug 2026 08:24:30 +0200 Subject: [PATCH 08/10] Rename fn to ensure_max_string_length --- crates/stackable-operator/CHANGELOG.md | 2 +- crates/stackable-operator/src/kvp/key.rs | 6 +-- .../src/utils/length_enforcement.rs | 44 +++++++++++-------- .../src/v2/role_group_utils.rs | 4 +- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 243a48f0f..1b85161d3 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -7,7 +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]). -- Add `length_enforcement::ensure_max_length` and `Key::shortened_to_valid_length` helper functions ([#1260]). +- Add `length_enforcement::ensure_max_string_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 059212ec6..629d0e86d 100644 --- a/crates/stackable-operator/src/kvp/key.rs +++ b/crates/stackable-operator/src/kvp/key.rs @@ -3,7 +3,7 @@ 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; +use crate::utils::length_enforcement::ensure_max_string_length; const KEY_PREFIX_MAX_LEN: usize = 253; const KEY_NAME_MAX_LEN: usize = 63; @@ -139,7 +139,7 @@ impl Deref for Key { impl Key { /// (Optionally) shortens the `prefix` and `name` to make sure they produce a valid [`Key`]. /// - /// See [`ensure_max_length`] for details on the shortening algorithm. + /// See [`ensure_max_string_length`] for details on the shortening algorithm. pub fn shortened_to_valid_length( prefix: Option<&str>, name: impl Into, @@ -148,7 +148,7 @@ impl Key { // name, shortening won't make it one. In particular, a prefix must end in a letters-only // TLD, but the appended hash adds a hyphen and probably digits, very likely being an // invalid result. - let name = ensure_max_length(name, KEY_NAME_MAX_LEN, 8); + let name = ensure_max_string_length(name, KEY_NAME_MAX_LEN, 8); let key = match prefix { Some(prefix) => format!("{prefix}/{name}"), diff --git a/crates/stackable-operator/src/utils/length_enforcement.rs b/crates/stackable-operator/src/utils/length_enforcement.rs index 87b171939..bbd8f430f 100644 --- a/crates/stackable-operator/src/utils/length_enforcement.rs +++ b/crates/stackable-operator/src/utils/length_enforcement.rs @@ -11,7 +11,7 @@ use sha2::{Digest, Sha256}; /// # Panics /// /// Panics if `max_length_bytes < 1 /* character */ + 1 /* dash */ + hash_length`. -pub fn ensure_max_length( +pub fn ensure_max_string_length( original: impl Into, max_length_bytes: usize, hash_length: usize, @@ -63,102 +63,108 @@ mod test { use super::*; #[test] - fn test_ensure_max_length() { + fn ensure_max_string_length_ascii() { // empty resource name, no hash length - assert_eq!(String::new(), ensure_max_length(String::new(), 2, 0)); + assert_eq!(String::new(), ensure_max_string_length(String::new(), 2, 0)); // resource_name.len() <= max_length assert_eq!( "abcdef".to_owned(), - ensure_max_length("abcdef".to_owned(), 6, 4) + ensure_max_string_length("abcdef".to_owned(), 6, 4) ); // hash_length == 0 assert_eq!( "abcdef".to_owned(), - ensure_max_length("abcdefg".to_owned(), 6, 0) + ensure_max_string_length("abcdefg".to_owned(), 6, 0) ); // hash appended with dash assert_eq!( "a-7d1a".to_owned(), - ensure_max_length("abcdefg".to_owned(), 6, 4) + ensure_max_string_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) + ensure_max_string_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) + ensure_max_string_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) + ensure_max_string_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) + ensure_max_string_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() { + fn ensure_max_string_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)); + assert_eq!( + "äöü".to_owned(), + ensure_max_string_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)); + assert_eq!( + "äö".to_owned(), + ensure_max_string_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) + ensure_max_string_length("äöüäöü".to_owned(), 9, 4) ); // hash appended with dash, three byte characters assert_eq!( "日-9efa".to_owned(), - ensure_max_length("日本語日本語".to_owned(), 10, 4) + ensure_max_string_length("日本語日本語".to_owned(), 10, 4) ); // hash appended with dash, four byte characters assert_eq!( "🚀-a13c".to_owned(), - ensure_max_length("🚀🚀🚀🚀".to_owned(), 13, 4) + ensure_max_string_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) + ensure_max_string_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) + ensure_max_string_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) + ensure_max_string_length("🚀🚀🚀".to_owned(), 6, 4) ); } } diff --git a/crates/stackable-operator/src/v2/role_group_utils.rs b/crates/stackable-operator/src/v2/role_group_utils.rs index d6600402b..6bb5a3fe2 100644 --- a/crates/stackable-operator/src/v2/role_group_utils.rs +++ b/crates/stackable-operator/src/v2/role_group_utils.rs @@ -6,7 +6,7 @@ use super::types::{ }, operator::{ClusterName, RoleGroupName, RoleName}, }; -use crate::{attributed_string_type, utils::length_enforcement::ensure_max_length}; +use crate::{attributed_string_type, utils::length_enforcement::ensure_max_string_length}; attributed_string_type! { QualifiedRoleGroupName, @@ -79,7 +79,7 @@ impl ResourceNames { ); // `concatenated_name` contains only ASCII characters. assert!(concatenated_name.is_ascii()); - let sanitized_name = ensure_max_length( + let sanitized_name = ensure_max_string_length( concatenated_name, QualifiedRoleGroupName::MAX_LENGTH, HASH_LENGTH, From 32fb08e5ba8f1bffe07d080c8917fe4859735b5b Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Fri, 7 Aug 2026 08:37:31 +0200 Subject: [PATCH 09/10] Update docs --- crates/stackable-operator/src/kvp/key.rs | 10 +++++----- .../stackable-operator/src/utils/length_enforcement.rs | 7 ++++++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/stackable-operator/src/kvp/key.rs b/crates/stackable-operator/src/kvp/key.rs index 629d0e86d..cc8b8d966 100644 --- a/crates/stackable-operator/src/kvp/key.rs +++ b/crates/stackable-operator/src/kvp/key.rs @@ -137,17 +137,17 @@ impl Deref for Key { } impl Key { - /// (Optionally) shortens the `prefix` and `name` to make sure they produce a valid [`Key`]. + /// Shortens `name` if needed, so that it does not exceed the maximum key name length. + /// + /// The `prefix` is used as-is: If it isn't already a valid DNS subdomain name, shortening won't + /// make it one. In particular, a prefix must end in a letters-only TLD, but the appended hash + /// adds a hyphen and probably digits, very likely being an invalid result. /// /// See [`ensure_max_string_length`] for details on the shortening algorithm. pub fn shortened_to_valid_length( prefix: Option<&str>, name: impl Into, ) -> Result { - // Note that we are *not* shortening the prefix: If it isn't already a valid DNS subdomain - // name, shortening won't make it one. In particular, a prefix must end in a letters-only - // TLD, but the appended hash adds a hyphen and probably digits, very likely being an - // invalid result. let name = ensure_max_string_length(name, KEY_NAME_MAX_LEN, 8); let key = match prefix { diff --git a/crates/stackable-operator/src/utils/length_enforcement.rs b/crates/stackable-operator/src/utils/length_enforcement.rs index bbd8f430f..894b4845a 100644 --- a/crates/stackable-operator/src/utils/length_enforcement.rs +++ b/crates/stackable-operator/src/utils/length_enforcement.rs @@ -10,12 +10,17 @@ use sha2::{Digest, Sha256}; /// /// # Panics /// -/// Panics if `max_length_bytes < 1 /* character */ + 1 /* dash */ + hash_length`. +/// Panics if the `hash_length > 64` or +/// `max_length_bytes < 1 /* character */ + 1 /* dash */ + hash_length`. pub fn ensure_max_string_length( original: impl Into, max_length_bytes: usize, hash_length: usize, ) -> String { + assert!( + hash_length <= 64, + "We hash using sha256, so we don't produce more than 64 bytes" + ); assert!(max_length_bytes >= 1 /* character */ + 1 /* dash */ + hash_length); let original = original.into(); From 03d8966c3ac58f42ad637e1eabaa14598150b9e3 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Fri, 7 Aug 2026 11:28:42 +0200 Subject: [PATCH 10/10] fix: Remove failing test for has length, which is now checked --- crates/stackable-operator/src/utils/length_enforcement.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/stackable-operator/src/utils/length_enforcement.rs b/crates/stackable-operator/src/utils/length_enforcement.rs index 894b4845a..08dc95316 100644 --- a/crates/stackable-operator/src/utils/length_enforcement.rs +++ b/crates/stackable-operator/src/utils/length_enforcement.rs @@ -109,13 +109,6 @@ mod test { "a--f7a0".to_owned(), ensure_max_string_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_string_length("a".repeat(1011), 1010, 1000) - ); } /// The maximum length is measured in bytes, so multi-byte characters must not be split up by