1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
//! Shared helpers for TOML-backed user-facing registries (categories, tags).
//!
//! Extracted in M171 Lane A so `CategoryRegistry` and `TagRegistry` share a
//! single authoritative name validator. If we ever need a third registry type
//! we can grow this module to include atomic write + soft-recovery machinery
//! as well; for now the minimum wins are:
//!
//! * `MAX_REGISTRY_NAME_LEN` — the shared 255-byte cap.
//! * `validate_registry_name` — returns `Err(reason)` with a human-readable
//! message; callers wrap the reason in their own error type (e.g.
//! `CategoryError::InvalidName` / `TagError::InvalidName`).
/// Maximum allowed length in bytes for a registry name. Matches qBt's 255-byte
/// limit on category names and is reused verbatim for tag names.
pub const MAX_REGISTRY_NAME_LEN: usize = 255;
/// qBt-parity name validation shared by category + tag registries.
///
/// Rules (identical to the former `category_manager::validate_name`):
/// * non-empty and not whitespace-only
/// * ≤ `MAX_REGISTRY_NAME_LEN` UTF-8 bytes
/// * characters restricted to `[a-zA-Z0-9_/-]`
/// * nested names via `/` allowed (`movies/4k`)
/// * reject leading `/`, empty segments, and `..` parent-traversal segments
///
/// `context` is an English noun used in the error messages (e.g. `"category"`
/// or `"tag"`) so the failure reason reads naturally when surfaced to the
/// caller.
pub fn validate_registry_name(name: &str, context: &str) -> Result<(), String> {
if name.is_empty() {
return Err(format!("{context} name is empty"));
}
if name.trim().is_empty() {
return Err(format!("{context} name is whitespace-only"));
}
if name.len() > MAX_REGISTRY_NAME_LEN {
return Err(format!(
"{context} name exceeds {MAX_REGISTRY_NAME_LEN} bytes"
));
}
if name.starts_with('/') {
return Err(format!("{context} name starts with '/': {name}"));
}
for segment in name.split('/') {
if segment == ".." || segment.is_empty() {
return Err(format!("{context} name contains invalid segment: {name}"));
}
for ch in segment.chars() {
let ok = ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-');
if !ok {
return Err(format!(
"{context} name contains illegal character '{ch}' in '{name}'"
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_names_accepted() {
for good in &[
"sonarr",
"radarr",
"kids/tv",
"a-b_c",
"movies/4k",
"letters_and_123",
"a",
] {
assert!(
validate_registry_name(good, "category").is_ok(),
"expected {good:?} to validate",
);
assert!(validate_registry_name(good, "tag").is_ok());
}
}
#[test]
fn invalid_names_rejected() {
for bad in &[
"",
" ",
"/leading",
"a/../b",
"with space",
"has!bang",
"a//b",
"trail/",
"..",
"dot/.",
] {
assert!(
validate_registry_name(bad, "category").is_err(),
"expected {bad:?} to be rejected",
);
}
}
#[test]
fn length_cap_enforced() {
let ok = "a".repeat(MAX_REGISTRY_NAME_LEN);
let too_long = "a".repeat(MAX_REGISTRY_NAME_LEN + 1);
assert!(validate_registry_name(&ok, "tag").is_ok());
assert!(validate_registry_name(&too_long, "tag").is_err());
}
#[test]
fn context_flows_into_error_message() {
let err = validate_registry_name("", "tag").unwrap_err();
assert!(err.contains("tag"), "expected context in error: {err}");
let err = validate_registry_name("", "category").unwrap_err();
assert!(err.contains("category"), "expected context in error: {err}");
}
#[test]
fn case_sensitive_alphabet() {
assert!(validate_registry_name("Sonarr", "tag").is_ok());
assert!(validate_registry_name("SONARR", "tag").is_ok());
assert!(validate_registry_name("sonarr", "tag").is_ok());
}
}