tibba_util/validate/
mod.rs1use std::collections::HashSet;
23use std::env;
24use std::sync::LazyLock;
25use validator::ValidationError;
26
27type Result<T> = std::result::Result<T, ValidationError>;
28
29mod common;
30mod user;
31
32fn env_key(code: &str) -> String {
35 code.replace('-', "_").to_lowercase()
36}
37
38fn collect_disabled(lookup: impl Fn(&str) -> Option<String>) -> HashSet<&'static str> {
44 ALL_CODES
45 .iter()
46 .copied()
47 .filter(|code| lookup(&env_key(code)).as_deref() == Some("*"))
48 .collect()
49}
50
51static DISABLED_CODES: LazyLock<HashSet<&'static str>> =
61 LazyLock::new(|| collect_disabled(|key| env::var(key).ok()));
62
63fn is_disabled(code: &str) -> bool {
65 DISABLED_CODES.contains(code)
66}
67
68fn new_error(code: &'static str, message: String) -> ValidationError {
69 ValidationError::new(code).with_message(message.into())
70}
71
72pub(super) fn is_identifier_char(c: char) -> bool {
78 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
79}
80
81pub(super) fn has_control_or_space(value: &str) -> bool {
86 value.chars().any(|c| c.is_ascii_control() || c == ' ')
87}
88
89fn validate_ascii_name(
94 name: &str,
95 code: &'static str,
96 max_len: usize,
97 field_name: &str,
98) -> Result<()> {
99 if name.is_empty() {
100 return Err(new_error(code, format!("{field_name} cannot be empty")));
101 }
102 if !name.is_ascii() {
103 return Err(new_error(code, format!("{field_name} must be ASCII")));
104 }
105 if name.len() > max_len {
106 return Err(new_error(
107 code,
108 format!("{field_name} must be less than {max_len} characters"),
109 ));
110 }
111 if !name.chars().all(is_identifier_char) {
112 return Err(new_error(
113 code,
114 format!("{field_name} may only contain letters, digits, '_', '-' and '.'"),
115 ));
116 }
117 Ok(())
118}
119
120macro_rules! validate_codes {
125 ($($(#[$attr:meta])* $name:ident = $value:literal;)+) => {
126 $($(#[$attr])* pub const $name: &str = $value;)+
127
128 const ALL_CODES: &[&str] = &[$($name),+];
130 };
131}
132
133validate_codes! {
134 CODE_USER_ACCOUNT = "x-user-account";
136 CODE_USER_ACCOUNT_STRICT = "x-user-account-strict";
137 CODE_USER_PASSWORD = "x-user-password";
138 CODE_USER_EMAIL = "x-user-email";
139 CODE_USER_ROLES = "x-user-roles";
140 CODE_USER_GROUPS = "x-user-groups";
141
142 CODE_LISTEN_ADDR = "x-listen-addr";
144 CODE_UUID = "x-uuid";
145 CODE_SHA256 = "x-sha256";
146 CODE_FILE_NAME = "x-file-name";
147 CODE_FILE_GROUP = "x-file-group";
148 CODE_SCHEMA_NAME = "x-schema-name";
149 CODE_IMAGE_FORMAT = "x-image-format";
150 CODE_IMAGE_QUALITY = "x-image-quality";
151}
152
153pub use common::*;
154pub use user::*;
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use pretty_assertions::assert_eq;
160 use std::collections::HashMap;
161
162 #[test]
163 fn env_key_maps_code_to_variable_name() {
164 assert_eq!(env_key("x-user-account"), "x_user_account");
165 assert_eq!(env_key("x-uuid"), "x_uuid");
166 assert_eq!(env_key("X-Image-Quality"), "x_image_quality");
167 }
168
169 #[test]
171 fn all_codes_registry_is_consistent() {
172 let unique: HashSet<&&str> = ALL_CODES.iter().collect();
173 assert_eq!(unique.len(), ALL_CODES.len(), "ALL_CODES 不得有重复项");
174 for code in ALL_CODES {
175 assert!(code.starts_with("x-"), "{code} 应以 x- 开头");
176 assert_eq!(*code, code.to_lowercase(), "{code} 应全小写");
177 }
178 }
179
180 #[test]
182 fn only_star_disables_a_validator() {
183 let env: HashMap<String, String> = [
184 ("x_user_account", "*"),
185 ("x_uuid", "1"), ("x_sha256", ""), ("x_unrelated", "*"), ]
189 .iter()
190 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
191 .collect();
192
193 let disabled = collect_disabled(|key| env.get(key).cloned());
194 assert_eq!(disabled, HashSet::from([CODE_USER_ACCOUNT]));
195 }
196
197 #[test]
198 fn empty_environment_disables_nothing() {
199 assert!(collect_disabled(|_| None).is_empty());
200 }
201
202 #[test]
205 fn every_registered_code_is_switchable() {
206 for code in ALL_CODES {
207 let key = env_key(code);
208 let disabled = collect_disabled(|k| (k == key).then(|| "*".to_string()));
209 assert_eq!(
210 disabled,
211 HashSet::from([*code]),
212 "{code} 的开关未生效(可能未登记进 ALL_CODES)"
213 );
214 }
215 }
216}