use serde::{Serialize, Serializer};
const MAX_IDENTIFIER_LEN: usize = 64;
const MAX_PHRASE_LEN: usize = 80;
const MAX_ERROR_LEN: usize = 200;
const OPAQUE_RUN_LEN: usize = 20;
const FINGERPRINT_HEX_LEN: usize = 12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SafeLabel {
text: String,
redacted: bool,
}
impl SafeLabel {
pub(crate) fn identifier(raw: &str) -> Self {
let trimmed = raw.trim();
if identifier_is_allowlisted(trimmed) {
Self {
text: trimmed.to_string(),
redacted: false,
}
} else {
Self::fingerprint(raw)
}
}
pub(crate) fn catalog_model(raw: &str) -> Self {
let trimmed = raw.trim();
if !trimmed.contains('/') {
return Self::identifier(raw);
}
if catalog_model_identifier_is_allowlisted(trimmed) {
Self {
text: trimmed.to_string(),
redacted: false,
}
} else {
Self::fingerprint(raw)
}
}
pub(crate) fn phrase(raw: &str) -> Self {
let trimmed = raw.trim();
if phrase_is_allowlisted(trimmed) {
Self {
text: trimmed.to_string(),
redacted: false,
}
} else {
Self::fingerprint(raw)
}
}
fn fingerprint(raw: &str) -> Self {
let digest = crate::hashing::sha256_hex(raw.as_bytes());
Self {
text: format!("sha256:{}", &digest[..FINGERPRINT_HEX_LEN]),
redacted: true,
}
}
pub(crate) fn as_str(&self) -> &str {
&self.text
}
#[cfg(test)]
pub(crate) fn is_redacted(&self) -> bool {
self.redacted
}
}
impl std::fmt::Display for SafeLabel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
impl Serialize for SafeLabel {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.text)
}
}
fn identifier_is_allowlisted(value: &str) -> bool {
if value.is_empty() || value.len() > MAX_IDENTIFIER_LEN {
return false;
}
if value.starts_with('/') || value.starts_with('~') || value.starts_with('.') {
return false;
}
if value.contains("//") || value.contains("..") || value.contains(':') && value.contains('/') {
return false;
}
if !value.chars().all(is_identifier_char) {
return false;
}
if value.contains('/') {
return false;
}
!looks_opaque(value)
}
fn catalog_model_identifier_is_allowlisted(value: &str) -> bool {
if value.is_empty()
|| value.len() > MAX_IDENTIFIER_LEN
|| value.starts_with('/')
|| value.starts_with('~')
|| value.starts_with('.')
|| value.contains("//")
|| value.contains("..")
|| value.contains(':')
|| !value.chars().all(is_identifier_char)
|| looks_opaque(value)
{
return false;
}
crate::model_catalog::resolved_entry(value)
.is_some_and(|entry| entry.id == value || entry.provider_model_id.as_deref() == Some(value))
}
fn phrase_is_allowlisted(value: &str) -> bool {
if value.is_empty() || value.len() > MAX_PHRASE_LEN {
return false;
}
if value.contains('/') || value.contains('\\') || value.contains('~') {
return false;
}
if !value
.chars()
.all(|ch| is_identifier_char(ch) || matches!(ch, ' ' | '(' | ')' | ','))
{
return false;
}
!looks_opaque(value)
}
fn is_identifier_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '+' | '@' | ':' | '/')
}
fn looks_opaque(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
for marker in ["sk-", "api_key", "apikey", "secret", "password", "bearer"] {
if lower.contains(marker) {
return true;
}
}
let mut run = 0usize;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
run += 1;
if run >= OPAQUE_RUN_LEN {
return true;
}
} else {
run = 0;
}
}
false
}
const MAX_ERROR_WORD_LEN: usize = 40;
const MAX_SCHEME_LEN: usize = 16;
const MAX_HOST_LEN: usize = 80;
const REDACTED_WORD: &str = "<redacted>";
const REDACTED_PATH: &str = "<path-redacted>";
pub(crate) fn safe_error_text(raw: &str) -> String {
let redacted = codewhale_config::persistence::redact_secrets(raw);
let mut out = String::with_capacity(redacted.len().min(MAX_ERROR_LEN));
let mut last_was_redacted = false;
for token in redacted.split_whitespace() {
let safe = safe_error_token(token);
if safe.is_empty() {
continue;
}
let is_redacted = safe == REDACTED_WORD;
if is_redacted && last_was_redacted {
continue;
}
last_was_redacted = is_redacted;
if !out.is_empty() {
out.push(' ');
}
out.push_str(&safe);
}
if out.is_empty() {
out.push_str("<unavailable>");
}
if out.len() > MAX_ERROR_LEN {
out.truncate(
(0..=MAX_ERROR_LEN)
.rev()
.find(|index| out.is_char_boundary(*index))
.unwrap_or(0),
);
out.push('…');
}
out
}
fn safe_error_token(token: &str) -> String {
if token.chars().any(char::is_control) {
return REDACTED_WORD.to_string();
}
if let Some(scheme_end) = token.find("://") {
return safe_url_token(token, scheme_end);
}
let looks_like_path = token.starts_with('/')
|| token.starts_with("~/")
|| token.contains('\\')
|| (token.len() > 2 && token.as_bytes()[1] == b':' && token.contains('\\'));
if looks_like_path {
return REDACTED_PATH.to_string();
}
if token.contains(['"', '\'', '`']) {
return REDACTED_WORD.to_string();
}
let (lead, core, trail) = split_sentence_punctuation(token);
if core.is_empty() {
return format!("{lead}{trail}");
}
if error_word_is_allowlisted(core) {
format!("{lead}{core}{trail}")
} else {
REDACTED_WORD.to_string()
}
}
fn safe_url_token(token: &str, scheme_end: usize) -> String {
let scheme = &token[..scheme_end];
let rest = &token[scheme_end + 3..];
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let host = rest[..authority_end].rsplit('@').next().unwrap_or("");
let scheme_ok = !scheme.is_empty()
&& scheme.len() <= MAX_SCHEME_LEN
&& scheme
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'));
let host_ok = !host.is_empty()
&& host.len() <= MAX_HOST_LEN
&& host
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | ':'))
&& !looks_opaque(host);
if scheme_ok && host_ok {
format!("{scheme}://{host}/{REDACTED_PATH}")
} else {
REDACTED_WORD.to_string()
}
}
fn is_edge_punctuation(ch: char) -> bool {
matches!(ch, '.' | ',' | ';' | ':' | '!' | '?' | '(' | ')')
}
fn split_sentence_punctuation(token: &str) -> (&str, &str, &str) {
let start = token
.char_indices()
.find(|(_, ch)| !is_edge_punctuation(*ch))
.map_or(token.len(), |(index, _)| index);
let end = token
.char_indices()
.rev()
.find(|(_, ch)| !is_edge_punctuation(*ch))
.map_or(start, |(index, ch)| index + ch.len_utf8());
(
&token[..start],
&token[start..end.max(start)],
&token[end.max(start)..],
)
}
fn error_word_is_allowlisted(word: &str) -> bool {
if word.is_empty() || word.len() > MAX_ERROR_WORD_LEN {
return false;
}
if word.contains("..") {
return false;
}
if !word
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
{
return false;
}
!looks_opaque(word)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_identifiers_pass_through_verbatim() {
for value in [
"deepseek-chat",
"claude-sonnet-4-5",
"gpt-5-codex",
"MiniMax-M3",
"my-gateway",
"kimi-k2-0905-preview",
] {
let label = SafeLabel::identifier(value);
assert_eq!(label.as_str(), value, "{value} must publish verbatim");
assert!(!label.is_redacted(), "{value}");
}
}
#[test]
fn hostile_route_and_model_identifiers_never_reach_a_surface() {
let hostile = [
"/Users/someone/models/private-weights.gguf".to_string(),
"~/.codewhale/config.toml".to_string(),
"https://internal.example.com/v1/deployments/prod".to_string(),
"C:\\Users\\someone\\models\\weights.bin".to_string(),
["sk", "-fixture-not-a-real-key-00000000"].concat(),
"deployments/9f8e7d6c5b4a39281706abcdef012345".to_string(),
"../../etc/passwd".to_string(),
"model with spaces and a /path/inside".to_string(),
["api_key=sk", "-live-1234567890"].concat(),
"src/lib.rs".to_string(),
"config/prod".to_string(),
"models/weights.gguf".to_string(),
"foo/bar-baz".to_string(),
];
for value in hostile {
let label = SafeLabel::identifier(&value);
assert!(label.is_redacted(), "`{value}` must not publish verbatim");
assert!(label.as_str().starts_with("sha256:"), "{}", label.as_str());
assert!(!label.as_str().contains('/'), "{}", label.as_str());
assert!(!label.as_str().contains(' '), "{}", label.as_str());
}
}
#[test]
fn generic_identifiers_reject_all_slashes_and_catalog_models_require_exact_ids() {
let _catalog_guard = crate::model_catalog::test_catalog_lock();
for path in [
"src/lib.rs",
"docs/PREVIEW_REQUEST.md",
"config/prod",
"models/llama-3.gguf",
"foo/bar-baz",
] {
assert!(
SafeLabel::identifier(path).is_redacted(),
"relative path `{path}` must not be published"
);
}
let known = "qwen/qwen3.6-flash";
assert!(SafeLabel::identifier(known).is_redacted());
assert_eq!(SafeLabel::catalog_model(known).as_str(), known);
for hostile in ["openai/secrets/config", "qwen/src/lib.rs"] {
assert!(SafeLabel::identifier(hostile).is_redacted());
assert!(SafeLabel::catalog_model(hostile).is_redacted());
}
}
#[test]
fn fingerprints_are_stable_and_distinguishing() {
let first = SafeLabel::identifier("/models/a.gguf");
let second = SafeLabel::identifier("/models/a.gguf");
let other = SafeLabel::identifier("/models/b.gguf");
assert_eq!(first, second);
assert_ne!(first, other);
}
#[test]
fn phrases_allow_spaces_but_not_paths() {
assert_eq!(
SafeLabel::phrase("Codex OAuth quota").as_str(),
"Codex OAuth quota"
);
assert!(SafeLabel::phrase("/opt/quota/plan").is_redacted());
}
#[test]
fn error_text_is_path_and_url_path_safe() {
let raw = "MCP server 'x' failed: cannot spawn /Users/someone/work/repo/bin/server \
while calling https://gateway.internal.example.com/v1/secret-deployment/messages";
let safe = safe_error_text(raw);
assert!(!safe.contains("/Users/someone"), "{safe}");
assert!(!safe.contains("/v1/secret-deployment"), "{safe}");
assert!(safe.contains("<path-redacted>"), "{safe}");
assert!(
safe.contains("https://gateway.internal.example.com/<path-redacted>"),
"{safe}"
);
}
#[test]
fn hostile_error_text_never_publishes_the_hostile_part() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let hostile: Vec<String> = vec![
"route 'prod-key-8f2a' rejected key sk-live-abcdef0123456789abcdef".to_string(),
"cannot read C:\\Users\\someone\\.codewhale\\config.toml".to_string(),
format!("cannot read {home}/.codewhale/config.toml"),
"GET https://gw.example.com/v1/deployments/prod-key-8f2a?api_key=sk-1234567890abcdef failed".to_string(),
"server \"my secret server\" refused: password=hunter2".to_string(),
"model /Users/someone/models/private.gguf is unavailable".to_string(),
"authorization: Bearer eyJhbGciFAKEFIXTUREnotasecret".to_string(),
"endpoint http://10.0.0.5:8443/internal/deploy-9f8e7d6c5b4a3928 timed out".to_string(),
format!("crash{}oops", '\u{7}'),
];
for raw in &hostile {
let safe = safe_error_text(raw);
for forbidden in [
"prod-key-8f2a",
"sk-live-",
"sk-1234567890",
"/Users/someone",
"C:\\Users",
".codewhale",
"api_key=",
"hunter2",
"password=",
"eyJhbGci",
"/v1/deployments",
"/internal/deploy",
"private.gguf",
"my secret server",
] {
assert!(
!safe.contains(forbidden),
"`{forbidden}` leaked from `{raw}`:\n{safe}"
);
}
assert!(!safe.contains(&home), "home leaked from `{raw}`:\n{safe}");
assert!(!safe.contains('"'), "{safe}");
assert!(!safe.contains('\''), "{safe}");
assert!(!safe.contains('`'), "{safe}");
assert!(!safe.chars().any(char::is_control), "{safe}");
}
}
#[test]
fn ordinary_error_words_survive_so_the_message_stays_useful() {
let safe = safe_error_text("the shared route planner could not resolve this turn.");
assert_eq!(
safe, "the shared route planner could not resolve this turn.",
"an allowlisted sentence must survive intact"
);
}
#[test]
fn a_url_with_a_hostile_authority_is_dropped_rather_than_half_published() {
let safe = safe_error_text("calling https://9f8e7d6c5b4a39281706abcdef012345.example/x");
assert!(!safe.contains("9f8e7d6c5b4a3928"), "{safe}");
assert!(safe.contains("<redacted>"), "{safe}");
}
#[test]
fn error_text_is_bounded_and_single_line() {
let raw = format!("failure {}", "x".repeat(4_000));
let safe = safe_error_text(&raw);
assert!(safe.chars().count() <= MAX_ERROR_LEN + 1, "{}", safe.len());
assert!(!safe.contains('\n'));
}
}