Skip to main content

evorule_cli/
io_util.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! I/O 工具:规则加载 + payload 解析 + 文件读写
5//!
6//! # P0-2 修复:确定性加载
7//! `load_rules` 必须按文件名排序后加载,保证不同平台/文件系统返回顺序一致。
8//! 原实现依赖 `fs::read_dir` 的返回顺序(Windows NTFS 字典序、Linux ext4 hash 序),
9//! 导致同目录规则在不同平台执行结果不同。
10//!
11//! # 复用 evorule-reactor 序列化
12//! `serde_json::Value → evorule_tcb::JsonValue` 转换复用 `evorule_reactor::serde_to_tcb`,
13//! 保证与 tier1 reactor WAL、tier2 auditor 的序列化路径一致。
14
15use std::fs;
16use std::path::Path;
17
18use evorule_tcb::JsonValue;
19use serde_json::Value as SerdeValue;
20
21use crate::error::CliError;
22
23/// 加载规则目录,合并 transform 列表
24///
25/// # 确定性加载(P0-2 修复)
26/// 按 `file_name()` 字典序排序后加载,消除 `fs::read_dir` 顺序差异。
27///
28/// # 支持的文件格式
29/// 每个 `.json` 文件可以是以下三种格式之一:
30/// 1. `{"transform": [...]}` 或 `{"transforms": [...]}` — 提取数组
31/// 2. `[{...}, {...}]` — 顶层数组,每项是一条 transform
32/// 3. `{...}` — 单条 transform 对象
33///
34/// # 保留数据文件排除
35/// 文件名恰好为 `payload.json`(大小写不敏感)的文件被当作**初始输入数据**而非规则,
36/// 不参与加载。若用户在规则目录内放置初始 payload,其 `{}` 通常无 `type` 字段,
37/// 会被误当成规则并触发 `missing field: type`(见 `parse_initial_payload` 与教程约定)。
38///
39/// # 错误
40/// - `RulesDirNotFound`:目录不存在
41/// - `NoRulesFound`:目录中无 `.json` 文件
42/// - `Io`:读取文件失败
43/// - `Json`:JSON 解析失败
44pub fn load_rules(rules_dir: &Path) -> Result<Vec<JsonValue>, CliError> {
45    if !rules_dir.exists() {
46        return Err(CliError::RulesDirNotFound(rules_dir.display().to_string()));
47    }
48    if !rules_dir.is_dir() {
49        return Err(CliError::RulesDirNotFound(format!(
50            "Not a directory: {}",
51            rules_dir.display()
52        )));
53    }
54
55    // 收集 .json 文件并按文件名排序(P0-2 修复)
56    // 排除保留数据文件 `payload.json`(大小写不敏感):它通常无 `type` 字段,
57    // 若被当规则加载会触发 "missing field: type"(见 parse_initial_payload 与教程约定)。
58    let mut entries: Vec<_> = fs::read_dir(rules_dir)?
59        .filter_map(Result::ok)
60        .filter(|e| {
61            let is_json = e
62                .path()
63                .extension()
64                .and_then(|s| s.to_str())
65                .is_some_and(|ext| ext == "json");
66            let is_payload = e
67                .file_name()
68                .to_str()
69                .is_some_and(|name| name.eq_ignore_ascii_case("payload.json"));
70            is_json && !is_payload
71        })
72        .collect();
73    entries.sort_by_key(|e| e.file_name());
74
75    if entries.is_empty() {
76        return Err(CliError::NoRulesFound(rules_dir.display().to_string()));
77    }
78
79    let mut all_transforms: Vec<SerdeValue> = Vec::new();
80    for entry in &entries {
81        let path = entry.path();
82        let content = fs::read_to_string(&path)?;
83        let json: SerdeValue = serde_json::from_str(&content)
84            .map_err(|e| CliError::Other(format!("Invalid JSON in {}: {}", path.display(), e)))?;
85        all_transforms.extend(extract_transforms(json));
86    }
87
88    tracing::info!(
89        files = entries.len(),
90        transforms = all_transforms.len(),
91        "Rules loaded"
92    );
93
94    // 复用 evorule-reactor 序列化:serde_json::Value → tier0 JsonValue
95    Ok(all_transforms
96        .into_iter()
97        .map(|v| evorule_reactor::serde_to_tcb(&v))
98        .collect())
99}
100
101/// 从单个 JSON 文件提取 transform 列表
102///
103/// 支持三格式:`{transform: [...]}` / `{transforms: [...]}` / 顶层数组 / 单对象
104fn extract_transforms(json: SerdeValue) -> Vec<SerdeValue> {
105    match &json {
106        SerdeValue::Object(map) => {
107            if let Some(SerdeValue::Array(arr)) = map.get("transform") {
108                arr.clone()
109            } else if let Some(SerdeValue::Array(arr)) = map.get("transforms") {
110                arr.clone()
111            } else {
112                vec![json.clone()]
113            }
114        }
115        SerdeValue::Array(arr) => arr.clone(),
116        _ => vec![json.clone()],
117    }
118}
119
120/// 解析初始 payload
121///
122/// 优先级:`--payload` 字符串 > `--payload-file` 文件 > 默认空对象 `{}`
123///
124/// # 错误
125/// - `InvalidPayload`:JSON 解析失败
126pub fn parse_initial_payload(
127    payload_str: Option<&str>,
128    payload_file: Option<&Path>,
129) -> Result<JsonValue, CliError> {
130    let raw: Option<String> = match (payload_str, payload_file) {
131        (Some(s), _) => Some(s.to_string()),
132        (None, Some(path)) => Some(fs::read_to_string(path).map_err(|e| {
133            CliError::Other(format!(
134                "Failed to read payload file {}: {}",
135                path.display(),
136                e
137            ))
138        })?),
139        (None, None) => None,
140    };
141
142    match raw {
143        Some(s) => {
144            let json: SerdeValue =
145                serde_json::from_str(&s).map_err(|e| CliError::InvalidPayload(e.to_string()))?;
146            Ok(evorule_reactor::serde_to_tcb(&json))
147        }
148        None => Ok(JsonValue::empty_object()),
149    }
150}
151
152/// 写输出到文件或 stdout
153///
154/// `output` 为 `None` 时打印到 stdout,为 `Some(path)` 时写入文件。
155pub fn write_output(output: Option<&Path>, content: &str) -> Result<(), CliError> {
156    match output {
157        Some(path) => {
158            fs::write(path, content)?;
159            tracing::info!(path = %path.display(), "Output written");
160        }
161        None => println!("{}", content),
162    }
163    Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168    #![allow(clippy::unwrap_used)]
169    use super::*;
170    use std::fs;
171    use std::io::Write;
172
173    fn make_temp_dir(name: &str) -> std::path::PathBuf {
174        let dir =
175            std::env::temp_dir().join(format!("evorule-cli-test-{}-{}", name, std::process::id()));
176        let _ = fs::remove_dir_all(&dir);
177        fs::create_dir_all(&dir).unwrap();
178        dir
179    }
180
181    fn cleanup(dir: &std::path::Path) {
182        let _ = fs::remove_dir_all(dir);
183    }
184
185    #[test]
186    fn test_load_rules_deterministic_order() {
187        // 创建 3 个文件,文件名顺序与写入顺序故意不一致
188        let dir = make_temp_dir("deterministic");
189        let write_rule = |name: &str, t: &str| {
190            let mut f = fs::File::create(dir.join(name)).unwrap();
191            f.write_all(t.as_bytes()).unwrap();
192        };
193        write_rule(
194            "03-third.json",
195            r#"{"transform":[{"type":"set","params":{}}]}"#,
196        );
197        write_rule("01-first.json", r#"{"transform":[{"type":"noop"}]}"#);
198        write_rule(
199            "02-second.json",
200            r#"{"transform":[{"type":"set","params":{}}]}"#,
201        );
202
203        let rules1 = load_rules(&dir).unwrap();
204        let rules2 = load_rules(&dir).unwrap();
205
206        assert_eq!(rules1.len(), 3, "should load 3 transforms from 3 files");
207        assert_eq!(
208            rules1, rules2,
209            "load_rules must be deterministic across calls"
210        );
211
212        // 验证顺序:01-first 的 noop 应该在第一位
213        let first = &rules1[0];
214        let type_str = first.get("type").and_then(|v| v.as_str()).unwrap();
215        assert_eq!(type_str, "noop");
216
217        cleanup(&dir);
218    }
219
220    #[test]
221    fn test_load_rules_three_formats() {
222        let dir = make_temp_dir("formats");
223        // 格式1: {transform: [...]}
224        fs::write(dir.join("a.json"), r#"{"transform":[{"type":"noop"}]}"#).unwrap();
225        // 格式2: {transforms: [...]}
226        fs::write(dir.join("b.json"), r#"{"transforms":[{"type":"noop"}]}"#).unwrap();
227        // 格式3: 顶层数组
228        fs::write(dir.join("c.json"), r#"[{"type":"noop"}]"#).unwrap();
229        // 格式4: 单对象
230        fs::write(dir.join("d.json"), r#"{"type":"noop"}"#).unwrap();
231
232        let rules = load_rules(&dir).unwrap();
233        assert_eq!(rules.len(), 4, "should load 4 transforms from 4 formats");
234
235        cleanup(&dir);
236    }
237
238    #[test]
239    fn test_load_rules_ignores_payload_json() {
240        let dir = make_temp_dir("payload-skip");
241        // 规则文件
242        fs::write(
243            dir.join("01-capture.json"),
244            r#"{"transform":[{"type":"noop"}]}"#,
245        )
246        .unwrap();
247        // 保留数据文件:无 type 字段的初始 payload,不应被当作规则加载
248        fs::write(dir.join("payload.json"), r#"{"request_id":"REQ-001"}"#).unwrap();
249
250        let rules = load_rules(&dir).unwrap();
251        assert_eq!(
252            rules.len(),
253            1,
254            "payload.json must not be loaded as a rule, got {} transforms",
255            rules.len()
256        );
257        assert_eq!(
258            rules[0].get("type").and_then(|v| v.as_str()),
259            Some("noop"),
260            "only the real rule should be loaded"
261        );
262
263        cleanup(&dir);
264    }
265
266    #[test]
267    fn test_load_rules_dir_not_found() {
268        let result = load_rules(std::path::Path::new("/nonexistent/path/xyz"));
269        assert!(matches!(result, Err(CliError::RulesDirNotFound(_))));
270    }
271
272    #[test]
273    fn test_load_rules_no_json() {
274        let dir = make_temp_dir("empty");
275        fs::write(dir.join("readme.txt"), "not a rule").unwrap();
276        let result = load_rules(&dir);
277        assert!(matches!(result, Err(CliError::NoRulesFound(_))));
278        cleanup(&dir);
279    }
280
281    #[test]
282    fn test_parse_initial_payload_default() {
283        let payload = parse_initial_payload(None, None).unwrap();
284        assert_eq!(payload, JsonValue::empty_object());
285    }
286
287    #[test]
288    fn test_parse_initial_payload_from_string() {
289        let payload = parse_initial_payload(Some(r#"{"x": 42}"#), None).unwrap();
290        assert_eq!(payload.get("x").and_then(|v| v.as_i64()), Some(42));
291    }
292
293    #[test]
294    fn test_parse_initial_payload_invalid() {
295        let result = parse_initial_payload(Some("not json"), None);
296        assert!(matches!(result, Err(CliError::InvalidPayload(_))));
297    }
298}