Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/stackable-operator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions crates/stackable-operator/src/kvp/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String>,
name: impl Into<String>,
) -> Result<Self, KeyError> {
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.
///
/// ```
Expand Down
164 changes: 164 additions & 0 deletions crates/stackable-operator/src/utils/length_enforcement.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
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 `<name>-<hash>`, 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)
);
}
}
1 change: 1 addition & 0 deletions crates/stackable-operator/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
107 changes: 4 additions & 103 deletions crates/stackable-operator/src/v2/role_group_utils.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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)
);
}
}