Skip to content

Commit 82abe54

Browse files
authored
feat: Add Key::new_trimmed helper function (#1260)
* feat(v2): Make `ResourceNames::ensure_max_length` public * changelog * Rework according to feedback * changelog * feat: Also handle UTF-8 * changelog * fix: Don't shorten prefix; Make prefix optional * Rename fn to ensure_max_string_length * Update docs * fix: Remove failing test for has length, which is now checked * Renamed fn to new_trimmed
1 parent 15633a6 commit 82abe54

5 files changed

Lines changed: 250 additions & 105 deletions

File tree

crates/stackable-operator/CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ All notable changes to this project will be documented in this file.
66

77
### Added
88

9-
- Add the Cargo feature `kube-cel` that enables the `cel` feature on the `kube` crate ([1259]).
9+
- Add the Cargo feature `kube-cel` that enables the `cel` feature on the `kube` crate ([#1259]).
10+
- Add `length_enforcement::ensure_max_string_length` and `Key::shortened_to_valid_length` helper functions ([#1260]).
1011

11-
[1259]: https://github.com/stackabletech/operator-rs/pull/1259
12+
[#1259]: https://github.com/stackabletech/operator-rs/pull/1259
13+
[#1260]: https://github.com/stackabletech/operator-rs/pull/1260
1214

1315
## [0.115.0] - 2026-08-04
1416

crates/stackable-operator/src/kvp/key.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use std::{fmt::Display, ops::Deref, str::FromStr, sync::LazyLock};
33
use regex::Regex;
44
use snafu::{ResultExt, Snafu, ensure};
55

6+
use crate::utils::length_enforcement::ensure_max_string_length;
7+
68
const KEY_PREFIX_MAX_LEN: usize = 253;
79
const KEY_NAME_MAX_LEN: usize = 63;
810

@@ -135,6 +137,23 @@ impl Deref for Key {
135137
}
136138

137139
impl Key {
140+
/// Shortens `name` if needed, so that it does not exceed the maximum key name length.
141+
///
142+
/// The `prefix` is used as-is: If it isn't already a valid DNS subdomain name, shortening won't
143+
/// make it one. In particular, a prefix must end in a letters-only TLD, but the appended hash
144+
/// adds a hyphen and probably digits, very likely being an invalid result.
145+
///
146+
/// See [`ensure_max_string_length`] for details on the shortening algorithm.
147+
pub fn new_trimmed(prefix: Option<&str>, name: impl Into<String>) -> Result<Self, KeyError> {
148+
let name = ensure_max_string_length(name, KEY_NAME_MAX_LEN, 8);
149+
150+
let key = match prefix {
151+
Some(prefix) => format!("{prefix}/{name}"),
152+
None => name,
153+
};
154+
Self::from_str(&key)
155+
}
156+
138157
/// Retrieves the key's prefix.
139158
///
140159
/// ```
@@ -362,6 +381,60 @@ mod test {
362381
assert_eq!(key.to_string(), "vendor");
363382
}
364383

384+
#[test]
385+
fn key_shortened_to_valid_length_with_short_enough_name() {
386+
let key = Key::new_trimmed(Some("stackable.tech"), "a".repeat(63)).unwrap();
387+
388+
assert_eq!(key.prefix, Some(KeyPrefix("stackable.tech".into())));
389+
assert_eq!(key.name, KeyName("a".repeat(63)));
390+
assert_eq!(key.name.len(), KEY_NAME_MAX_LEN);
391+
assert_eq!(
392+
key.to_string(),
393+
"stackable.tech/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
394+
);
395+
}
396+
397+
#[test]
398+
fn key_shortened_to_valid_length_with_too_long_name() {
399+
let key = Key::new_trimmed(Some("stackable.tech"), "a".repeat(64)).unwrap();
400+
401+
assert_eq!(key.prefix, Some(KeyPrefix("stackable.tech".into())));
402+
assert_eq!(key.name, KeyName(format!("{}-ffe054fe", "a".repeat(54))));
403+
assert_eq!(key.name.len(), KEY_NAME_MAX_LEN);
404+
assert_eq!(
405+
key.to_string(),
406+
"stackable.tech/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffe054fe"
407+
);
408+
}
409+
410+
#[test]
411+
fn key_shortened_to_valid_length_with_too_long_prefix() {
412+
// The prefix is a valid DNS subdomain name, except for being one character too long.
413+
let prefix = format!("{}.tech", "a".repeat(249));
414+
let error = Key::new_trimmed(Some(&prefix), "myname")
415+
.expect_err("the prefix exceeds the maximum length");
416+
417+
assert_eq!(
418+
error,
419+
KeyError::KeyPrefixError {
420+
source: KeyPrefixError::PrefixTooLong { length: 254 }
421+
}
422+
);
423+
}
424+
425+
#[test]
426+
fn key_shortened_to_valid_length_without_prefix() {
427+
let key = Key::new_trimmed(None, "a".repeat(64)).unwrap();
428+
429+
assert_eq!(key.prefix, None);
430+
assert_eq!(key.name, KeyName(format!("{}-ffe054fe", "a".repeat(54))));
431+
assert_eq!(key.name.len(), KEY_NAME_MAX_LEN);
432+
assert_eq!(
433+
key.to_string(),
434+
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-ffe054fe"
435+
);
436+
}
437+
365438
#[test]
366439
fn prefix_equality() {
367440
const EXAMPLE_PREFIX_STR: &str = "stackable.tech";
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
use sha2::{Digest, Sha256};
2+
3+
/// Ensures that the given input does not exceed the given maximum length.
4+
/// If required, the input is truncated and a hex encoded hash is appended with a dash.
5+
///
6+
/// It is recommended to only use ASCII characters, but this function also handles UTF-8: Multi-byte
7+
/// characters are never split up, so the result can be shorter than the maximum length.
8+
///
9+
/// If the truncation does not leave any character then only the hash is returned.
10+
///
11+
/// # Panics
12+
///
13+
/// Panics if the `hash_length > 64` or
14+
/// `max_length_bytes < 1 /* character */ + 1 /* dash */ + hash_length`.
15+
pub fn ensure_max_string_length(
16+
original: impl Into<String>,
17+
max_length_bytes: usize,
18+
hash_length: usize,
19+
) -> String {
20+
assert!(
21+
hash_length <= 64,
22+
"We hash using sha256, so we don't produce more than 64 bytes"
23+
);
24+
assert!(max_length_bytes >= 1 /* character */ + 1 /* dash */ + hash_length);
25+
26+
let original = original.into();
27+
if original.len() <= max_length_bytes {
28+
return original;
29+
}
30+
if hash_length == 0 {
31+
return truncate_at_char_boundary(original, max_length_bytes);
32+
}
33+
34+
let mut hash = format!("{:x}", Sha256::digest(original.as_bytes()));
35+
hash.truncate(hash_length);
36+
37+
// The result is `<name>-<hash>`, so the name must not occupy the bytes which are reserved
38+
// for the hash.
39+
let mut name = truncate_at_char_boundary(original, max_length_bytes - hash_length);
40+
41+
// Remove one more character to make room for the dash.
42+
let removed_char = name.pop();
43+
44+
if name.is_empty() {
45+
return hash;
46+
}
47+
48+
// A dash at the end of the name is reused as the separator. If the removed character was a
49+
// dash itself then both dashes belong to the name and are kept.
50+
if !name.ends_with('-') || removed_char == Some('-') {
51+
name.push('-');
52+
}
53+
54+
format!("{name}{hash}")
55+
}
56+
57+
/// Truncates the given input to at most `max_length_bytes` bytes.
58+
///
59+
/// The input is only truncated at a character boundary, so a multi-byte character is never split
60+
/// up but dropped entirely.
61+
fn truncate_at_char_boundary(mut input: String, max_length_bytes: usize) -> String {
62+
input.truncate(input.floor_char_boundary(max_length_bytes));
63+
input
64+
}
65+
66+
#[cfg(test)]
67+
mod test {
68+
use super::*;
69+
70+
#[test]
71+
fn ensure_max_string_length_ascii() {
72+
// empty resource name, no hash length
73+
assert_eq!(String::new(), ensure_max_string_length(String::new(), 2, 0));
74+
75+
// resource_name.len() <= max_length
76+
assert_eq!(
77+
"abcdef".to_owned(),
78+
ensure_max_string_length("abcdef".to_owned(), 6, 4)
79+
);
80+
81+
// hash_length == 0
82+
assert_eq!(
83+
"abcdef".to_owned(),
84+
ensure_max_string_length("abcdefg".to_owned(), 6, 0)
85+
);
86+
87+
// hash appended with dash
88+
assert_eq!(
89+
"a-7d1a".to_owned(),
90+
ensure_max_string_length("abcdefg".to_owned(), 6, 4)
91+
);
92+
93+
// hash appended without an extra dash
94+
assert_eq!(
95+
"ab-a1b1".to_owned(),
96+
ensure_max_string_length("ab-defgh".to_owned(), 7, 4)
97+
);
98+
99+
// hash appended without an extra dash
100+
// In this case, the result is one character shorter than the maximum length.
101+
assert_eq!(
102+
"a-3951".to_owned(),
103+
ensure_max_string_length("a-cdefgh".to_owned(), 7, 4)
104+
);
105+
106+
// hash appended without an extra dash
107+
// The two dashes in the given resource name are intentionally kept.
108+
assert_eq!(
109+
"a--f7a0".to_owned(),
110+
ensure_max_string_length("a--defgh".to_owned(), 7, 4)
111+
);
112+
}
113+
114+
/// The maximum length is measured in bytes, so multi-byte characters must not be split up by
115+
/// the truncation. This can make the result shorter than the maximum length.
116+
#[test]
117+
fn ensure_max_string_length_with_multi_byte_characters() {
118+
// The two byte characters fit exactly into the maximum length.
119+
assert_eq!(
120+
"äöü".to_owned(),
121+
ensure_max_string_length("äöü".to_owned(), 6, 4)
122+
);
123+
124+
// Truncating after 5 bytes would split up the "ü", so it is dropped entirely.
125+
assert_eq!(
126+
"äö".to_owned(),
127+
ensure_max_string_length("äöü".to_owned(), 5, 0)
128+
);
129+
130+
// The 5 bytes reserved for the name only fit "äö", of which the "ö" is then replaced by
131+
// the dash, so the result is two bytes shorter than the maximum length.
132+
assert_eq!(
133+
"ä-e109".to_owned(),
134+
ensure_max_string_length("äöüäöü".to_owned(), 9, 4)
135+
);
136+
137+
// hash appended with dash, three byte characters
138+
assert_eq!(
139+
"日-9efa".to_owned(),
140+
ensure_max_string_length("日本語日本語".to_owned(), 10, 4)
141+
);
142+
143+
// hash appended with dash, four byte characters
144+
assert_eq!(
145+
"🚀-a13c".to_owned(),
146+
ensure_max_string_length("🚀🚀🚀🚀".to_owned(), 13, 4)
147+
);
148+
149+
// The trailing dash of the truncated name is replaced by the dash which separates the
150+
// hash.
151+
assert_eq!(
152+
"aä-f726".to_owned(),
153+
ensure_max_string_length("aä-öüb".to_owned(), 8, 4)
154+
);
155+
156+
// The truncated name is "aä-ö", so the "ö" is dropped and the existing dash is reused.
157+
assert_eq!(
158+
"aä-ae0c".to_owned(),
159+
ensure_max_string_length("aä-öüäöü".to_owned(), 10, 4)
160+
);
161+
162+
// The truncation does not leave any character, so only the hash is returned.
163+
assert_eq!(
164+
"d24d".to_owned(),
165+
ensure_max_string_length("🚀🚀🚀".to_owned(), 6, 4)
166+
);
167+
}
168+
}

crates/stackable-operator/src/utils/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod bash;
22
pub mod cluster_info;
33
pub mod crds;
44
pub mod kubelet;
5+
pub mod length_enforcement;
56
pub mod logging;
67
pub mod signal;
78

0 commit comments

Comments
 (0)