diff --git a/crates/stackable-operator/CHANGELOG.md b/crates/stackable-operator/CHANGELOG.md index 22f82faa6..243a48f0f 100644 --- a/crates/stackable-operator/CHANGELOG.md +++ b/crates/stackable-operator/CHANGELOG.md @@ -6,9 +6,11 @@ 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 +[#1259]: https://github.com/stackabletech/operator-rs/pull/1259 +[#1260]: https://github.com/stackabletech/operator-rs/pull/1260 ## [0.115.0] - 2026-08-04 diff --git a/crates/stackable-operator/src/kvp/key.rs b/crates/stackable-operator/src/kvp/key.rs index 65336e4a0..ef73fc142 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,19 @@ 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. + pub fn shortened_to_valid_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..87b171939 --- /dev/null +++ b/crates/stackable-operator/src/utils/length_enforcement.rs @@ -0,0 +1,164 @@ +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_bytes < 1 /* character */ + 1 /* dash */ + hash_length`. +pub fn ensure_max_length( + original: impl Into, + max_length_bytes: usize, + hash_length: usize, +) -> String { + assert!(max_length_bytes >= 1 /* character */ + 1 /* dash */ + hash_length); + + let original = original.into(); + 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)] +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) + ); + } + + /// 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) + ); + } +} 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/role_group_utils.rs b/crates/stackable-operator/src/v2/role_group_utils.rs index 1b25d2266..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,61 +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. - 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!( @@ -334,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) - ); - } }