Skip to content
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_string_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
76 changes: 76 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_string_length;

const KEY_PREFIX_MAX_LEN: usize = 253;
const KEY_NAME_MAX_LEN: usize = 63;

Expand Down Expand Up @@ -135,6 +137,26 @@ impl Deref for Key {
}

impl 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<String>,
) -> Result<Self, KeyError> {
let name = ensure_max_string_length(name, KEY_NAME_MAX_LEN, 8);

let key = match prefix {
Some(prefix) => format!("{prefix}/{name}"),
None => name,
};
Self::from_str(&key)
}

/// Retrieves the key's prefix.
///
/// ```
Expand Down Expand Up @@ -362,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";
Expand Down
168 changes: 168 additions & 0 deletions crates/stackable-operator/src/utils/length_enforcement.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 the `hash_length > 64` or
/// `max_length_bytes < 1 /* character */ + 1 /* dash */ + hash_length`.
pub fn ensure_max_string_length(
original: impl Into<String>,
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();
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 ensure_max_string_length_ascii() {
// empty resource name, no hash length
assert_eq!(String::new(), ensure_max_string_length(String::new(), 2, 0));

// resource_name.len() <= max_length
assert_eq!(
"abcdef".to_owned(),
ensure_max_string_length("abcdef".to_owned(), 6, 4)
);

// hash_length == 0
assert_eq!(
"abcdef".to_owned(),
ensure_max_string_length("abcdefg".to_owned(), 6, 0)
);

// hash appended with dash
assert_eq!(
"a-7d1a".to_owned(),
ensure_max_string_length("abcdefg".to_owned(), 6, 4)
);

// hash appended without an extra dash
assert_eq!(
"ab-a1b1".to_owned(),
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_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_string_length("a--defgh".to_owned(), 7, 4)
);
}

/// 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 ensure_max_string_length_with_multi_byte_characters() {
// The two byte characters fit exactly into the maximum length.
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_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_string_length("äöüäöü".to_owned(), 9, 4)
);

// hash appended with dash, three byte characters
assert_eq!(
"日-9efa".to_owned(),
ensure_max_string_length("日本語日本語".to_owned(), 10, 4)
);

// hash appended with dash, four byte characters
assert_eq!(
"🚀-a13c".to_owned(),
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_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_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_string_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
Loading