Skip to main content

tibba_util/validate/
mod.rs

1// Copyright 2026 Tree xie.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `validator` crate 的自定义校验函数(`x_*`)与错误码常量(`CODE_*`)。
16//!
17//! 每个校验器可通过同名环境变量(`-` 换 `_`、转小写、值为 `*`)临时关闭,
18//! 便于本地开发绕过格式限制,见 [`is_disabled`]。
19//!
20//! 模块名用 `validate` 而非 `validator`,以免与外部 `validator` crate 撞名。
21
22use 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
32/// 校验器码对应的环境变量名:`-` 换 `_` 并转小写。
33/// 例:`x-user-account` → `x_user_account`。
34fn env_key(code: &str) -> String {
35    code.replace('-', "_").to_lowercase()
36}
37
38/// 按给定的环境查询函数,挑出被关闭(值为 `*`)的校验器码。
39///
40/// `lookup` 抽成参数是为了单测能注入假环境——`std::env::set_var` 在多线程
41/// test binary 中与其它线程读环境存在竞态(Rust 2024 已标记为 `unsafe`),
42/// 与 `tibba-config` 的 `build_with_env` same trick。
43fn 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
51/// 首次校验时快照一次「已关闭的校验器」集合。
52///
53/// 此前每次调用都要 `code.replace("-","_").to_lowercase()`(两次堆分配)再
54/// `env::var` 遍历一遍环境,而这是**每请求 × 每个被校验字段**都会走的路径——
55/// 单个登录请求就有 3-4 个字段。
56///
57/// 快照而非每次读取是有意为之:这些开关只用于本地开发,进程启动前设好即可。
58/// Rust 2024 起 `std::env::set_var` 已是 `unsafe`(多线程下与读环境竞态),
59/// 本就不该在运行中改。
60static DISABLED_CODES: LazyLock<HashSet<&'static str>> =
61    LazyLock::new(|| collect_disabled(|key| env::var(key).ok()));
62
63/// 该校验器是否已被环境变量关闭。O(1) 查表,零分配。
64fn 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
72/// 标识符允许的字符集:ASCII 字母、数字、`_`、`-`、`.`。
73///
74/// 账号、角色、用户组、文件分组、模型名共用这一套白名单。它们都会进日志、
75/// 进权限判定、进各类键名拼接,任何带分隔符语义的字符(`:`、`/`、空白、
76/// 控制符)留在里面都只是风险敞口,没有任何正当用途。
77pub(super) fn is_identifier_char(c: char) -> bool {
78    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
79}
80
81/// 是否含 ASCII 控制字符或空格。
82///
83/// 这是最低限度必须挡掉的一类:`\n` 能伪造日志行,`\r` 可用于头注入,
84/// `\0` 会截断下游 C 实现的字符串。
85pub(super) fn has_control_or_space(value: &str) -> bool {
86    value.chars().any(|c| c.is_ascii_control() || c == ' ')
87}
88
89/// 校验一个 ASCII 标识符:非空、长度上限、且只含 [`is_identifier_char`] 允许的字符。
90///
91/// 此前只查「非空 + ASCII + 长度」,于是控制字符、空格、`/`、`..` 全部放行——
92/// 而这些值会被用作存储分组、模型注册名,是拼进键名与日志的。
93fn 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
120/// 声明校验器错误码常量,并同步生成 [`ALL_CODES`] 注册表。
121///
122/// 用宏而不是「一堆常量 + 手写一份列表」:漏登记不会有任何编译错误,只会让该
123/// 校验器的环境变量开关静默失效——这种 bug 极难被发现。宏让两者不可能脱节。
124macro_rules! validate_codes {
125    ($($(#[$attr:meta])* $name:ident = $value:literal;)+) => {
126        $($(#[$attr])* pub const $name: &str = $value;)+
127
128        /// 全部校验器码,由 `validate_codes!` 统一生成,不会漏项。
129        const ALL_CODES: &[&str] = &[$($name),+];
130    };
131}
132
133validate_codes! {
134    // user validate
135    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    // common validate
143    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    /// 注册表由宏生成,这里守住两个不变量:无重复、命名统一。
170    #[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    /// 只有值恰为 `*` 才算关闭;其它值(含空串)一律视为开启。
181    #[test]
182    fn only_star_disables_a_validator() {
183        let env: HashMap<String, String> = [
184            ("x_user_account", "*"),
185            ("x_uuid", "1"),      // 非 `*`,不算关闭
186            ("x_sha256", ""),     // 空串,不算关闭
187            ("x_unrelated", "*"), // 不在注册表里,忽略
188        ]
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    /// 注册表里的每一项都必须能被自己的环境变量关闭。
203    /// 若将来有人绕过宏手写常量而漏登记,本例会失败。
204    #[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}