@antprofuse/saddle-skill 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +21 -17
- package/agents/openai.yaml +3 -3
- package/assets/v1-business/Cargo.lock +1932 -0
- package/assets/v1-business/Cargo.toml +12 -0
- package/assets/v1-business/schema.sql +18 -0
- package/assets/v1-business/src/main.rs +42 -0
- package/assets/v1-business/src/order.rs +118 -0
- package/assets/v1-business/src/user.rs +54 -0
- package/package.json +5 -3
- package/references/db-0.1.1.md +96 -0
- package/references/gates-0.1.1.md +77 -0
- package/references/observability-0.1.1.md +69 -0
- package/references/overview-0.1.1.md +73 -0
- package/references/runtime-0.1.1.md +54 -0
- package/references/service-0.1.1.md +73 -112
- package/references/validation-0.1.1.md +50 -0
- package/scripts/check-business-boundaries.sh +5 -0
- package/scripts/check_business_boundaries.py +356 -0
|
@@ -1,163 +1,124 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Service 契约
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 目录
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- [定义和实现](#定义和实现)
|
|
6
|
+
- [描述和注册](#描述和注册)
|
|
7
|
+
- [内部 Service 调用](#内部-service-调用)
|
|
8
|
+
- [稳定错误](#稳定错误)
|
|
9
|
+
- [错误模式](#错误模式)
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
[package]
|
|
9
|
-
edition = "2024"
|
|
10
|
-
rust-version = "1.85"
|
|
11
|
+
## 定义和实现
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
saddle-framework = "=0.1.1"
|
|
14
|
-
serde = { version = "1", features = ["derive"] }
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Cargo 包名是 `saddle-framework`,Rust 库名是 `saddle`。
|
|
18
|
-
|
|
19
|
-
## 正确的最小模式
|
|
20
|
-
|
|
21
|
-
定义一个零大小的契约类型、可序列化的请求/响应类型,以及独立的处理器:
|
|
13
|
+
为每个业务操作定义零大小契约类型、请求、响应和独立 Handler:
|
|
22
14
|
|
|
23
15
|
```rust
|
|
24
16
|
use saddle::{
|
|
25
17
|
CallContext,
|
|
26
18
|
service::{Service, ServiceFuture, ServiceHandler},
|
|
27
19
|
};
|
|
28
|
-
use serde::{Deserialize, Serialize};
|
|
29
20
|
|
|
30
|
-
struct
|
|
21
|
+
struct GetUser;
|
|
31
22
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
impl Service for GetUser {
|
|
24
|
+
type Request = GetUserRequest;
|
|
25
|
+
type Response = GetUserResponse;
|
|
35
26
|
}
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
struct HelloResponse {
|
|
39
|
-
message: String,
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
impl Service for Hello {
|
|
43
|
-
type Request = HelloRequest;
|
|
44
|
-
type Response = HelloResponse;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
struct HelloHandler;
|
|
48
|
-
|
|
49
|
-
impl ServiceHandler<Hello> for HelloHandler {
|
|
28
|
+
impl ServiceHandler<GetUser> for GetUserHandler {
|
|
50
29
|
fn call<'a>(
|
|
51
30
|
&'a self,
|
|
52
|
-
|
|
53
|
-
request:
|
|
54
|
-
) -> ServiceFuture<'a,
|
|
31
|
+
context: &'a CallContext,
|
|
32
|
+
request: GetUserRequest,
|
|
33
|
+
) -> ServiceFuture<'a, GetUserResponse> {
|
|
55
34
|
Box::pin(async move {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
})
|
|
35
|
+
// 只表达业务规则,并通过注入的 Saddle 能力执行调用和 I/O。
|
|
36
|
+
Ok(GetUserResponse { exists: true })
|
|
59
37
|
})
|
|
60
38
|
}
|
|
61
39
|
}
|
|
62
40
|
```
|
|
63
41
|
|
|
64
|
-
|
|
42
|
+
请求和响应必须是 `Send + 'static`。通过 JSON 暴露时,请求还必须实现
|
|
43
|
+
`DeserializeOwned`,响应必须实现 `Serialize`。
|
|
65
44
|
|
|
66
|
-
|
|
67
|
-
builder.register::<Hello, _>(
|
|
68
|
-
ServiceDescriptor::new("helloworld", "hello", "say_hello"),
|
|
69
|
-
HelloHandler,
|
|
70
|
-
)?;
|
|
71
|
-
builder.expose_json::<Hello>("/hello");
|
|
72
|
-
```
|
|
45
|
+
## 描述和注册
|
|
73
46
|
|
|
74
|
-
|
|
47
|
+
`ServiceDescriptor::new(module, service, operation)` 的三个标识必须稳定、低基数:
|
|
75
48
|
|
|
76
49
|
```rust
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
// Register and expose Services here.
|
|
83
|
-
Ok(())
|
|
84
|
-
})
|
|
50
|
+
builder.register::<GetUser, _>(
|
|
51
|
+
ServiceDescriptor::new("user", "user", "get"),
|
|
52
|
+
GetUserHandler::new(database.clone()),
|
|
53
|
+
)?;
|
|
54
|
+
builder.expose_json::<GetUser>("/users/get");
|
|
85
55
|
```
|
|
86
56
|
|
|
87
|
-
|
|
57
|
+
- `module` 表示业务模块。
|
|
58
|
+
- `service` 表示稳定的业务服务。
|
|
59
|
+
- `operation` 表示具体操作。
|
|
60
|
+
- 只对确实需要外部访问的 Service 调用 `expose_json`。
|
|
61
|
+
- 由 Saddle 检查重复注册、未注册入口和依赖闭合,不要自建注册表。
|
|
88
62
|
|
|
89
|
-
##
|
|
90
|
-
|
|
91
|
-
不要使用未固定版本或兼容版本范围:
|
|
92
|
-
|
|
93
|
-
```toml
|
|
94
|
-
# 错误:0.1.x 可能发生不兼容变化。
|
|
95
|
-
saddle-framework = "0.1"
|
|
96
|
-
```
|
|
63
|
+
## 内部 Service 调用
|
|
97
64
|
|
|
98
|
-
|
|
65
|
+
调用方依赖 `ServiceClient<目标契约>`,不得依赖目标 Handler:
|
|
99
66
|
|
|
100
67
|
```rust
|
|
101
|
-
|
|
102
|
-
|
|
68
|
+
struct CreateOrderHandler {
|
|
69
|
+
users: ServiceClient<GetUser>,
|
|
70
|
+
}
|
|
103
71
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
.
|
|
72
|
+
let user = self
|
|
73
|
+
.users
|
|
74
|
+
.call(context, GetUserRequest { user_id })
|
|
75
|
+
.await?;
|
|
107
76
|
```
|
|
108
77
|
|
|
109
|
-
|
|
78
|
+
在 `register_with` 工厂中解析依赖,使缺失依赖在装配阶段失败:
|
|
110
79
|
|
|
111
80
|
```rust
|
|
112
|
-
|
|
113
|
-
|
|
81
|
+
builder.register_with::<CreateOrder, CreateOrderHandler, _>(
|
|
82
|
+
ServiceDescriptor::new("order", "order", "create"),
|
|
83
|
+
move |services| {
|
|
84
|
+
Ok(CreateOrderHandler::new(
|
|
85
|
+
database,
|
|
86
|
+
services.client::<GetUser>()?,
|
|
87
|
+
))
|
|
88
|
+
},
|
|
89
|
+
)?;
|
|
114
90
|
```
|
|
115
91
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
```rust
|
|
119
|
-
// 错误:业务代码不能拥有运行时或 HTTP 服务器。
|
|
120
|
-
#[tokio::main]
|
|
121
|
-
async fn main() {}
|
|
92
|
+
不要直接构造 `ServiceClient`,也不要直接调用另一个模块的 Handler。
|
|
122
93
|
|
|
123
|
-
|
|
124
|
-
axum::Router::new();
|
|
125
|
-
```
|
|
94
|
+
## 稳定错误
|
|
126
95
|
|
|
127
|
-
|
|
96
|
+
业务预期失败使用 `SaddleError`,错误码使用稳定的大写标识:
|
|
128
97
|
|
|
129
98
|
```rust
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
async fn main() { ... }
|
|
99
|
+
return Err(SaddleError::new(
|
|
100
|
+
ErrorKind::InvalidArgument,
|
|
101
|
+
"ORDER_AMOUNT_INVALID",
|
|
102
|
+
"order amount must be greater than zero",
|
|
103
|
+
));
|
|
136
104
|
```
|
|
137
105
|
|
|
138
|
-
|
|
106
|
+
可用 `ErrorKind`:`InvalidArgument`、`NotFound`、`Conflict`、`Business`、
|
|
107
|
+
`Unavailable`、`Infrastructure`、`Internal`。该枚举是非穷尽的;业务匹配时保留
|
|
108
|
+
通配分支。错误消息不得包含凭据、SQL 参数、完整 payload 或内部敏感细节。
|
|
139
109
|
|
|
140
|
-
|
|
141
|
-
// 错误:装配和托管 I/O 的创建属于 Saddle。
|
|
142
|
-
let registry = saddle::service::ServiceRegistryBuilder::new();
|
|
143
|
-
let database = saddle::db::Database::connect(...);
|
|
144
|
-
```
|
|
110
|
+
## 错误模式
|
|
145
111
|
|
|
146
|
-
|
|
112
|
+
```rust
|
|
113
|
+
// 错误:绕过 Saddle 的内部调用和上下文传播。
|
|
114
|
+
let response = GetUserHandler::new(database).call(context, request).await?;
|
|
147
115
|
|
|
148
|
-
|
|
116
|
+
// 错误:0.1.1 没有这些宏。
|
|
117
|
+
#[saddle::service]
|
|
118
|
+
async fn create_order(...) { ... }
|
|
149
119
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
cargo check --locked --manifest-path examples/helloworld/Cargo.toml
|
|
153
|
-
cargo tree --locked --manifest-path examples/helloworld/Cargo.toml \
|
|
154
|
-
-p saddle-framework@0.1.1
|
|
120
|
+
// 错误:Service trait 位于 saddle::service。
|
|
121
|
+
use saddle::{Service, ServiceHandler};
|
|
155
122
|
```
|
|
156
123
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
- Rust 1.85 和当前工具链检查均可针对 crates.io 的 `saddle-framework 0.1.1` 制品编译。
|
|
160
|
-
- 依赖树将所有 Saddle 组件解析为 0.1.1。
|
|
161
|
-
- 锁文件使用 Cargo 1.85 的不兼容 Rust 版本回退机制解析,因此所有选中包都支持声明的最低 Rust 版本。
|
|
162
|
-
|
|
163
|
-
该示例不能证明运行时已就绪,因为 `SADDLE_DATABASE_URL` 必须指向可访问的数据库,且配置的监听地址必须可用。
|
|
124
|
+
完整组合方式见 `assets/v1-business/src/user.rs`、`order.rs` 和 `main.rs`。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 验证
|
|
2
|
+
|
|
3
|
+
## 编译
|
|
4
|
+
|
|
5
|
+
在 Saddle 仓库执行:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cargo +1.85.0 fmt --manifest-path examples/v1-business/Cargo.toml -- --check
|
|
9
|
+
cargo +1.85.0 check --locked --manifest-path examples/v1-business/Cargo.toml
|
|
10
|
+
cargo check --locked --manifest-path examples/v1-business/Cargo.toml
|
|
11
|
+
cargo tree --locked --manifest-path examples/v1-business/Cargo.toml \
|
|
12
|
+
-p saddle-framework@0.1.1
|
|
13
|
+
gates/check-business-boundaries.sh examples/v1-business/Cargo.toml
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
对安装后的 Skill,将路径替换为 `assets/v1-business/Cargo.toml`。
|
|
17
|
+
|
|
18
|
+
## 依赖边界
|
|
19
|
+
|
|
20
|
+
确认业务清单只直接声明精确版本 Saddle 门面和批准的序列化/纯计算依赖。依赖树中
|
|
21
|
+
出现 Tokio、Axum 或 sqlx 作为 Saddle 的传递依赖是正常的;业务 `Cargo.toml`
|
|
22
|
+
直接声明或业务源码直接使用它们才是旁路。
|
|
23
|
+
|
|
24
|
+
## 当前事实
|
|
25
|
+
|
|
26
|
+
- Rust 1.85 和当前工具链可编译 `assets/v1-business`。
|
|
27
|
+
- 所有 Saddle 组件解析为 0.1.1。
|
|
28
|
+
- 示例覆盖 Service 注册与 HTTP/JSON 暴露、内部 `ServiceClient`、托管 DB、
|
|
29
|
+
单层事务、稳定错误和领域事件。
|
|
30
|
+
- 示例已在 MariaDB 10.11 上完成真实 HTTP 验证:正常订单同时提交 `orders` 和
|
|
31
|
+
`order_audit`;让第二条写入失败后返回 `db.query_failed`,第一条写入被回滚。
|
|
32
|
+
- 示例实测 `InvalidArgument` 映射为 HTTP 400、`Business` 映射为 HTTP 422、
|
|
33
|
+
DB 基础设施错误映射为 HTTP 500;响应头和错误体携带同一个 trace ID。
|
|
34
|
+
- 32 位十六进制 `x-saddle-trace-id` 被继承;无效值由框架替换。日志中的外部入口、
|
|
35
|
+
内部 Service、DB、事务和领域事件共享 trace,并记录 commit 或 rollback。
|
|
36
|
+
|
|
37
|
+
这些 HTTP 和日志结论是 `saddle-framework 0.1.1` 配合本示例的验证基线。升级制品后
|
|
38
|
+
必须重新运行,不能把示例观测外推为未发布版本的承诺。
|
|
39
|
+
|
|
40
|
+
## MariaDB 部署前提
|
|
41
|
+
|
|
42
|
+
0.1.1 会校验数据库入站包上限。MariaDB 的 `max_allowed_packet` 大于框架允许值时,
|
|
43
|
+
示例启动返回 `db.invalid_config`;本次以 8 MiB 配置验证通过。部署前显式确认:
|
|
44
|
+
|
|
45
|
+
```sql
|
|
46
|
+
SELECT @@global.max_allowed_packet;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
不要为了通过启动检查提高该值;生产配置应由部署环境按 0.1.1 的 8 MiB 结果边界
|
|
50
|
+
统一设置。
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Conservative, closed-policy gate for Saddle V1 business crates."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ALLOWED_DEPENDENCIES = {"saddle-framework", "serde"}
|
|
16
|
+
ALLOWED_TOP_LEVEL = {"package", "dependencies", "workspace"}
|
|
17
|
+
ALLOWED_PACKAGE_KEYS = {"name", "version", "edition", "rust-version", "publish"}
|
|
18
|
+
FORBIDDEN_MANIFEST_KEYS = {
|
|
19
|
+
"lib", "bin", "example", "test", "bench", "target",
|
|
20
|
+
"build-dependencies", "dev-dependencies",
|
|
21
|
+
}
|
|
22
|
+
SOURCE_RULES = {
|
|
23
|
+
"THREAD": [r"\bstd\s*::\s*thread\b"],
|
|
24
|
+
"RUNTIME": [
|
|
25
|
+
r"\btokio\s*::\s*runtime\b", r"\basync_std\s*::\s*task\s*::\s*block_on\b",
|
|
26
|
+
r"\bsmol\s*::\s*block_on\b",
|
|
27
|
+
],
|
|
28
|
+
"TASK": [
|
|
29
|
+
r"\btokio\s*::\s*(?:task\s*::\s*)?spawn\b", r"\bspawn_blocking\b",
|
|
30
|
+
r"\basync_std\s*::\s*task\s*::\s*spawn\b", r"\bsmol\s*::\s*spawn\b",
|
|
31
|
+
],
|
|
32
|
+
"WEB": [
|
|
33
|
+
r"\baxum\s*::", r"\bhyper\s*::", r"\bactix_web\s*::", r"\bwarp\s*::",
|
|
34
|
+
r"\bpoem\s*::", r"\brocket\s*::",
|
|
35
|
+
],
|
|
36
|
+
"DB": [
|
|
37
|
+
r"\bsqlx\s*::", r"\bdiesel\s*::", r"\bsea_orm\s*::",
|
|
38
|
+
r"\b(?:MySql|Pg|Sqlite)Pool\b",
|
|
39
|
+
],
|
|
40
|
+
"OBSERVABILITY": [r"\btracing_subscriber\b", r"\blog4rs\b", r"\benv_logger\b"],
|
|
41
|
+
"IO": [
|
|
42
|
+
r"\bstd\s*::\s*fs\b", r"\bstd\s*::\s*process\b",
|
|
43
|
+
r"\bstd\s*::\s*io\s*::\s*(?:stdin|stdout|stderr|Read|Write|BufRead)\b",
|
|
44
|
+
r"\bstd\s*::\s*net\s*::\s*(?:TcpStream|TcpListener|UdpSocket)\b",
|
|
45
|
+
r"\b(?:reqwest|ureq|surf|isahc|curl|tonic|tarpc|lapin|rdkafka|nats|redis)\s*::",
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Gate:
|
|
51
|
+
def __init__(self, manifest: Path) -> None:
|
|
52
|
+
self.manifest = manifest.resolve()
|
|
53
|
+
self.root = self.manifest.parent
|
|
54
|
+
self.failures = 0
|
|
55
|
+
|
|
56
|
+
def report(self, rule: str, detail: str) -> None:
|
|
57
|
+
print(f"SADDLE_GATE {rule} {detail}", file=sys.stderr)
|
|
58
|
+
self.failures += 1
|
|
59
|
+
|
|
60
|
+
def check_manifest(self, data: dict) -> None:
|
|
61
|
+
for key in data.keys() - ALLOWED_TOP_LEVEL:
|
|
62
|
+
rule = "DEPENDENCY" if key in {"patch", "replace"} else "MANIFEST"
|
|
63
|
+
self.report(rule, f"{self.manifest}: 不允许 Cargo 顶层配置 [{key}]")
|
|
64
|
+
for key in FORBIDDEN_MANIFEST_KEYS & data.keys():
|
|
65
|
+
self.report("TARGET", f"{self.manifest}: 不允许 [{key}] 或自定义/附加 Cargo target")
|
|
66
|
+
|
|
67
|
+
package = data.get("package")
|
|
68
|
+
if not isinstance(package, dict):
|
|
69
|
+
self.report("MANIFEST", f"{self.manifest}: 缺少 [package]")
|
|
70
|
+
else:
|
|
71
|
+
for key in package.keys() - ALLOWED_PACKAGE_KEYS:
|
|
72
|
+
rule = "BUILD" if key in {"build", "links"} else "TARGET"
|
|
73
|
+
self.report(rule, f"{self.manifest}: 不允许 package.{key}")
|
|
74
|
+
|
|
75
|
+
workspace = data.get("workspace")
|
|
76
|
+
if workspace not in ({}, None):
|
|
77
|
+
self.report("MANIFEST", f"{self.manifest}: [workspace] 不得包含配置")
|
|
78
|
+
|
|
79
|
+
dependencies = data.get("dependencies")
|
|
80
|
+
if not isinstance(dependencies, dict):
|
|
81
|
+
self.report("DEPENDENCY", f"{self.manifest}: 缺少 [dependencies]")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
for name, spec in dependencies.items():
|
|
85
|
+
if name not in ALLOWED_DEPENDENCIES:
|
|
86
|
+
self.report("DEPENDENCY", f"{self.manifest}: 未批准的直接依赖 {name}")
|
|
87
|
+
continue
|
|
88
|
+
if name == "saddle-framework":
|
|
89
|
+
if isinstance(spec, dict) and spec.get("version") == "=0.1.1":
|
|
90
|
+
self.report("DEPENDENCY", f"{self.manifest}: saddle-framework 禁止 path/git/registry/package 等依赖来源")
|
|
91
|
+
elif spec != "=0.1.1":
|
|
92
|
+
self.report("SADDLE_VERSION", f'{self.manifest}: 必须为 saddle-framework = "=0.1.1"')
|
|
93
|
+
elif name == "serde":
|
|
94
|
+
valid = (
|
|
95
|
+
isinstance(spec, dict)
|
|
96
|
+
and spec.get("version") == "1"
|
|
97
|
+
and set(spec) <= {"version", "features"}
|
|
98
|
+
and spec.get("features") == ["derive"]
|
|
99
|
+
)
|
|
100
|
+
if not valid:
|
|
101
|
+
self.report("DEPENDENCY", f'{self.manifest}: serde 仅允许 version="1", features=["derive"]')
|
|
102
|
+
|
|
103
|
+
if "saddle-framework" not in dependencies:
|
|
104
|
+
self.report("SADDLE_VERSION", f'{self.manifest}: 必须直接依赖 saddle-framework = "=0.1.1"')
|
|
105
|
+
|
|
106
|
+
def check_layout(self) -> list[Path]:
|
|
107
|
+
main = self.root / "src" / "main.rs"
|
|
108
|
+
if not main.is_file():
|
|
109
|
+
self.report("ENTRY", f"{main}: 必须使用默认二进制入口 src/main.rs")
|
|
110
|
+
|
|
111
|
+
forbidden = [self.root / "build.rs", self.root / "src" / "lib.rs"]
|
|
112
|
+
forbidden.extend((self.root / "src" / "bin").glob("*.rs") if (self.root / "src" / "bin").is_dir() else [])
|
|
113
|
+
for path in forbidden:
|
|
114
|
+
if path.exists():
|
|
115
|
+
self.report("TARGET", f"{path}: 不允许额外 target 或 build script")
|
|
116
|
+
|
|
117
|
+
if (self.root / ".cargo").exists():
|
|
118
|
+
self.report("DEPENDENCY", f"{self.root / '.cargo'}: 不允许项目级 Cargo source/config 覆盖")
|
|
119
|
+
|
|
120
|
+
source_dir = self.root / "src"
|
|
121
|
+
return sorted(source_dir.rglob("*.rs")) if source_dir.is_dir() else []
|
|
122
|
+
|
|
123
|
+
def check_sources(self, sources: list[Path]) -> None:
|
|
124
|
+
stripped: dict[Path, str] = {}
|
|
125
|
+
for path in sources:
|
|
126
|
+
text = strip_rust_non_code(path.read_text(encoding="utf-8"))
|
|
127
|
+
stripped[path] = text
|
|
128
|
+
self.check_closed_source(path, text)
|
|
129
|
+
for rule, patterns in SOURCE_RULES.items():
|
|
130
|
+
if any(re.search(pattern, text) for pattern in patterns):
|
|
131
|
+
self.report(rule, f"{path}: 命中禁止能力")
|
|
132
|
+
|
|
133
|
+
main_count = sum(len(re.findall(r"\bfn\s+main\s*\(", text)) for text in stripped.values())
|
|
134
|
+
if main_count != 1:
|
|
135
|
+
self.report("ENTRY", f"{self.root / 'src'}: 必须且只能定义一个 fn main,实际为 {main_count}")
|
|
136
|
+
|
|
137
|
+
main_path = self.root / "src" / "main.rs"
|
|
138
|
+
main_text = stripped.get(main_path, "")
|
|
139
|
+
body = function_body(main_text, "main")
|
|
140
|
+
normalized_main = re.sub(r"\s+", "", main_text)
|
|
141
|
+
if "use::saddle::Saddle;" not in normalized_main:
|
|
142
|
+
self.report("ENTRY", f"{main_path}: 必须以 use ::saddle::Saddle 绑定发布 facade")
|
|
143
|
+
saddle_occurrences = sum(
|
|
144
|
+
len(re.findall(r"\bSaddle\b", source_text))
|
|
145
|
+
for source_text in stripped.values()
|
|
146
|
+
)
|
|
147
|
+
if saddle_occurrences != 2:
|
|
148
|
+
self.report("ENTRY", f"{self.root / 'src'}: Saddle 标识只能用于 facade 导入和唯一启动调用")
|
|
149
|
+
if body is not None and re.search(r"\buse\s+", body):
|
|
150
|
+
self.report("ENTRY", f"{main_path}: fn main/块作用域内禁止 use 与名称遮蔽")
|
|
151
|
+
if body is None or len(re.findall(r"\bSaddle\s*::\s*run\s*\(", body)) != 1:
|
|
152
|
+
self.report("ENTRY", f"{main_path}: fn main 函数体必须调用 Saddle::run")
|
|
153
|
+
elif not call_is_tail_expression(body, "Saddle", "run"):
|
|
154
|
+
self.report("ENTRY", f"{main_path}: Saddle::run 必须是 fn main 的唯一尾部启动调用")
|
|
155
|
+
|
|
156
|
+
def check_closed_source(self, path: Path, text: str) -> None:
|
|
157
|
+
if re.search(r"\bmacro_rules\s*!|\bmacro\s+[A-Za-z_]", text):
|
|
158
|
+
self.report("MACRO", f"{path}: 禁止业务定义声明式或过程宏")
|
|
159
|
+
for macro_name in re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*!\s*[\(\{\[]", text):
|
|
160
|
+
self.report("MACRO", f"{path}: 未批准的宏调用 {macro_name}!")
|
|
161
|
+
|
|
162
|
+
for attribute in re.findall(r"#\s*\[\s*([^\]]+)\]", text, re.S):
|
|
163
|
+
compact = re.sub(r"\s+", "", attribute)
|
|
164
|
+
derive = re.fullmatch(r"derive\(([^)]+)\)", compact)
|
|
165
|
+
names = set(derive.group(1).split(",")) if derive else set()
|
|
166
|
+
if not derive or not names or not names <= {"Deserialize", "Serialize"}:
|
|
167
|
+
self.report("ATTRIBUTE", f"{path}: 仅允许 serde Deserialize/Serialize derive")
|
|
168
|
+
|
|
169
|
+
if re.search(r"\b(?:include|include_str|include_bytes)\s*!", text) or re.search(r"#\s*\[\s*path\s*=", text):
|
|
170
|
+
self.report("SOURCE", f"{path}: 禁止 include!/外部 path 源码注入")
|
|
171
|
+
if re.search(r"\bextern\s+crate\b", text):
|
|
172
|
+
self.report("SOURCE", f"{path}: 禁止 extern crate/别名注入")
|
|
173
|
+
if re.search(r"\bunsafe\b|\bextern\s*\"|\b(?:global_asm|asm)\s*!|#\s*\[\s*(?:link|no_mangle)", text):
|
|
174
|
+
self.report("SOURCE", f"{path}: 禁止 unsafe、FFI、汇编或链接注入")
|
|
175
|
+
|
|
176
|
+
for statement in re.findall(r"\buse\s+[^;]+;", text, re.S):
|
|
177
|
+
compact = re.sub(r"\s+", "", statement)
|
|
178
|
+
if re.search(r"\bas\b", statement):
|
|
179
|
+
self.report("SOURCE", f"{path}: use 不允许 as 别名")
|
|
180
|
+
if compact.startswith("use::std::"):
|
|
181
|
+
if compact != "use::std::net::{IpAddr,Ipv4Addr,SocketAddr};":
|
|
182
|
+
self.report("IO", f"{path}: 未批准的 ::std 导入")
|
|
183
|
+
elif compact.startswith(("use::saddle::", "use::serde::")):
|
|
184
|
+
pass
|
|
185
|
+
elif compact.startswith(("usecrate::", "useself::", "usesuper::")):
|
|
186
|
+
pass
|
|
187
|
+
elif compact.startswith(("usestd::", "usesaddle::", "useserde::")):
|
|
188
|
+
self.report("SOURCE", f"{path}: 外部 crate 必须使用不可遮蔽的绝对路径")
|
|
189
|
+
|
|
190
|
+
allowed_std = re.sub(
|
|
191
|
+
r"use\s*::\s*std\s*::\s*net\s*::\s*\{\s*IpAddr\s*,\s*Ipv4Addr\s*,\s*SocketAddr\s*\}\s*;",
|
|
192
|
+
"",
|
|
193
|
+
text,
|
|
194
|
+
)
|
|
195
|
+
allowed_std = re.sub(r"::\s*std\s*::\s*env\s*::\s*var\b", "", allowed_std)
|
|
196
|
+
if re.search(r"(?<!:)\bstd\s*::|::\s*std\s*::", allowed_std):
|
|
197
|
+
self.report("IO", f"{path}: 仅允许约定的 std 地址类型和部署环境变量读取")
|
|
198
|
+
if re.search(r"(?<!:)\b(?:saddle|serde)\s*::", text):
|
|
199
|
+
self.report("SOURCE", f"{path}: saddle/serde 必须从绝对 external-prelude 路径导入")
|
|
200
|
+
for root in re.findall(r"(?<![:\w])::\s*([A-Za-z_][A-Za-z0-9_]*)\s*::", text):
|
|
201
|
+
if root not in {"saddle", "serde", "std"}:
|
|
202
|
+
self.report("SOURCE", f"{path}: 未批准的绝对 crate 路径 ::{root}")
|
|
203
|
+
|
|
204
|
+
def check_compiler(self) -> None:
|
|
205
|
+
lock = self.root / "Cargo.lock"
|
|
206
|
+
if not lock.is_file():
|
|
207
|
+
self.report("COMPILER", f"{lock}: 缺少锁文件,无法验证真实依赖解析")
|
|
208
|
+
return
|
|
209
|
+
with tempfile.TemporaryDirectory(prefix="saddle-gate-") as target:
|
|
210
|
+
environment = os.environ.copy()
|
|
211
|
+
environment["CARGO_TARGET_DIR"] = target
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
["cargo", "check", "--locked", "--quiet", "--manifest-path", str(self.manifest)],
|
|
214
|
+
env=environment,
|
|
215
|
+
capture_output=True,
|
|
216
|
+
text=True,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
if result.returncode:
|
|
220
|
+
detail = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "cargo check 失败"
|
|
221
|
+
self.report("COMPILER", f"{self.manifest}: {detail}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def strip_rust_non_code(text: str) -> str:
|
|
225
|
+
"""Replace comments and literals with spaces while preserving newlines."""
|
|
226
|
+
out = list(text)
|
|
227
|
+
i = 0
|
|
228
|
+
block_depth = 0
|
|
229
|
+
while i < len(text):
|
|
230
|
+
if block_depth:
|
|
231
|
+
if text.startswith("/*", i):
|
|
232
|
+
out[i:i + 2] = " "
|
|
233
|
+
block_depth += 1
|
|
234
|
+
i += 2
|
|
235
|
+
elif text.startswith("*/", i):
|
|
236
|
+
out[i:i + 2] = " "
|
|
237
|
+
block_depth -= 1
|
|
238
|
+
i += 2
|
|
239
|
+
else:
|
|
240
|
+
if text[i] != "\n":
|
|
241
|
+
out[i] = " "
|
|
242
|
+
i += 1
|
|
243
|
+
continue
|
|
244
|
+
if text.startswith("//", i):
|
|
245
|
+
end = text.find("\n", i)
|
|
246
|
+
end = len(text) if end < 0 else end
|
|
247
|
+
out[i:end] = " " * (end - i)
|
|
248
|
+
i = end
|
|
249
|
+
continue
|
|
250
|
+
if text.startswith("/*", i):
|
|
251
|
+
out[i:i + 2] = " "
|
|
252
|
+
block_depth = 1
|
|
253
|
+
i += 2
|
|
254
|
+
continue
|
|
255
|
+
raw = re.match(r"(?:b)?r(#{0,255})\"", text[i:])
|
|
256
|
+
if raw:
|
|
257
|
+
marker = '"' + raw.group(1)
|
|
258
|
+
start = i
|
|
259
|
+
i += raw.end()
|
|
260
|
+
end = text.find(marker, i)
|
|
261
|
+
i = len(text) if end < 0 else end + len(marker)
|
|
262
|
+
for j in range(start, i):
|
|
263
|
+
if text[j] != "\n":
|
|
264
|
+
out[j] = " "
|
|
265
|
+
continue
|
|
266
|
+
char = re.match(r"(?:b)?'(?:\\(?:u\{[0-9A-Fa-f_]+\}|.)|[^\\'\n])'", text[i:])
|
|
267
|
+
if char:
|
|
268
|
+
end = i + char.end()
|
|
269
|
+
out[i:end] = " " * (end - i)
|
|
270
|
+
i = end
|
|
271
|
+
continue
|
|
272
|
+
prefix = 1 if text.startswith('b"', i) else 0
|
|
273
|
+
quote_at = i + prefix
|
|
274
|
+
if quote_at < len(text) and text[quote_at] == '"':
|
|
275
|
+
quote = '"'
|
|
276
|
+
start = i
|
|
277
|
+
i = quote_at + 1
|
|
278
|
+
while i < len(text):
|
|
279
|
+
if text[i] == "\\":
|
|
280
|
+
i += 2
|
|
281
|
+
elif text[i] == quote:
|
|
282
|
+
i += 1
|
|
283
|
+
break
|
|
284
|
+
else:
|
|
285
|
+
i += 1
|
|
286
|
+
for j in range(start, min(i, len(text))):
|
|
287
|
+
if text[j] != "\n":
|
|
288
|
+
out[j] = " "
|
|
289
|
+
continue
|
|
290
|
+
i += 1
|
|
291
|
+
return "".join(out)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def function_body(text: str, name: str) -> str | None:
|
|
295
|
+
match = re.search(rf"\bfn\s+{re.escape(name)}\s*\([^)]*\)[^{{;]*\{{", text, re.S)
|
|
296
|
+
if not match:
|
|
297
|
+
return None
|
|
298
|
+
start = match.end() - 1
|
|
299
|
+
depth = 0
|
|
300
|
+
for index in range(start, len(text)):
|
|
301
|
+
if text[index] == "{":
|
|
302
|
+
depth += 1
|
|
303
|
+
elif text[index] == "}":
|
|
304
|
+
depth -= 1
|
|
305
|
+
if depth == 0:
|
|
306
|
+
return text[start + 1:index]
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def call_is_tail_expression(body: str, type_name: str, method: str) -> bool:
|
|
311
|
+
match = re.search(
|
|
312
|
+
rf"\b{re.escape(type_name)}\s*::\s*{re.escape(method)}\s*\(",
|
|
313
|
+
body,
|
|
314
|
+
)
|
|
315
|
+
if not match:
|
|
316
|
+
return False
|
|
317
|
+
opening = body.find("(", match.start())
|
|
318
|
+
depth = 0
|
|
319
|
+
for index in range(opening, len(body)):
|
|
320
|
+
if body[index] == "(":
|
|
321
|
+
depth += 1
|
|
322
|
+
elif body[index] == ")":
|
|
323
|
+
depth -= 1
|
|
324
|
+
if depth == 0:
|
|
325
|
+
return not body[index + 1:].strip()
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main() -> int:
|
|
330
|
+
if len(sys.argv) != 2:
|
|
331
|
+
print(f"用法: {sys.argv[0]} <业务 Cargo.toml>", file=sys.stderr)
|
|
332
|
+
return 2
|
|
333
|
+
manifest = Path(sys.argv[1])
|
|
334
|
+
if not manifest.is_file():
|
|
335
|
+
print(f"SADDLE_GATE MANIFEST {manifest}: 文件不存在", file=sys.stderr)
|
|
336
|
+
return 2
|
|
337
|
+
try:
|
|
338
|
+
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
|
|
339
|
+
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
|
|
340
|
+
print(f"SADDLE_GATE MANIFEST {manifest}: {error}", file=sys.stderr)
|
|
341
|
+
return 2
|
|
342
|
+
|
|
343
|
+
gate = Gate(manifest)
|
|
344
|
+
gate.check_manifest(data)
|
|
345
|
+
gate.check_sources(gate.check_layout())
|
|
346
|
+
if not gate.failures:
|
|
347
|
+
gate.check_compiler()
|
|
348
|
+
if gate.failures:
|
|
349
|
+
print(f"SADDLE_GATE rejected: {gate.failures} violation(s)", file=sys.stderr)
|
|
350
|
+
return 1
|
|
351
|
+
print(f"SADDLE_GATE passed: {gate.manifest}")
|
|
352
|
+
return 0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
if __name__ == "__main__":
|
|
356
|
+
raise SystemExit(main())
|