refactor(agent): extract OpenAI provider into agent/llm/provider/ module with openai subdir and tests
zhenyi committed
Added agent/llm/provider/openai/config.rs agent/llm/provider/openai/config.rs +202 −0 View agent/llm/provider/openai/config.rs
@@ -0,0 +1,202 @@
1use crate::error::LlmError;
2use crate::provider::LlmProvider;
3use crate::types::{ChatRequest, ChatResponse, ProviderCapabilities, StreamEvent};
4use futures::StreamExt;
5use std::fmt;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio_stream::Stream;
10
11use super::error::map_response_err;
12use super::request::build_request_body;
13use super::response::parse_response;
14use super::stream::sse_to_stream_events;
15use eventsource_stream::Eventsource;
16
17type EventStream = Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>;
18
19#[derive(Clone)]
20pub struct SecretString(String);
21
22impl SecretString {
23 pub fn new(value: impl Into<String>) -> Self {
24 Self(value.into())
25 }
26
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30}
31
32impl fmt::Debug for SecretString {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 f.write_str("SecretString(***)")
35 }
36}
37
38#[derive(Debug, Clone)]
39pub struct OpenAIConfig {
40 pub api_key: SecretString,
41 pub model: String,
42 pub organization: Option<String>,
43 pub project: Option<String>,
44 pub max_context_tokens: usize,
45 pub max_output_tokens: usize,
46 pub request_timeout: Duration,
47 pub base_url: String,
48}
49
50impl OpenAIConfig {
51 pub fn new(api_key: impl Into<String>) -> Self {
52 Self {
53 api_key: SecretString::new(api_key),
54 model: "gpt-4o-mini".to_string(),
55 organization: None,
56 project: None,
57 max_context_tokens: 128_000,
58 max_output_tokens: 16_384,
59 request_timeout: Duration::from_secs(60),
60 base_url: "https://api.openai.com".to_string(),
61 }
62 }
63
64 pub fn with_model(mut self, model: impl Into<String>) -> Self {
65 self.model = model.into();
66 self
67 }
68
69 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
70 self.base_url = base_url.into();
71 self
72 }
73
74 pub fn with_organization(mut self, organization: impl Into<String>) -> Self {
75 self.organization = Some(organization.into());
76 self
77 }
78
79 pub fn with_project(mut self, project: impl Into<String>) -> Self {
80 self.project = Some(project.into());
81 self
82 }
83
84 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
85 self.request_timeout = timeout;
86 self
87 }
88
89 pub fn with_max_context_tokens(mut self, n: usize) -> Self {
90 self.max_context_tokens = n;
91 self
92 }
93
94 pub fn with_max_output_tokens(mut self, n: usize) -> Self {
95 self.max_output_tokens = n;
96 self
97 }
98}
99
100#[derive(Clone)]
101pub struct OpenAIProvider {
102 client: reqwest::Client,
103 config: Arc<OpenAIConfig>,
104}
105
106impl OpenAIProvider {
107 pub fn new(config: OpenAIConfig) -> Result<Self, LlmError> {
108 let client = reqwest::Client::builder()
109 .timeout(config.request_timeout)
110 .build()
111 .map_err(|e| LlmError::Config(format!("reqwest client build failed: {e}")))?;
112 Ok(Self {
113 client,
114 config: Arc::new(config),
115 })
116 }
117
118 fn chat_url(&self) -> String {
119 format!("{}/v1/responses", self.config.base_url)
120 }
121}
122
123#[async_trait::async_trait]
124impl LlmProvider for OpenAIProvider {
125 fn provider_id(&self) -> &str {
126 "openai"
127 }
128
129 fn model_id(&self) -> &str {
130 &self.config.model
131 }
132
133 fn capabilities(&self) -> ProviderCapabilities {
134 ProviderCapabilities {
135 streaming: true,
136 tool_calling: true,
137 structured_output: true,
138 vision: true,
139 reasoning: true,
140 embeddings: false,
141 max_context_tokens: self.config.max_context_tokens,
142 max_output_tokens: self.config.max_output_tokens,
143 }
144 }
145
146 async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError> {
147 let body = build_request_body(&self.config.model, &request, false)?;
148 let mut req = self
149 .client
150 .post(self.chat_url())
151 .bearer_auth(self.config.api_key.as_str())
152 .json(&body);
153 if let Some(org) = &self.config.organization {
154 req = req.header("OpenAI-Organization", org);
155 }
156 if let Some(proj) = &self.config.project {
157 req = req.header("OpenAI-Project", proj);
158 }
159 let resp = req.send().await.map_err(|e| map_response_err(e, "openai"))?;
160 let status = resp.status();
161 let bytes = resp.bytes().await.map_err(|e| {
162 LlmError::Network(format!("openai response body read failed: {e}"))
163 })?;
164 if !status.is_success() {
165 return Err(super::error::classify_status(
166 "openai",
167 status.as_u16(),
168 &bytes,
169 ));
170 }
171 parse_response(&bytes)
172 }
173
174 async fn chat_stream(&self, request: ChatRequest) -> Result<EventStream, LlmError> {
175 let body = build_request_body(&self.config.model, &request, true)?;
176 let mut req = self
177 .client
178 .post(self.chat_url())
179 .bearer_auth(self.config.api_key.as_str())
180 .json(&body);
181 if let Some(org) = &self.config.organization {
182 req = req.header("OpenAI-Organization", org);
183 }
184 if let Some(proj) = &self.config.project {
185 req = req.header("OpenAI-Project", proj);
186 }
187 let resp = req.send().await.map_err(|e| map_response_err(e, "openai"))?;
188 let status = resp.status();
189 if !status.is_success() {
190 let bytes = resp.bytes().await.map_err(|e| {
191 LlmError::Network(format!("openai stream body read failed: {e}"))
192 })?;
193 return Err(super::error::classify_status(
194 "openai",
195 status.as_u16(),
196 &bytes,
197 ));
198 }
199 let sse = resp.bytes_stream().eventsource();
200 Ok(Box::pin(sse_to_stream_events(sse).boxed()))
201 }
202}
No newline at end of file
Added agent/llm/provider/openai/error.rs agent/llm/provider/openai/error.rs +245 −0 View agent/llm/provider/openai/error.rs
@@ -0,0 +1,245 @@
1use crate::error::LlmError;
2
3pub fn map_response_err(err: reqwest::Error, provider: &'static str) -> LlmError {
4 if err.is_timeout() {
5 return LlmError::Network(format!("{provider} request timed out: {err}"));
6 }
7 if err.is_connect() {
8 return LlmError::Network(format!("{provider} connect failed: {err}"));
9 }
10 if err.is_request() {
11 return LlmError::Network(format!("{provider} request error: {err}"));
12 }
13 LlmError::Network(format!("{provider} transport error: {err}"))
14}
15
16pub fn classify_status(provider: &'static str, status: u16, body: &[u8]) -> LlmError {
17 if let Some(env) = parse_error_envelope(body) {
18 return classify_from_envelope(provider, status, &env);
19 }
20
21 match status {
22 401 | 403 => LlmError::AuthError {
23 provider: provider.into(),
24 },
25 404 => LlmError::ApiError {
26 provider: provider.into(),
27 status,
28 message: String::from_utf8_lossy(body).into_owned(),
29 },
30 429 => LlmError::RateLimited {
31 provider: provider.into(),
32 retry_after_secs: None,
33 },
34 400 if body_mentions(body, "context_length") => LlmError::ContextLengthExceeded {
35 tokens_used: 0,
36 limit: 0,
37 },
38 s if (500..600).contains(&s) => LlmError::ApiError {
39 provider: provider.into(),
40 status,
41 message: String::from_utf8_lossy(body).into_owned(),
42 },
43 _ => LlmError::ApiError {
44 provider: provider.into(),
45 status,
46 message: String::from_utf8_lossy(body).into_owned(),
47 },
48 }
49}
50
51#[derive(Debug, Default)]
52struct ErrorEnvelope {
53 message: String,
54 kind: String,
55 code: String,
56 param: Option<String>,
57}
58
59fn parse_error_envelope(body: &[u8]) -> Option<ErrorEnvelope> {
60 let v: serde_json::Value = serde_json::from_slice(body).ok()?;
61 let env = v.get("error")?;
62 let mut out = ErrorEnvelope::default();
63 if let Some(msg) = env.get("message").and_then(|m| m.as_str()) {
64 out.message = msg.to_string();
65 }
66 if let Some(t) = env.get("type").and_then(|t| t.as_str()) {
67 out.kind = t.to_string();
68 }
69 if let Some(c) = env.get("code").and_then(|c| c.as_str()) {
70 out.code = c.to_string();
71 }
72 out.param = env.get("param").and_then(|p| p.as_str()).map(String::from);
73 Some(out)
74}
75
76fn classify_from_envelope(provider: &'static str, status: u16, env: &ErrorEnvelope) -> LlmError {
77 let lower_kind = env.kind.to_ascii_lowercase();
78 let lower_code = env.code.to_ascii_lowercase();
79 let lower_msg = env.message.to_ascii_lowercase();
80
81 if matches!(status, 401 | 403)
82 || lower_code == "invalid_api_key"
83 || lower_code == "insufficient_quota"
84 || lower_kind == "authentication_error"
85 {
86 return LlmError::AuthError {
87 provider: provider.into(),
88 };
89 }
90
91 if status == 429
92 || lower_code == "rate_limit_exceeded"
93 || lower_kind == "rate_limit_error"
94 || lower_msg.contains("rate limit")
95 {
96 return LlmError::RateLimited {
97 provider: provider.into(),
98 retry_after_secs: None,
99 };
100 }
101
102 if lower_code == "context_length_exceeded"
103 || lower_msg.contains("context length")
104 || lower_msg.contains("maximum context length")
105 {
106 return LlmError::ContextLengthExceeded {
107 tokens_used: 0,
108 limit: 0,
109 };
110 }
111
112 if lower_code == "content_policy_violation"
113 || lower_msg.contains("content policy")
114 || lower_msg.contains("content_filter")
115 {
116 return LlmError::ContentFiltered {
117 provider: provider.into(),
118 reason: env.message.clone(),
119 };
120 }
121
122 if lower_code == "model_not_found"
123 || lower_msg.contains("model not found")
124 || lower_msg.contains("does not exist")
125 {
126 return LlmError::ModelNotFound {
127 provider: provider.into(),
128 model: env.param.clone().unwrap_or_default(),
129 };
130 }
131
132 LlmError::ApiError {
133 provider: provider.into(),
134 status,
135 message: env.message.clone(),
136 }
137}
138
139fn body_mentions(body: &[u8], needle: &str) -> bool {
140 let s = String::from_utf8_lossy(body);
141 s.contains(needle)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn envelope(kind: &str, code: &str, message: &str) -> Vec<u8> {
149 serde_json::to_vec(&serde_json::json!({
150 "error": {"type": kind, "code": code, "message": message}
151 }))
152 .unwrap()
153 }
154
155 #[test]
156 fn rate_limit_by_status() {
157 let e = classify_status("openai", 429, b"{}");
158 assert!(matches!(e, LlmError::RateLimited { .. }));
159 }
160
161 #[test]
162 fn auth_by_envelope_code() {
163 let body = envelope("invalid_request_error", "invalid_api_key", "bad key");
164 let e = classify_status("openai", 401, &body);
165 assert!(matches!(e, LlmError::AuthError { .. }));
166 }
167
168 #[test]
169 fn auth_by_insufficient_quota() {
170 let body = envelope("insufficient_quota_error", "insufficient_quota", "no $");
171 let e = classify_status("openai", 429, &body);
172 assert!(matches!(e, LlmError::AuthError { .. }));
173 }
174
175 #[test]
176 fn context_length_via_envelope() {
177 let body = envelope(
178 "invalid_request_error",
179 "context_length_exceeded",
180 "too many tokens",
181 );
182 let e = classify_status("openai", 400, &body);
183 assert!(matches!(e, LlmError::ContextLengthExceeded { .. }));
184 }
185
186 #[test]
187 fn context_length_via_bare_400_body() {
188 let e = classify_status("openai", 400, b"oh no, context_length limit reached");
189 assert!(matches!(e, LlmError::ContextLengthExceeded { .. }));
190 }
191
192 #[test]
193 fn content_filter_recognised() {
194 let body = envelope("invalid_request_error", "content_policy_violation", "filtered");
195 let e = classify_status("openai", 400, &body);
196 assert!(matches!(e, LlmError::ContentFiltered { .. }));
197 }
198
199 #[test]
200 fn model_not_found_recognised() {
201 let body = envelope(
202 "invalid_request_error",
203 "model_not_found",
204 "model o99 does not exist",
205 );
206 let e = classify_status("openai", 404, &body);
207 assert!(matches!(e, LlmError::ModelNotFound { .. }));
208 }
209
210 #[test]
211 fn unknown_envelope_falls_back_to_api_error() {
212 let body = envelope("server_error", "internal", "boom");
213 let e = classify_status("openai", 500, &body);
214 match e {
215 LlmError::ApiError {
216 status,
217 message,
218 provider,
219 } => {
220 assert_eq!(status, 500);
221 assert_eq!(message, "boom");
222 assert_eq!(provider, "openai");
223 }
224 other => panic!("expected ApiError, got {other:?}"),
225 }
226 }
227
228 #[test]
229 fn non_json_error_body_yields_api_error() {
230 let e = classify_status("openai", 500, b"<html>oops</html>");
231 assert!(matches!(e, LlmError::ApiError { status: 500, .. }));
232 }
233
234 #[test]
235 fn rate_limit_envelope_with_400_status_still_routes_to_rate_limited() {
236 let body = envelope("rate_limit_error", "rate_limit_exceeded", "slow down");
237 let e = classify_status("openai", 429, &body);
238 assert!(matches!(e, LlmError::RateLimited { .. }));
239 }
240
241 #[test]
242 fn transport_timeout_helper_compiles_and_routes_to_network() {
243 let _ = reqwest::Client::new();
244 }
245}
No newline at end of file
Added agent/llm/provider/openai/mod.rs agent/llm/provider/openai/mod.rs +7 −0 View agent/llm/provider/openai/mod.rs
@@ -0,0 +1,7 @@
1mod config;
2mod error;
3mod request;
4mod response;
5mod stream;
6
7pub use config::{OpenAIConfig, OpenAIProvider, SecretString};
No newline at end of file
Added agent/llm/provider/openai/request.rs agent/llm/provider/openai/request.rs +306 −0 View agent/llm/provider/openai/request.rs
@@ -0,0 +1,306 @@
1use crate::error::LlmError;
2use crate::types::*;
3use serde_json::{Value, json};
4
5pub fn build_request_body(
6 model: &str,
7 request: &ChatRequest,
8 stream: bool,
9) -> Result<Value, LlmError> {
10 let effective_model = request.model_override.as_deref().unwrap_or(model);
11
12 let mut input: Vec<Value> = Vec::new();
13 let mut instructions: Option<String> = None;
14
15 for msg in &request.messages {
16 match msg.role {
17 MessageRole::System => {
18 let text = msg.text_content();
19 if !text.is_empty() {
20 instructions = Some(match instructions {
21 Some(existing) => format!("{existing}\n{text}"),
22 None => text,
23 });
24 }
25 }
26 MessageRole::User => {
27 let content = encode_content(&msg.content)?;
28 input.push(json!({ "role": "user", "content": content }));
29 }
30 MessageRole::Assistant => {
31 let mut item = json!({ "role": "assistant" });
32 let text = msg.text_content();
33 if !text.is_empty() {
34 item.as_object_mut().unwrap().insert(
35 "content".into(),
36 json!([{ "type": "output_text", "text": text }]),
37 );
38 }
39 if !msg.tool_calls.is_empty() {
40 let calls: Vec<Value> = msg
41 .tool_calls
42 .iter()
43 .map(|tc| {
44 json!({
45 "type": "function_call",
46 "call_id": tc.id,
47 "name": tc.name,
48 "arguments": serde_json::to_string(&tc.arguments)
49 .unwrap_or_else(|_| "{}".into()),
50 })
51 })
52 .collect();
53 item.as_object_mut()
54 .unwrap()
55 .insert("tool_calls".into(), Value::Array(calls));
56 }
57 input.push(item);
58 }
59 MessageRole::Tool => {
60 let id = msg.tool_call_id.as_deref().ok_or_else(|| {
61 LlmError::Serialization(serde_json::from_str::<()>("\"tool message requires tool_call_id\"").unwrap_err())
62 })?;
63 input.push(json!({
64 "type": "function_call_output",
65 "call_id": id,
66 "output": msg.text_content(),
67 }));
68 }
69 }
70 }
71
72 let mut body = json!({
73 "model": effective_model,
74 "input": input,
75 });
76
77 if let Some(inst) = instructions {
78 body.as_object_mut()
79 .unwrap()
80 .insert("instructions".into(), Value::String(inst));
81 }
82
83 if let Some(max_tokens) = request.max_tokens {
84 body.as_object_mut()
85 .unwrap()
86 .insert("max_output_tokens".into(), Value::from(max_tokens));
87 }
88 if let Some(t) = request.temperature {
89 body.as_object_mut()
90 .unwrap()
91 .insert("temperature".into(), Value::from(t));
92 }
93 if let Some(p) = request.top_p {
94 body.as_object_mut()
95 .unwrap()
96 .insert("top_p".into(), Value::from(p));
97 }
98 if !request.stop_sequences.is_empty() {
99 body.as_object_mut().unwrap().insert(
100 "truncation".into(),
101 Value::String("auto".into()),
102 );
103 }
104
105 if !request.tools.is_empty() {
106 let tools: Vec<Value> = request
107 .tools
108 .iter()
109 .map(|t| {
110 json!({
111 "type": "function",
112 "name": t.name,
113 "description": t.description,
114 "parameters": t.parameters,
115 })
116 })
117 .collect();
118 body.as_object_mut()
119 .unwrap()
120 .insert("tools".into(), Value::Array(tools));
121 }
122
123 if let Some(format) = request.response_format {
124 let text_format = match format {
125 ResponseFormat::Text => json!({ "type": "text" }),
126 ResponseFormat::Json => json!({ "type": "json_object" }),
127 };
128 body.as_object_mut()
129 .unwrap()
130 .insert("text".into(), text_format);
131 }
132
133 if let Some(opts) = &request.provider_options
134 && let Some(obj) = opts.as_object()
135 {
136 for (k, v) in obj {
137 body.as_object_mut().unwrap().insert(k.clone(), v.clone());
138 }
139 }
140
141 if stream {
142 body.as_object_mut()
143 .unwrap()
144 .insert("stream".into(), Value::Bool(true));
145 }
146
147 Ok(body)
148}
149
150fn encode_content(content: &MessageContent) -> Result<Value, LlmError> {
151 match content {
152 MessageContent::Text(s) => Ok(json!([{ "type": "input_text", "text": s }])),
153 MessageContent::Parts(parts) => {
154 let items: Result<Vec<Value>, LlmError> = parts
155 .iter()
156 .map(|p| match p {
157 ContentPart::Text(s) => Ok(json!({ "type": "input_text", "text": s })),
158 ContentPart::Image { url, detail } => Ok(json!({
159 "type": "input_image",
160 "image_url": url,
161 "detail": match detail {
162 ImageDetail::Low => "low",
163 ImageDetail::High => "high",
164 ImageDetail::Auto => "auto",
165 },
166 })),
167 })
168 .collect();
169 Ok(Value::Array(items?))
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178
179 #[test]
180 fn user_text_becomes_input_text() {
181 let body = build_request_body(
182 "gpt-4o-mini",
183 &ChatRequest::new(vec![ChatMessage::user("hi")]),
184 false,
185 )
186 .unwrap();
187 assert_eq!(body["model"], "gpt-4o-mini");
188 assert_eq!(body["input"][0]["role"], "user");
189 assert_eq!(body["input"][0]["content"][0]["type"], "input_text");
190 assert_eq!(body["input"][0]["content"][0]["text"], "hi");
191 }
192
193 #[test]
194 fn system_messages_become_instructions() {
195 let req = ChatRequest::new(vec![
196 ChatMessage::system("be terse"),
197 ChatMessage::system("use bullet points"),
198 ChatMessage::user("hi"),
199 ]);
200 let body = build_request_body("m", &req, false).unwrap();
201 let instructions = body["instructions"].as_str().unwrap();
202 assert!(instructions.contains("be terse"));
203 assert!(instructions.contains("use bullet points"));
204 }
205
206 #[test]
207 fn image_with_detail_becomes_input_image() {
208 let msg = ChatMessage {
209 role: MessageRole::User,
210 content: MessageContent::Parts(vec![ContentPart::Image {
211 url: "https://example.invalid/x.png".into(),
212 detail: ImageDetail::High,
213 }]),
214 name: None,
215 tool_calls: Vec::new(),
216 tool_call_id: None,
217 };
218 let body = build_request_body("m", &ChatRequest::new(vec![msg]), false).unwrap();
219 let item = &body["input"][0]["content"][0];
220 assert_eq!(item["type"], "input_image");
221 assert_eq!(item["detail"], "high");
222 }
223
224 #[test]
225 fn assistant_tool_calls_become_function_call_items() {
226 let mut msg = ChatMessage::assistant("");
227 msg.tool_calls = vec![ToolCall {
228 id: "c1".into(),
229 name: "search".into(),
230 arguments: json!({ "q": "rust" }),
231 }];
232 let body = build_request_body("m", &ChatRequest::new(vec![msg]), false).unwrap();
233 let item = &body["input"][0];
234 assert_eq!(item["role"], "assistant");
235 assert_eq!(item["tool_calls"][0]["type"], "function_call");
236 assert_eq!(item["tool_calls"][0]["call_id"], "c1");
237 assert_eq!(item["tool_calls"][0]["name"], "search");
238 }
239
240 #[test]
241 fn tool_messages_become_function_call_output() {
242 let body = build_request_body(
243 "m",
244 &ChatRequest::new(vec![ChatMessage::tool("c1", "42")]),
245 false,
246 )
247 .unwrap();
248 assert_eq!(body["input"][0]["type"], "function_call_output");
249 assert_eq!(body["input"][0]["call_id"], "c1");
250 assert_eq!(body["input"][0]["output"], "42");
251 }
252
253 #[test]
254 fn tool_call_id_missing_yields_error() {
255 let msg = ChatMessage {
256 role: MessageRole::Tool,
257 content: MessageContent::Text("x".into()),
258 name: None,
259 tool_calls: Vec::new(),
260 tool_call_id: None,
261 };
262 assert!(build_request_body("m", &ChatRequest::new(vec![msg]), false).is_err());
263 }
264
265 #[test]
266 fn tools_become_function_tool_entries() {
267 let req = ChatRequest::new(vec![ChatMessage::user("hi")]).with_tools(vec![ToolDefinition {
268 name: "search".into(),
269 description: "Search".into(),
270 parameters: json!({"type": "object"}),
271 concurrency: ConcurrencyPolicy::default(),
272 }]);
273 let body = build_request_body("m", &req, false).unwrap();
274 assert_eq!(body["tools"][0]["type"], "function");
275 assert_eq!(body["tools"][0]["name"], "search");
276 }
277
278 #[test]
279 fn json_response_format_sets_text_field() {
280 let req = ChatRequest::new(vec![ChatMessage::user("hi")])
281 .with_response_format(ResponseFormat::Json);
282 let body = build_request_body("m", &req, false).unwrap();
283 assert_eq!(body["text"]["type"], "json_object");
284 }
285
286 #[test]
287 fn stream_flag_is_set_when_requested() {
288 let body = build_request_body("m", &ChatRequest::new(vec![]), true).unwrap();
289 assert_eq!(body["stream"], true);
290 }
291
292 #[test]
293 fn model_override_wins_over_configured_model() {
294 let req = ChatRequest::new(vec![]).with_model_override("o3");
295 let body = build_request_body("gpt-4o-mini", &req, false).unwrap();
296 assert_eq!(body["model"], "o3");
297 }
298
299 #[test]
300 fn provider_options_are_merged_into_body() {
301 let mut req = ChatRequest::new(vec![]);
302 req.provider_options = Some(json!({ "prompt_cache_key": "session-1" }));
303 let body = build_request_body("m", &req, false).unwrap();
304 assert_eq!(body["prompt_cache_key"], "session-1");
305 }
306}
No newline at end of file
Added agent/llm/provider/openai/response.rs agent/llm/provider/openai/response.rs +253 −0 View agent/llm/provider/openai/response.rs
@@ -0,0 +1,253 @@
1use crate::error::LlmError;
2use crate::token::TokenUsageStats;
3use crate::types::*;
4use serde_json::Value;
5
6pub fn parse_response(bytes: &[u8]) -> Result<ChatResponse, LlmError> {
7 let v: Value = serde_json::from_slice(bytes)
8 .map_err(LlmError::Serialization)?;
9
10 let provider = "openai".to_string();
11 let model = v
12 .get("model")
13 .and_then(|m| m.as_str())
14 .unwrap_or("unknown")
15 .to_string();
16
17 let mut content = String::new();
18 let mut tool_calls: Vec<ToolCall> = Vec::new();
19
20 if let Some(items) = v.get("output").and_then(|o| o.as_array()) {
21 for item in items {
22 match item.get("type").and_then(|t| t.as_str()) {
23 Some("message") => {
24 if let Some(parts) = item.get("content").and_then(|c| c.as_array()) {
25 for part in parts {
26 if part.get("type").and_then(|t| t.as_str()) == Some("output_text")
27 && let Some(text) = part.get("text").and_then(|t| t.as_str())
28 {
29 if !content.is_empty() {
30 content.push('\n');
31 }
32 content.push_str(text);
33 }
34 }
35 }
36 }
37 Some("function_call") => {
38 let id = item
39 .get("call_id")
40 .and_then(|s| s.as_str())
41 .unwrap_or("")
42 .to_string();
43 let name = item
44 .get("name")
45 .and_then(|s| s.as_str())
46 .unwrap_or("")
47 .to_string();
48 let arguments = item
49 .get("arguments")
50 .and_then(|a| {
51 if a.is_string() {
52 serde_json::from_str(a.as_str().unwrap_or("{}")).ok()
53 } else {
54 Some(a.clone())
55 }
56 })
57 .unwrap_or_else(|| Value::Object(Default::default()));
58 tool_calls.push(ToolCall {
59 id,
60 name,
61 arguments,
62 });
63 }
64 _ => {}
65 }
66 }
67 }
68
69 let finish_reason = finish_reason_from_status(&v, !tool_calls.is_empty());
70
71 let usage = parse_usage(v.get("usage"));
72
73 let mut resp = ChatResponse::new(content, provider, model).with_usage(usage);
74 resp.finish_reason = finish_reason;
75 if !tool_calls.is_empty() {
76 resp.tool_calls = tool_calls;
77 if v.get("status").is_none() {
78 resp.finish_reason = FinishReason::ToolUse;
79 }
80 }
81 Ok(resp)
82}
83
84fn finish_reason_from_status(v: &Value, has_tool_calls: bool) -> FinishReason {
85 if let Some(status) = v.get("status").and_then(|s| s.as_str()) {
86 match status {
87 "completed" => {
88 if has_tool_calls {
89 FinishReason::ToolUse
90 } else {
91 FinishReason::Stop
92 }
93 }
94 "incomplete" => {
95 if let Some(reason) = v
96 .get("incomplete_details")
97 .and_then(|d| d.get("reason"))
98 .and_then(|r| r.as_str())
99 {
100 match reason {
101 "max_output_tokens" => FinishReason::MaxTokens,
102 "content_filter" => FinishReason::ContentFilter,
103 _ => FinishReason::Error,
104 }
105 } else {
106 FinishReason::Error
107 }
108 }
109 "failed" => FinishReason::Error,
110 _ => FinishReason::Error,
111 }
112 } else if has_tool_calls {
113 FinishReason::ToolUse
114 } else {
115 FinishReason::Stop
116 }
117}
118
119fn parse_usage(v: Option<&Value>) -> TokenUsageStats {
120 let Some(v) = v else { return TokenUsageStats::default() };
121 let input = v
122 .get("input_tokens")
123 .and_then(|t| t.as_u64())
124 .unwrap_or(0) as usize;
125 let output = v
126 .get("output_tokens")
127 .and_then(|t| t.as_u64())
128 .unwrap_or(0) as usize;
129 let reasoning = v
130 .get("output_tokens_details")
131 .and_then(|d| d.get("reasoning_tokens"))
132 .and_then(|t| t.as_u64())
133 .map(|n| n as usize);
134 let mut stats = TokenUsageStats::new(input, output);
135 if let Some(r) = reasoning {
136 stats = stats.with_reasoning(r);
137 }
138 stats
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use serde_json::json;
145
146 #[test]
147 fn parses_simple_text_completion() {
148 let body = json!({
149 "model": "gpt-4o-mini",
150 "status": "completed",
151 "output": [{
152 "type": "message",
153 "role": "assistant",
154 "content": [{"type": "output_text", "text": "hello"}]
155 }],
156 "usage": {"input_tokens": 3, "output_tokens": 1}
157 });
158 let bytes = serde_json::to_vec(&body).unwrap();
159 let resp = parse_response(&bytes).unwrap();
160 assert_eq!(resp.content, "hello");
161 assert_eq!(resp.finish_reason, FinishReason::Stop);
162 assert_eq!(resp.usage.input_tokens, 3);
163 assert_eq!(resp.usage.output_tokens, 1);
164 assert_eq!(resp.model, "gpt-4o-mini");
165 assert_eq!(resp.provider, "openai");
166 }
167
168 #[test]
169 fn parses_function_call() {
170 let body = json!({
171 "model": "gpt-4o-mini",
172 "status": "completed",
173 "output": [
174 {"type": "message", "role": "assistant",
175 "content": [{"type": "output_text", "text": "calling"}]},
176 {"type": "function_call", "call_id": "c1", "name": "search",
177 "arguments": "{\"q\":\"rust\"}"}
178 ]
179 });
180 let resp = parse_response(&serde_json::to_vec(&body).unwrap()).unwrap();
181 assert_eq!(resp.tool_calls.len(), 1);
182 assert_eq!(resp.tool_calls[0].name, "search");
183 assert_eq!(resp.tool_calls[0].id, "c1");
184 assert_eq!(resp.tool_calls[0].arguments, json!({"q": "rust"}));
185 assert_eq!(resp.finish_reason, FinishReason::ToolUse);
186 }
187
188 #[test]
189 fn incomplete_max_tokens_maps_to_max_tokens() {
190 let body = json!({
191 "model": "m",
192 "status": "incomplete",
193 "incomplete_details": {"reason": "max_output_tokens"},
194 "output": []
195 });
196 let resp = parse_response(&serde_json::to_vec(&body).unwrap()).unwrap();
197 assert_eq!(resp.finish_reason, FinishReason::MaxTokens);
198 }
199
200 #[test]
201 fn incomplete_content_filter_maps_to_content_filter() {
202 let body = json!({
203 "model": "m",
204 "status": "incomplete",
205 "incomplete_details": {"reason": "content_filter"},
206 "output": []
207 });
208 let resp = parse_response(&serde_json::to_vec(&body).unwrap()).unwrap();
209 assert_eq!(resp.finish_reason, FinishReason::ContentFilter);
210 }
211
212 #[test]
213 fn reasoning_tokens_are_picked_up() {
214 let body = json!({
215 "model": "m",
216 "status": "completed",
217 "output": [{"type": "message", "role": "assistant",
218 "content": [{"type": "output_text", "text": "x"}]}],
219 "usage": {"input_tokens": 1, "output_tokens": 2,
220 "output_tokens_details": {"reasoning_tokens": 5}}
221 });
222 let resp = parse_response(&serde_json::to_vec(&body).unwrap()).unwrap();
223 assert_eq!(resp.usage.reasoning_tokens, Some(5));
224 }
225
226 #[test]
227 fn empty_output_array_is_tolerated() {
228 let body = json!({"model": "m", "status": "completed", "output": []});
229 let resp = parse_response(&serde_json::to_vec(&body).unwrap()).unwrap();
230 assert_eq!(resp.content, "");
231 assert_eq!(resp.finish_reason, FinishReason::Stop);
232 }
233
234 #[test]
235 fn malformed_json_returns_serialization_error() {
236 let err = parse_response(b"not-json").unwrap_err();
237 assert!(matches!(err, LlmError::Serialization(_)));
238 }
239
240 #[test]
241 fn function_call_with_object_arguments_is_kept_as_object() {
242 let body = json!({
243 "model": "m",
244 "status": "completed",
245 "output": [
246 {"type": "function_call", "call_id": "c1", "name": "search",
247 "arguments": {"q": "rust"}}
248 ]
249 });
250 let resp = parse_response(&serde_json::to_vec(&body).unwrap()).unwrap();
251 assert_eq!(resp.tool_calls[0].arguments, json!({"q": "rust"}));
252 }
253}
No newline at end of file
Added agent/llm/provider/openai/stream.rs agent/llm/provider/openai/stream.rs +270 −0 View agent/llm/provider/openai/stream.rs
@@ -0,0 +1,270 @@
1use crate::error::LlmError;
2use crate::types::*;
3use crate::token::TokenUsageStats;
4use async_stream::try_stream;
5use futures::{Stream, StreamExt};
6use serde_json::Value;
7
8pub fn parse_event_data(data: &str) -> Result<Vec<StreamEvent>, LlmError> {
9 if data.trim() == "[DONE]" {
10 return Ok(vec![StreamEvent::Done {
11 finish_reason: FinishReason::Stop,
12 }]);
13 }
14 let v: Value = serde_json::from_str(data)
15 .map_err(|e| LlmError::Stream(format!("openai sse: bad json: {e}")))?;
16 let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
17 let mut out = Vec::new();
18 match ty {
19 "response.output_text.delta" => {
20 if let Some(d) = v.get("delta").and_then(|d| d.as_str()) {
21 out.push(StreamEvent::TextDelta { delta: d.to_string() });
22 }
23 }
24 "response.reasoning_summary_text.delta"
25 | "response.reasoning_text.delta"
26 | "response.reasoning.delta" => {
27 if let Some(d) = v.get("delta").and_then(|d| d.as_str()) {
28 out.push(StreamEvent::ReasoningDelta { delta: d.to_string() });
29 }
30 }
31 "response.function_call_arguments.delta" => {
32 let call_id = v
33 .get("item_id")
34 .and_then(|s| s.as_str())
35 .or_else(|| v.get("call_id").and_then(|s| s.as_str()))
36 .unwrap_or("")
37 .to_string();
38 let name = v
39 .get("name")
40 .and_then(|s| s.as_str())
41 .map(String::from);
42 let delta = v
43 .get("delta")
44 .and_then(|d| d.as_str())
45 .unwrap_or("")
46 .to_string();
47 out.push(StreamEvent::ToolCallDelta {
48 index: v
49 .get("output_index")
50 .and_then(|i| i.as_u64())
51 .map(|n| n as usize),
52 id: call_id,
53 name,
54 args_delta: delta,
55 });
56 }
57 "response.output_item.added" | "response.output_item.done" => {
58 if let Some(item) = v.get("item")
59 && item.get("type").and_then(|t| t.as_str()) == Some("function_call")
60 {
61 let call_id = item
62 .get("call_id")
63 .and_then(|s| s.as_str())
64 .or_else(|| item.get("id").and_then(|s| s.as_str()))
65 .unwrap_or("")
66 .to_string();
67 let name = item
68 .get("name")
69 .and_then(|s| s.as_str())
70 .map(String::from);
71 let args = item
72 .get("arguments")
73 .and_then(|a| a.as_str())
74 .unwrap_or("")
75 .to_string();
76 if !call_id.is_empty() && !args.is_empty() {
77 out.push(StreamEvent::ToolCallDelta {
78 index: v
79 .get("output_index")
80 .and_then(|i| i.as_u64())
81 .map(|n| n as usize),
82 id: call_id,
83 name,
84 args_delta: args,
85 });
86 }
87 }
88 }
89 "response.completed" | "response.done" => {
90 if let Some(resp) = v.get("response")
91 && let Some(usage) = resp.get("usage")
92 {
93 out.push(StreamEvent::Usage(parse_usage(usage)));
94 }
95 out.push(StreamEvent::Done {
96 finish_reason: FinishReason::Stop,
97 });
98 }
99 "response.incomplete" => {
100 out.push(StreamEvent::Done {
101 finish_reason: FinishReason::Error,
102 });
103 }
104 _ => {
105 }
106 }
107 Ok(out)
108}
109
110fn parse_usage(v: &Value) -> TokenUsageStats {
111 let input = v
112 .get("input_tokens")
113 .and_then(|t| t.as_u64())
114 .unwrap_or(0) as usize;
115 let output = v
116 .get("output_tokens")
117 .and_then(|t| t.as_u64())
118 .unwrap_or(0) as usize;
119 let reasoning = v
120 .get("output_tokens_details")
121 .and_then(|d| d.get("reasoning_tokens"))
122 .and_then(|t| t.as_u64())
123 .map(|n| n as usize);
124 let mut stats = TokenUsageStats::new(input, output);
125 if let Some(r) = reasoning {
126 stats = stats.with_reasoning(r);
127 }
128 stats
129}
130
131pub fn sse_to_stream_events<E>(
132 mut events: eventsource_stream::EventStream<
133 impl futures::Stream<Item = Result<bytes::Bytes, E>> + Unpin + Send,
134 >,
135) -> impl Stream<Item = Result<StreamEvent, LlmError>>
136where
137 E: std::fmt::Display,
138{
139 try_stream! {
140 while let Some(event) = events.next().await {
141 let event = event.map_err(|e| {
142 LlmError::Network(format!("openai sse transport error: {e}"))
143 })?;
144 let events = parse_event_data(&event.data)?;
145 for ev in events {
146 yield ev;
147 }
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use serde_json::json;
156
157 #[test]
158 fn done_sentinel_yields_stop_event() {
159 let evs = parse_event_data("[DONE]").unwrap();
160 assert_eq!(
161 evs,
162 vec![StreamEvent::Done {
163 finish_reason: FinishReason::Stop
164 }]
165 );
166 }
167
168 #[test]
169 fn text_delta_yields_text_delta() {
170 let data = json!({"type": "response.output_text.delta", "delta": "hi"})
171 .to_string();
172 let evs = parse_event_data(&data).unwrap();
173 assert!(matches!(&evs[0], StreamEvent::TextDelta { delta } if delta == "hi"));
174 }
175
176 #[test]
177 fn reasoning_delta_yields_reasoning_delta() {
178 let data = json!({"type": "response.reasoning_text.delta", "delta": "hmm"})
179 .to_string();
180 let evs = parse_event_data(&data).unwrap();
181 assert!(matches!(&evs[0], StreamEvent::ReasoningDelta { delta } if delta == "hmm"));
182 }
183
184 #[test]
185 fn function_call_args_delta_yields_tool_call_delta() {
186 let data = json!({
187 "type": "response.function_call_arguments.delta",
188 "item_id": "c1",
189 "output_index": 0,
190 "delta": "{\"q\":"
191 })
192 .to_string();
193 let evs = parse_event_data(&data).unwrap();
194 match &evs[0] {
195 StreamEvent::ToolCallDelta {
196 index,
197 id,
198 name,
199 args_delta,
200 } => {
201 assert_eq!(*index, Some(0));
202 assert_eq!(id, "c1");
203 assert!(name.is_none());
204 assert_eq!(args_delta, "{\"q\":");
205 }
206 other => panic!("unexpected: {other:?}"),
207 }
208 }
209
210 #[test]
211 fn output_item_added_seeds_tool_call() {
212 let data = json!({
213 "type": "response.output_item.added",
214 "output_index": 0,
215 "item": {
216 "type": "function_call",
217 "call_id": "c1",
218 "name": "search",
219 "arguments": ""
220 }
221 })
222 .to_string();
223 let evs = parse_event_data(&data).unwrap();
224 assert!(evs.is_empty());
225 }
226
227 #[test]
228 fn completed_event_yields_usage_and_done() {
229 let data = json!({
230 "type": "response.completed",
231 "response": {
232 "usage": {"input_tokens": 3, "output_tokens": 1}
233 }
234 })
235 .to_string();
236 let evs = parse_event_data(&data).unwrap();
237 assert!(matches!(&evs[0], StreamEvent::Usage(_)));
238 assert!(matches!(
239 &evs[1],
240 StreamEvent::Done {
241 finish_reason: FinishReason::Stop
242 }
243 ));
244 }
245
246 #[test]
247 fn incomplete_event_yields_error_done() {
248 let data = json!({"type": "response.incomplete"}).to_string();
249 let evs = parse_event_data(&data).unwrap();
250 assert!(matches!(
251 &evs[0],
252 StreamEvent::Done {
253 finish_reason: FinishReason::Error
254 }
255 ));
256 }
257
258 #[test]
259 fn unknown_event_type_is_ignored() {
260 let data = json!({"type": "response.created"}).to_string();
261 let evs = parse_event_data(&data).unwrap();
262 assert!(evs.is_empty());
263 }
264
265 #[test]
266 fn malformed_json_returns_stream_error() {
267 let err = parse_event_data("not-json").unwrap_err();
268 assert!(matches!(err, LlmError::Stream(_)));
269 }
270}
No newline at end of file
Renamed agent/llm/provider.rs → agent/llm/provider/mod.rs agent/llm/provider/mod.rs +2 −0 View agent/llm/provider/mod.rs
@@ -7,6 +7,8 @@
77use std::pin::Pin;
88use tokio_stream::Stream;
99
10pub mod openai;
11
1012#[async_trait::async_trait]
1113pub trait LlmProvider: Send + Sync + 'static {
1214 fn provider_id(&self) -> &str;
Added agent/llm/tests/openai_provider.rs agent/llm/tests/openai_provider.rs +233 −0 View agent/llm/tests/openai_provider.rs
@@ -0,0 +1,233 @@
1use llm::error::LlmError;
2use llm::provider::LlmProvider;
3use llm::provider::openai::{OpenAIConfig, OpenAIProvider};
4use llm::types::*;
5use futures::StreamExt;
6use serde_json::json;
7use wiremock::matchers::{method, path};
8use wiremock::{Mock, MockServer, ResponseTemplate};
9
10fn provider_for(base_url: &str) -> OpenAIProvider {
11 OpenAIProvider::new(
12 OpenAIConfig::new("test-key")
13 .with_model("gpt-4o-mini")
14 .with_base_url(base_url)
15 .with_request_timeout(std::time::Duration::from_secs(5)),
16 )
17 .expect("client builds")
18}
19
20#[tokio::test]
21async fn chat_decodes_a_text_completion() {
22 let server = MockServer::start().await;
23 let body = json!({
24 "id": "resp_1",
25 "model": "gpt-4o-mini",
26 "status": "completed",
27 "output": [{
28 "type": "message",
29 "role": "assistant",
30 "content": [{"type": "output_text", "text": "hello"}]
31 }],
32 "usage": {"input_tokens": 3, "output_tokens": 1}
33 });
34 Mock::given(method("POST"))
35 .and(path("/v1/responses"))
36 .respond_with(ResponseTemplate::new(200).set_body_json(body))
37 .mount(&server)
38 .await;
39
40 let provider = provider_for(&server.uri());
41 let resp = provider
42 .chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
43 .await
44 .unwrap();
45 assert_eq!(resp.content, "hello");
46 assert_eq!(resp.finish_reason, FinishReason::Stop);
47 assert_eq!(resp.usage.input_tokens, 3);
48}
49
50#[tokio::test]
51async fn chat_routes_a_429_envelope_to_rate_limited() {
52 let server = MockServer::start().await;
53 Mock::given(method("POST"))
54 .and(path("/v1/responses"))
55 .respond_with(ResponseTemplate::new(429).set_body_json(json!({
56 "error": {
57 "type": "rate_limit_error",
58 "code": "rate_limit_exceeded",
59 "message": "slow down"
60 }
61 })))
62 .mount(&server)
63 .await;
64
65 let provider = provider_for(&server.uri());
66 let err = provider
67 .chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
68 .await
69 .unwrap_err();
70 assert!(matches!(err, LlmError::RateLimited { .. }));
71}
72
73#[tokio::test]
74async fn chat_routes_a_401_to_auth_error() {
75 let server = MockServer::start().await;
76 Mock::given(method("POST"))
77 .and(path("/v1/responses"))
78 .respond_with(ResponseTemplate::new(401).set_body_json(json!({
79 "error": {
80 "type": "authentication_error",
81 "code": "invalid_api_key",
82 "message": "bad key"
83 }
84 })))
85 .mount(&server)
86 .await;
87
88 let provider = provider_for(&server.uri());
89 let err = provider
90 .chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
91 .await
92 .unwrap_err();
93 assert!(matches!(err, LlmError::AuthError { .. }));
94}
95
96#[tokio::test]
97async fn chat_routes_400_context_length_envelope() {
98 let server = MockServer::start().await;
99 Mock::given(method("POST"))
100 .and(path("/v1/responses"))
101 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
102 "error": {
103 "type": "invalid_request_error",
104 "code": "context_length_exceeded",
105 "message": "too many tokens"
106 }
107 })))
108 .mount(&server)
109 .await;
110
111 let provider = provider_for(&server.uri());
112 let err = provider
113 .chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
114 .await
115 .unwrap_err();
116 assert!(matches!(err, LlmError::ContextLengthExceeded { .. }));
117}
118
119#[tokio::test]
120async fn chat_sends_bearer_and_org_headers() {
121 use wiremock::matchers::header;
122 let server = MockServer::start().await;
123 Mock::given(method("POST"))
124 .and(path("/v1/responses"))
125 .and(header("Authorization", "Bearer test-key"))
126 .and(header("OpenAI-Organization", "org-x"))
127 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
128 "model": "gpt-4o-mini",
129 "status": "completed",
130 "output": []
131 })))
132 .expect(1)
133 .mount(&server)
134 .await;
135
136 let mut config = OpenAIConfig::new("test-key").with_base_url(server.uri());
137 config.organization = Some("org-x".into());
138 let provider = OpenAIProvider::new(config).unwrap();
139 let _ = provider
140 .chat(ChatRequest::new(vec![ChatMessage::user("hi")]))
141 .await
142 .unwrap();
143}
144
145#[tokio::test]
146async fn chat_stream_decodes_text_deltas() {
147 let server = MockServer::start().await;
148 let sse_body = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"he\"}\n\n\
149 data: {\"type\":\"response.output_text.delta\",\"delta\":\"llo\"}\n\n\
150 data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}}\n\n\
151 data: [DONE]\n\n";
152 Mock::given(method("POST"))
153 .and(path("/v1/responses"))
154 .respond_with(
155 ResponseTemplate::new(200)
156 .insert_header("content-type", "text/event-stream")
157 .set_body_string(sse_body),
158 )
159 .mount(&server)
160 .await;
161
162 let provider = provider_for(&server.uri());
163 let stream = provider
164 .chat_stream(ChatRequest::new(vec![ChatMessage::user("hi")]))
165 .await
166 .unwrap();
167 let events: Vec<StreamEvent> = stream
168 .map(|r| r.unwrap())
169 .collect()
170 .await;
171
172 let texts: Vec<String> = events
173 .iter()
174 .filter_map(|e| match e {
175 StreamEvent::TextDelta { delta } => Some(delta.clone()),
176 _ => None,
177 })
178 .collect();
179 assert_eq!(texts, vec!["he", "llo"]);
180 assert!(events.iter().any(
181 |e| matches!(e, StreamEvent::Done { finish_reason: FinishReason::Stop })
182 ));
183 assert!(events.iter().any(|e| matches!(e, StreamEvent::Usage(_))));
184}
185
186#[tokio::test]
187async fn chat_stream_surfaces_404_as_model_not_found() {
188 let server = MockServer::start().await;
189 Mock::given(method("POST"))
190 .and(path("/v1/responses"))
191 .respond_with(ResponseTemplate::new(404).set_body_json(json!({
192 "error": {
193 "type": "invalid_request_error",
194 "code": "model_not_found",
195 "message": "model o99 does not exist"
196 }
197 })))
198 .mount(&server)
199 .await;
200
201 let provider = provider_for(&server.uri());
202 let result = provider
203 .chat_stream(ChatRequest::new(vec![ChatMessage::user("hi")]))
204 .await;
205 let err = match result {
206 Ok(_) => panic!("expected ModelNotFound, got Ok"),
207 Err(e) => e,
208 };
209 assert!(matches!(err, LlmError::ModelNotFound { .. }));
210}
211
212#[tokio::test]
213async fn request_body_uses_model_override() {
214 use wiremock::matchers::body_partial_json;
215 let server = MockServer::start().await;
216 Mock::given(method("POST"))
217 .and(path("/v1/responses"))
218 .and(body_partial_json(json!({"model": "o3"})))
219 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
220 "model": "o3",
221 "status": "completed",
222 "output": []
223 })))
224 .expect(1)
225 .mount(&server)
226 .await;
227
228 let provider = provider_for(&server.uri());
229 provider
230 .chat(ChatRequest::new(vec![ChatMessage::user("hi")]).with_model_override("o3"))
231 .await
232 .unwrap();
233}
No newline at end of file
Modified agent/llm/Cargo.toml agent/llm/Cargo.toml +6 −1 View agent/llm/Cargo.toml
@@ -19,6 +19,11 @@
1919futures.workspace = true
2020tokio-stream.workspace = true
2121tracing.workspace = true
22reqwest = { workspace = true, default-features = false, features = ["default-tls", "json", "stream"] }
23bytes.workspace = true
24eventsource-stream = "0.2"
25async-stream = "0.3"
2226
2327[dev-dependencies]
24tokio = { workspace = true, features = ["macros", "rt"] }
No newline at end of file
28tokio = { workspace = true, features = ["macros", "rt"] }
29wiremock = "0.6"
No newline at end of file
Deleted cybersecurity.md cybersecurity.md +0 −407
@@ -1,407 +0,0 @@
1# 网络安全审计报告 — `/home/zhenyi/work/untitled`
2
3> **审计范围**:完整 Rust workspace(Git 远程托管平台 + 聊天 + LiveKit 视频 + 支付),共 30+ crate、约 6.5 万行代码,5 个二进制(apihost / gitpro / githook / livechat / mails)。
4>
5> **方法**:OWASP Top 10:2021 / CWE Top 25 静态审计;8 个并行专职 agent(git 传输层 / 基础设施与配置 / 聊天·媒体·直播·WS / Web API / 认证与令牌 / 供应链与后门 / 合规与隐私 / 交叉验证与攻击链),加上 `cargo audit`、`cargo deny` 依赖审计,主会话对最高风险项的逐条源码复核,以及独立多 agent 交叉验证(结论与修订见 [§13](#13-交叉验证修订记录),2026-08-08 修订)。
6>
7> **日期**:2026-08-08 | **扫描器**:Claude Code(cybersecurity skill)
8>
9> **结论**:代码库**没有**发现恶意后门、C2 信标、混淆代码或供应链篡改(`unsafe` 全库为 0)。但存在**多个可直接利用的授权绕过漏洞**(CRITICAL × 3,HIGH × 9),其中仓库 REST API 的鉴权失效是核心根因,可导致任意仓库被删除/接管、所有私有仓库被读取、任意用户提权为站点管理员。
10
11---
12
13## 目录
14
151. [总体风险评估](#1-总体风险评估)
162. [关键发现摘要(按严重程度)](#2-关键发现摘要按严重程度)
173. [CRITICAL 漏洞详情](#3-critical-漏洞详情)
184. [HIGH 漏洞详情](#4-high-漏洞详情)
195. [MEDIUM 漏洞详情](#5-medium-漏洞详情)
206. [LOW 与潜在问题](#6-low-与潜在问题)
217. [供应链与依赖审计](#7-供应链与依赖审计)
228. [后门与 C2 扫描结论](#8-后门与-c2-扫描结论)
239. [合规与隐私(GDPR/CCPA/ISO 27001)](#9-合规与隐私gdprccpaiso-27001)
2410. [完整攻击链](#10-完整攻击链)
2511. [确认已有的防护措施](#11-确认已有的防护措施)
2612. [修复优先级路线图](#12-修复优先级路线图)
2713. [交叉验证修订记录](#13-交叉验证修订记录)
28
29---
30
31## 1. 总体风险评估
32
33| 维度 | 评分 (0-100) | 说明 |
34|---|---|---|
35| **授权 / Access Control** | **15 / 100** | 仓库/Issue REST API 鉴权形同虚设,SSH 作用域短路,git Basic 认证可越权提权 |
36| **认证 / Authentication** | 45 / 100 | 密码/令牌哈希良好,但 deny-list 覆盖不一致、签名密钥生命周期损坏 |
37| **聊天 / Chat & WS** | 55 / 100 | 存储层有严格成员校验,但幂等键、mentions、Live 存在越权点 |
38| **Git 传输 / Git & SSH** | 40 / 100 | 智能 HTTP 鉴权正确,但 SSH 绕过 + 推送保护失效 + DoS |
39| **基础设施 / Infra & Config** | 50 / 100 | 无 TLS、密钥明文落库、Redis/出口默认明文 |
40| **供应链 / Supply Chain** | 80 / 100 | 3 个已知 CVE(rsa ×2 无修复、rkyv 需 major 升级至 ≥0.8.17),无后门,锁文件干净 |
41| **隐私合规 / Privacy** | 40 / 100 | 无删除权、无数据可携带、同意记录可伪造、PII 泄漏 |
42
43**总体风险等级:高危(High)** — 存在可立即利用、影响全部租户的授权绕过。
44
45---
46
47## 2. 关键发现摘要(按严重程度)
48
49| # | 严重程度 | 标题 | 根因文件 | 影响 |
50|---|---|---|---|---|
51| **C1** | CRITICAL | **任意认证用户可销毁/转移/重命名任意仓库** | `lib/repo/features/lifecycle/usecase/*.rs` | 数据丢失 / 仓库劫持 |
52| **C2** | CRITICAL | **git Basic 认证可越权为站点管理员(JWT-as-password 用户名混合)** | `dvcs/gitauth/service.rs:68-80` | 任意用户对所有仓库获得 Admin |
53| **C3** | CRITICAL | **任何 SSH 用户可读所有私有仓库(作用域短路绕过仓库 ACL)** | `lib/repo/permission.rs:35-43` + `lib/repo/actor.rs:71-81` | 全库私有数据外泄 |
54| **H1** | HIGH | **任意用户可添加/删除 collaborator(含自封 admin)并管理 deploy key/分支** | `lib/repo/features/collab/usecase.rs:33-38` | 提权 + 写任意仓库 |
55| **H2** | HIGH | **私有仓库元数据全量泄漏(list/get 无视 actor)** | `lib/repo/features/lifecycle/usecase/{list,get}.rs` | 私有库清单/名称泄漏 |
56| **H3** | HIGH | **Issue 更新/删除/关闭无归属或成员校验** | `lib/issues/features/issue/usecase/*.rs` | 跨频道修改/删除工单 |
57| **H4** | HIGH | **私有用户 PII(真名/出生日期)可被任意登录用户读取** | `lib/user/features/profile/usecase.rs` | 隐私泄漏 |
58| **H5** | HIGH | **撤销 deny-list 未在 repo/issues API、git HTTP/SSH、chat WS 上执行** | `lib/repo/service.rs`、`lib/issues/service.rs`(project 已正确接线) | 登出/改密/封禁后令牌仍有效 |
59| **H6** | HIGH | **分支/标签保护在推送路径上从不执行** | `dvcs/gitsync/post_receive.rs`(只有 capture hook) | `git push --force` 到受保护分支 |
60| **H7** | HIGH | **SSH 通道缓冲无上限 → 内存耗尽 DoS** | `dvcs/gitssh/handler/channel.rs:8-12` | OOM |
61| **H8** | HIGH | **聊天消息幂等键全局无主键 → 跨用户去重抑制 + 消息 ID 泄漏** | `chat/chatlayer/storage_bridge.rs:52-85` | 消息被丢弃 / 存在性探测 |
62| **H9** | HIGH | **Live 会议销毁副作用先于鉴权(任何成员可踢掉会议)** | `chat/live/service.rs:211-242` | 拒绝服务(Live) |
63| **H10** | HIGH | **所有 HTTP 服务无 TLS,git Basic 认证明文传输密码** | `config/default.yaml` + `app/*/main.rs` | 全站密码嗅探 |
64
65---
66
67## 3. CRITICAL 漏洞详情
68
69### C1 — 任意认证用户可销毁/转移/重命名任意仓库(CWE-306)
70
71**根因**:`lib/repo` 的 Web API 层完全绕过了 `repo::permission::require`(在 `lib/repo/features` 中被 grep 确认无任何引用),改用只检查 `actor.is_nil()` 的桩守卫。
72
73- `lib/repo/features/lifecycle/usecase/delete.rs:19-23` — `require_authenticated(actor)?` 后直接 `Entity::delete_by_id(repo_id).exec(&db)`,**无所有权/空间成员检查**。
74- `update.rs:20-55` — 同上,可翻转 `is_private`、`default_branch`。
75- `transfer.rs:21-32` — `active.channel_id = Set(input.new_channel_id)` → 攻击者把仓库搬到自己的频道(**仓库劫持**)。
76- 守卫桩:`lib/repo/features/lifecycle/usecase/helpers.rs:8-13`(仅拒绝 nil UUID)。
77
78**攻击路径**:`GET /api/v1/repos`(见 H2)枚举所有 `channel_id`+`id` → `DELETE /api/v1/repo/{channel_id}/{repo_id}` 删除任意仓库,或 `POST .../transfer` 接管。
79
80**修复**:所有仓库变更路由改走 `repo::permission::require(actor, repo.access, ...)`,delete/transfer 要求 owner 或 channel-write,update 要求 owner/space-admin。
81
82---
83
84### C2 — git Basic 认证可越权为站点管理员(JWT-as-password 身份混合)(CWE-269)
85
86**根因**:`dvcs/gitauth/service.rs:68-80` 中 `authenticate()` 按 Basic 认证的 **username** 查 DB(第 61-64 行),而 `verify_credentials()`(第 112-115 行)—— 如果 `password` 字段是一个**合法 access token**,直接返回 `claims.user_id()`,**从不与 username 记录比对**。随后 actor 用 `user_id: Some(claims.user_id())`(攻击者自己的 id)**但** `is_site_admin: user.is_admin`(被冒充用户名对应的管理员标记,第 77 行)构造。
87
88**提权点**:`lib/repo/permission.rs:25-27` — `if actor.is_site_admin { return RepoAccess::Admin }`。(初版误标 130-132,该文件仅 111 行;交叉验证修正)
89
90**攻击路径**:
911. 攻击者用自己的账号登录获得自己的 access token。
922. `curl -u <站点管理员用户名>:<自己的access_token> https://host/priv-repo.git/info/refs?service=git-upload-pack`
933. `authenticate` 返回 `user_id` = 攻击者、`is_site_admin = true` 的 actor。
944. `authorize()` → `effective_access` 返回 `Admin` → 对所有仓库(含私有)读/写/删,且 LFS 同样生效。
95
96**修复**:JWT 路径成功后,actor 必须完全从验证后的身份构建:使用 `claims.is_admin`,或从 `claims.sub` 重取用户并拒绝 `claims.sub ≠ username-row.uid` 的情况;**永不**混合 username 行的 `is_admin` 与另一身份的 `user_id`。强烈建议彻底放弃 JWT-as-password 路径(改用独立的 git 令牌)。
97
98---
99
100### C3 — 任何 SSH 用户可读所有私有仓库(作用域短路绕过仓库 ACL)(CWE-863 / CWE-276)
101
102**根因**:
103- `lib/repo/actor.rs:71-81` — `ActorContext::for_user` 给**每个**用户都盖上 `TokenScope::RepoRead`。
104- `lib/repo/permission.rs:35-43` — `effective_access` 在 `has_scope(RepoRead)` 时**立即返回 `Read`**,早于任何 `space_role` / `collaborator_mode` / `is_public` 检查;`collaborator_mode` 只用于升级到 Write,从不用于拒绝。
105- `dvcs/gitssh/handler/command.rs:47-49` — SSH 路径用 `for_user(u.uid)` 构造 actor(HTTP 路径在 `dvcs/gitauth/service.rs:73-79` 传空 scopes,故 HTTP 安全)。
106
107**攻击路径**:任何注册用户添加 SSH key → `git clone ssh://host/任意/私有仓库.git` → `authorize(..., Read)` → `effective_access = Read` 无论仓库私有性 → 克隆成功。**全站每个私有仓库对每个认证用户可读**(只读外泄,写入仍受控)。
108
109**修复**:`for_user` 不要授予 `RepoRead` 作用域(与 HTTP actor 一致——空 scopes,让 space/collab 决定);或重排 `effective_access`,让仓库级检查先跑,作用域只用于升级 token/deploy-key 流。
110
111> **主会话独立复核确认**:已在 `lib/repo/permission.rs`、`lib/repo/actor.rs`、`dvcs/gitssh/handler/command.rs` 源码中逐行验证该路径真实存在。
112
113---
114
115## 4. HIGH 漏洞详情
116
117### H1 — 任意用户可添加/删除 collaborator(含自封 admin)并管理 deploy key/分支(CWE-306 → 提权)
118
119- `lib/repo/features/collab/usecase.rs:33-38` — `require_admin(actor)` 是桩,任意认证用户通过;`add_collaborator`(96-135)/`delete_collaborator`(137-157)直接写 `repo_collaborations`。
120- `lib/repo/handler/collab/add.rs:19-28` — `PUT /api/v1/repo/{channel}/{repo}/collaborators/{user_id}` body `{"permission":"admin"}`。
121- 同类桩守卫:`deploy_key/usecase.rs:12-17,95`、`branch/usecase.rs:16-21`、`branch_protection/usecase.rs`、`tag_protection/usecase.rs`。
122- **攻击路径**:把自己加为任意仓库的 `admin` collaborator → git `receive-pack` 获得写权限(collab 表被 `gitauth/permission.rs:53-71` 读取)→ 部署代码、转移/删除仓库、翻转分支保护、投毒下游消费者。
123
124### H2 — 私有仓库元数据全量泄漏(CWE-200 / CWE-639)
125
126- `lib/repo/features/lifecycle/usecase/list.rs:14` — `let _ = actor;`,查询无 actor 过滤,`GET /api/v1/repos` 返回**所有**仓库行(含 `is_private`)。
127- `get.rs:19-21` — 仅对匿名用户挡私有,任何认证用户可读任意私有仓库元数据(名称、描述、默认分支、clone/SSH URL)。
128- 这也为 C1 提供了 `channel_id`/`repo_id` 枚举源。
129
130### H3 — Issue 更新/删除/关闭无归属或成员校验(CWE-306)
131
132- `lib/issues/features/issue/usecase/delete.rs:18-24` — `require_authenticated` 后硬删;`update.rs:21-58` 同;`close.rs` 同。仅 comment 有真实 owner 检查。
133- `lib/issues` 层从不校验 channel/space 成员(唯一助手 `require_authenticated` 见 `helpers.rs:9-14`)。
134- 任意登录用户给定 channel+issue id 即可改/删/关他人工单。
135
136### H4 — 私有用户 PII(真名/出生日期)可被任意登录用户读取(CWE-200)
137
138- `lib/user/handler/profile/profile_view.rs:23-30` — `AuthUser` 存在即调 `svc.get_profile(uid)`。
139- `lib/user/features/profile/usecase.rs:124-137` — `fetch_profile` 无 viewer/可见性检查(对比 `fetch_user_public`:41-62 + `can_view`)。
140- `ProfileView::from` 无条件输出 `real_name`、`date_of_birth`、`location` 等,`show_real_name`/`show_location` 标志**从不执行过滤**。
141- 用户设为 `visibility: Private` 仍会暴露真名 + 出生日期。
142
143### H5 — 撤销 deny-list 未在 repo/issues API、git HTTP/SSH、chat WS 上执行(CWE-613)
144
145- `lib/repo/service.rs:47-54`、`lib/issues/service.rs:41-45` — 实现 `HasJwtState` 但**不覆盖** `access_blacklist()` → 默认 `None`(`lib/jwt/middleware.rs:16-18`),repo/issues API 的 JWT 不做撤销检查。
146- `dvcs/gitauth/service.rs:130-144`(git JWT-as-password)、`chat/chatlayer/auth_bridge.rs:26-54`(chat WS/服务层)、`dvcs/gitssh/handler/auth.rs:32-34`(SSH 公钥:仅拒绝 `!is_active`)— 均无 blacklist、无 `pwd_version` 校验。
147- 已正确接线(确认):`lib/auth`、`lib/user`、`lib/space`、`chat/live`、**`lib/project`**(`lib/project/service.rs:42-45` 覆写 `access_blacklist`,project 路由实际执行撤销)。
148- **影响**:受害者改密/登出/被挂起后,其令牌在 repo/issues API、git HTTP(JWT)、chat WS 上仍有效(`access_ttl` 默认 15 分钟);SSH 为公钥认证,封禁只置 `is_restricted`(`lib/user/features/suspensions/usecase.rs:67`)而非 `is_active`,被封用户 SSH key 继续有效。
149- **交叉验证修正**:初版把 `lib/project` 误列为未接线并计入受害面——实际 project 已正确接线;真实缺口是 **repo + issues(HTTP)+ git HTTP/SSH + chat WS**。因撤销窗口受 `access_ttl`(15 分钟)约束,严重度建议 **MEDIUM–HIGH**。
150
151### H6 — 分支/标签保护在推送路径上从不执行(CWE-284)
152
153- 推送路径跑裸 `git receive-pack`,仅装了一个 **capture** `post-receive` hook(`dvcs/gitsync/post_receive.rs:25-38`,只把 ref 更新追加到 `_gta_post_receive`)。
154- `hook_pre_receive`(`dvcs/git/hook/usecase.rs:6-12`)是恒返回 `accepted:false` 的桩,且 **从不被调用**(grep 只有 trait 定义 + 该实现)。
155- `lib/repo/features/branch_protection/*` 只管理 DB 策略行,`git push` 时不读取。
156- **影响**:任何有 `RepoAccess::Write` 的用户可 `git push --force`/删除受保护分支/标签,`protected_file_patterns`/`required_approvals` 形同虚设。
157
158### H7 — SSH 通道缓冲无上限 → 内存耗尽 DoS(CWE-770)
159
160- `dvcs/gitssh/handler/channel.rs:8-12` — 每个 `data()` 回调 `state.buffer.extend_from_slice(data)` 无上限。
161- git 子进程直到 EOF 才 spawn(`channel.rs:18-51`),整包先驻留内存再整份拷给 git stdin。HTTP 侧对应防护 `MAX_RPC_BODY = 1<<30` 仅存在于 `gitsmart/handler/smart.rs:13`。
162- **补充(交叉验证)**:`max_sessions=32` 信号量虽在 `server.rs:22-26` 创建,但其 `OwnedSemaphorePermit` 标 `#[allow(dead_code)]` **从未用于拒绝超额连接** → 并发会话实际无上限;缓冲发生在 exec 之前(首个 `data` 即建 channel 条目),故**只需注册 SSH key**、无需任何仓库权限即可触发;`inactivity_timeout` 在持续流式期间不会触发。
163- **影响**:任意认证 SSH 用户单条/少数持续大流即可耗尽共享 git 进程内存(OOM)。
164
165### H8 — 聊天消息幂等键全局无主键 → 跨用户去重抑制 + 消息 ID 泄漏(CWE-639 / CWE-345)
166
167- `chat/chatlayer/storage_bridge.rs:52-85` — key = `chat:msg:{client_msg_id}`,**无 actor 作用域**。
168- `chat/chatlayer/service.rs:193-201` 与 439-447;`chat/glayer/handler/lookups.rs:15-25` — `GET /api/v1/chat/messages/by-client-msg-id`。
169- **攻击**:(a) 攻击者抢注受害者 `client_msg_id` → 受害者消息命中 `AlreadyResolved` 被静默丢弃,还被"告知"已用攻击者消息 UUID 接受;(b) 任意认证用户可确认他人 `client_msg_id` 存在性并拿到解析后的 `server_msg_id`(可枚举 UUID,跨会话存在性 oracle)。
170- **修复**:键加 actor 作用域 `chat:msg:{user_id}:{client_msg_id}`;`AlreadyResolved` 时校验已解析消息属于调用方;lookup 要求所有权。
171- **严重度(交叉验证)**:HIGH 成立——(b) lookup oracle 对任意认证用户无条件成立;(a) 的野外可利用性取决于客户端 `client_msg_id` 是否可预测(客户端生成随机 UUID 时可下调至 Medium–High)。存储层 `require_member` 只作用于 DB 消息持久化,Redis 幂等键与 lookup 路径不走 storagelayer。
172
173### H9 — Live 会议销毁副作用先于鉴权(CWE-285)
174
175- `chat/live/service.rs:211-242` — `end_room` 在**鉴权前**(第 224 行)调用 `self.client.delete_room(...)`(LiveKit admin `DeleteRoom`,踢掉全部参与者);第 225 行 `mark_ended()` 才执行真正的权限检查(`created_by == caller || require_channel_admin`)。
176- 任何该频道活跃空间成员都可销毁会议并断开所有参与者,即使 DB 更新随后因 `PermissionDenied` 失败。
177- **严重度(交叉验证)**:HIGH 处上沿——属瞬时可用性 DoS(可重开会话、无数据损坏),可下调至 Medium–High;对直播型产品仍是实质拒绝服务。
178- **修复**:先读 meet、先执行 `mark_ended`/专用 `require creator-or-admin`,鉴权成功后再调 `client.delete_room`。
179
180### H10 — 所有 HTTP 服务无 TLS,git Basic 认证明文传输密码
181
182- `config/default.yaml:385-386, 399-400` — `git_smart.bind_addr: 0.0.0.0:8080`、`git_ssh.bind_addr: 0.0.0.0:2222`;`app/apihost`/`app/livechat`/`app/gitpro` 均明文 HTTP 绑 0.0.0.0。
183- `dvcs/gitsmart/handler/mod.rs:167-183` — 每个 clone/fetch/push 用 `Authorization: Basic <base64(user:password)>` 认证。仓库内**无任何 TLS**(无 rustls/axum-server/tls-listener,无 TLS 终止部署清单)。
184- 旁路/邻近攻击者可解码每次 git 事务的 base64,批量收割全站密码;Basic 可重放。`auth.cookie_secure: true` 在明文 HTTP 上根本不发送,认证流程也静默失效(除非外部 TLS 代理存在)。
185- **修复**:`git_smart`/`apihost`/`livechat` 前置 TLS(入口或进程内 rustls),生产收紧 bind 到回环/内网,明文 HTTP 拒绝 Basic 认证。
186
187---
188
189## 5. MEDIUM 漏洞详情
190
191| # | 标题 | 文件 | 说明 |
192|---|---|---|---|
193| M1 | 签名密钥明文落库且列名误导 `*_encrypted`,无预置/无轮换 | `lib/jwt/keys.rs:89-93`、`lib/jwt/handlers.rs:111-116` | `private_key_encrypted` 直接喂给 `EncodingKey::from_ed_der`,无解密/KMS;`rotate_keys_handler` 是 TODO;新库无活动密钥 → `NoActiveKey` |
194| M2 | Redis 未编译 TLS、默认 `password: null` | `Cargo.toml:102`、`lib/cache/redis/mod.rs`、`config/default.yaml:53-60` | Redis 承载 presence、access-token blacklist、限流身份、缓存;无认证无 TLS → 可达端口即可读私有状态、清除 `auth:deny:*`(使登出失效)、投毒限流 |
195| M3 | 出口集成默认明文:Kafka/Meilisearch/OpenTelemetry | `lib/queue/{producer,consumer}.rs`、`lib/search/client.rs`、`lib/config/obs.rs`、`lib/obs/otlp.rs` | rdkafka 无 `security.protocol`/`sasl.*`;Meilisearch `http://127.0.0.1:7700` 明文;OTLP 明文带 `Authorization: Bearer`/`Basic` 逐字透传 token |
196| M4 | 默认 `auto_migrate: true` 每次启动跑 DDL | `config/default.yaml:46`、`lib/db/conn.rs:64-65`、`lib/db/schema.rs:14-16` | 违反最小权限;任何被攻破/异常的代码版本可自动改写 schema |
197| M5 | Webhook 分发无 SSRF 防护,secret/payload 明文落库 | `lib/webhook/dispatcher.rs:106-128,314-323`、`config/webhook.rs:33`、`lib/db/webhook/repo_webhooks.rs:14` | 跟随最多 5 个重定向,POST 到攻击者配置的 URL,无私有 IP/元数据 IP 过滤 → 内网扫描;secret 明文;deliveries 持久化完整 payload/响应体 |
198| M6 | API 面缺少进程内请求上限 | `lib/webapi/router.rs`、`gitsmart/handler/{smart,lfs}.rs` | 无 body 上限/超时/并发上限;gitsmart 的 1 GiB / LFS 的 8 GiB 上限本身即内存 DoS |
199| M7 | 登出仅当客户端发送 `Authorization` 头时撤销 access token | `lib/auth/features/logout/usecase.rs:143-160`、`handler/logout.rs:109-116` | 无头(cookie 客户端)只撤销 refresh 会话,无状态 access token 在 TTL 内继续有效 |
200| M8 | 刷新令牌重放窗口(grace_window 默认 5 分钟) | `lib/jwt/rotation.rs:148-156` | 被替换的旧 token 在 grace 窗口内仍可兑换新对;重用检测仅在 grace 之后 |
201| M9 | 陈旧授权声明:`is_admin`/`is_restricted` 烧进令牌且从不复查 | `lib/jwt/extractor.rs:96-108`、`rotation.rs:220-242` | 被降权的 admin 的旧令牌(含副本)最多 15 分钟仍过 `AuthAdmin` |
202| M10 | 签名密钥缓存从未在 web/chat 进程填充;永不重载/轮换;已撤销密钥仍受信任 | `lib/webapi/services.rs:94`(空缓存)、唯一 `load_from_db` 在 `dvcs/gitauth/service.rs:134` | apihost 的 `AuthUser`/`issue_login_pair` 全部 `UnknownKid`/`NoActiveKey` → 身份平面故障;gitpro 一次加载后永不过期校验,`grace.rs` 零调用方 |
203| M11 | PoW HMAC secret 在 API host 硬编码为空,且配置被忽略 | `lib/webapi/services.rs:97`(`PowService::new(PowSettings::default())`,`app.pow` 从不读取)、`config/default.yaml:196-204`、`lib/auth/pow/mod.rs:188-194` | 空 secret → 所有 `verify_solution_once` 失败 → login/register/forgot/reclaim/resend 永不成功(fail-closed 但不可用) |
204| M12 | 聊天 REST 无限流;actor 邮箱与 presence/mention 结构无界 | `chat/glayer/router.rs`、`chat/chatlayer/actor/runtime.rs`(`unbounded_channel`)、`projection/user.rs:124-141` | 合法令牌可无限刷消息/heartbeat(随机 `device_id`)/mentions;Redis zset/list 无界增长 |
205| M13 | 上传 content-type 是黑名单且未校验实际对象 | `chat/chatlayer/media.rs:25-57,225-272`、`chat/storagelayer/.../s3.rs:105-121` | presign 声称 `image/png`,随后 PUT 实际 `text/html`+HTML/JS → 存储源上存储型 XSS 可能(若 S3 域共享 cookie 或加同源 serving 路由则升级) |
206| M14 | Live room/presence 授权按 **space** 粒度而非发起频道 | `chat/storagelayer/mapper/meet.rs`、`chat/storagelayer/auth.rs:72-83,117-145`、`chat/chatlive/service.rs:81-174` | 1:1 私有会话频道所在空间的其他成员可 join/end/presence 获取 LiveKit 令牌,吞噬私有会话边界 |
207| M15 | git 子进程缺少 repo/global 配置隔离(预期防护是死代码) | `dvcs/gitssh/git_service.rs:92-103`(`build_git_command` 从未被调用)、实际 spawn 在 `channel.rs:130-139`/`command.rs:128-135`/`gitsmart/protocol/rpc.rs:33-45` | 若 gitpro 账号 `~/.gitconfig`/`/etc/gitconfig`/仓库 `.git/config` 出现 `core.fsmonitor`/`core.sshCommand`/credential helper,将在 upload-pack/receive-pack 上执行(纵深防御) |
208| M16 | LFS `batch` 下载要求 `Write`(授权映射错配) | `dvcs/gitsmart/handler/lfs.rs:31-35`(batch 对所有操作含 Download 都要求 Write);verify 仅 Read | 只读协作者对 LFS 对象永远 403,无法下载(功能/可用性缺陷,fail-closed) |
209| M17 | OTLP 遥测导出 PII(邮箱/IP) | `lib/auth/features/register/usecase.rs`(`email = %cmd.email`)、`dvcs/gitssh/handler/command.rs`(`client_addr`)、`lib/obs/otlp.rs` | `obs.enabled=true` + `enable_logs: true` 默认开启,`traces_sample_ratio` 默认 1.0,无脱敏层 → 明文邮箱/IP 送达第三方 |
210| M18 | 邮箱在 git commit 中作为数据暴露 | `lib/db/git/git_commits.rs`、`lib/export/repo/git_ref.rs`、`lib/repo/handler/branch/list.rs` | REST 视图把 `author_email`/`committer_email` 返回给有仓库访问的任何用户;配合 H2 可从私有仓库枚举真实邮箱 |
211
212---
213
214## 6. LOW 与潜在问题
215
216| # | 标题 | 文件 | 说明 |
217|---|---|---|---|
218| L1 | git HTTP Basic 无限流/无锁定 | `app/gitpro/src/main.rs`、`dvcs/gitauth/service.rs:112-128` | 每次尝试一次 Argon2 校验是唯一节流;弱密码可暴力 |
219| L2 | Apple OAuth 从 id_token **payload** 读 `kid` 而非 header | `lib/auth/features/oauth/oauth_apple/impls.rs:71-88` | Apple 登录恒失败(功能故障,签名仍校验,不可利用) |
220| L3 | Argon2 用 `Argon2::default()`(OWASP 最低参数) | `lib/auth/features/password_hash/usecase.rs:19,33` | 建议可配置更高 `m_cost`/`t_cost` |
221| L4 | 限流器是客户端 cookie 键控,处处可绕过 | `lib/rate_limit/middleware.rs:31-40,64-70,117-124` | 无 IP 分量;清/轮换 cookie 每请求新配额 → login/forgot/reset/register 无限尝试 |
222| L5 | SSH 认证忽略 `prohibit_login` | `dvcs/gitssh/handler/auth.rs:32` | 被禁止登录用户仍可走 SSH push/pull |
223| L6 | LFS 上传不校验 batch 声明的 size 与实际字节 | `server/lfs.rs:83-106,204-242` | 已由 `enforce_limit` + SHA-256 封顶,声明 size 欺骗配额记账 |
224| L7 | 服务器侧 mirror/import SSRF(潜在)+ askpass helper 卫生 | `dvcs/git/remote/usecase.rs`、`dvcs/git/validate.rs:53-66`、`gitcli.rs:76-105` | 允许 http/https/ssh 无 host/IP 限制(目前未接 HTTP 路由,属潜伏);askpass 写 `/tmp/git-askpass-{pid}-{nonce}.sh` 0600,路径部分可预测 → 本地 TOCTOU |
225| L8 | 默认弱 Postgres 凭据 `untitled/untitled` | `config/default.yaml:35` | 文档化 dev 默认;生产若带上该文件 DB 可猜 |
226| L9 | `pow.hmac_secret: ""` 默认关闭匿名反滥用 | `config/default.yaml:177-200`、`lib/auth/pow/mod.rs:189-201` | 应加配置 `validate()` 空 secret 即大声失败 |
227| L10 | 弱默认凭据占位(LiveKit/payment/search master key) | `config/default.yaml:373-377` | 目前 fail-closed(好);启用 LiveKit 后明文 http base_url 路径重现 |
228| L11 | SSH host key 文件 operator 自备、不强制权限 | `dvcs/gitssh/server.rs:61-70`、`config/git.rs:56` | 缺失即 fail-closed;应 bootstrap 生成 0600 |
229| L12 | `mark_read` 不校验成员 | `chat/chatlayer/service.rs:376-393`、`projection/user.rs:104-123` | 仅影响自身,但可任意设置读状态/删除 unread 计数 |
230| L13 | `.env` 自动加载可静默覆盖生产配置 | `lib/config/loader.rs:76-77` | 生产 CWD 残留 `.env` 即注入覆盖;建议 `--no-dotenv` 开关 |
231| L14 | TOTP secret 字段名 `*_encrypted` 但无加密代码 | `lib/db/user/users.rs` | 未来启用 2FA 若不加真实加密则明文落库 |
232| L15 | 登录/OAuth 账户状态可枚举 | `login/usecase.rs:82-113` | distinct 错误揭示账户存在/状态(PoW 廉价,消息应统一) |
233
234---
235
236## 7. 供应链与依赖审计
237
238### 7.1 cargo audit / cargo deny 结果
239
240| # | CVE/ID | Crate | 版本 → 修复 | 严重度/CVSS | 可达性 | 建议 |
241|---|---|---|---|---|---|---|
242| 1 | RUSTSEC-2026-0235 | **rkyv** | 0.7.46 → **`>=0.8.17`**(需 major 升级,0.7 线无 backport) | OOB 读 | 仅传递性(由 `rust_decimal 1.42.1` 引入,decimal 运算),不反序列化用户输入 | 升级至 0.8 并做兼容回归;勿让不可信二进制进入任何 rkyv 反序列化路径 |
243| 2 | RUSTSEC-2023-0071 | **rsa** | 0.9.10 → **无修复版** | 5.9 Medium · AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N | 经 **`jsonwebtoken 11.0.0`**(`features=["rust_crypto"]`;`lib/jwt/signer.rs:12`、`lib/auth/features/oauth/oauth_apple/usecase.rs:62` 走 RS256 签名/验签)→ **JWT 认证令牌路径**;实际利用需大量时序样本 | 上游无补丁(正在迁移常量时间);位处平台认证面、比 SSH host-key 更敏感,高威胁部署应 wrapper 密钥轮换 / 优先 EdDSA |
244| 3 | RUSTSEC-2023-0071 | **rsa** | 0.10.0-rc.18 → 无修复 | 同上 | 经 `ssh-key 0.7.0-rc.11`(host key 签名)→ **SSH 认证**;实际利用需大量时序样本 | 同 #2(Marvin 时序缓解) |
245| 4 | RUSTSEC-2024-0436(`unmaintained`) | **paste** | 1.0.15 | informational | 仅内省宏,无运行时代码 | 可选换 `pastey` 兼容分支 |
246
247`cargo deny check advisories` 输出:**advisories FAILED**(RUSTSEC-2024-0436 unmaintained + RUSTSEC-2023-0071 ×2)。
248
249> **交叉验证修正(§7.1)**:#2/#3 的 rsa 可达性初版对调——rsa 0.9.10 实际由 `jsonwebtoken 11.0.0`(`features=["rust_crypto"]`)引入,位于 **JWT 签名/Apple OAuth 验签的认证令牌路径**(`lib/jwt/signer.rs:12`、`lib/jwt/keys.rs:77`、`lib/auth/features/oauth/oauth_apple/usecase.rs:62,148`),比 SSH host-key 路径更敏感;rsa 0.10.0-rc.18 才属于 ssh-key → SSH 认证。rkyv「无修复版」措辞修正:`>=0.8.17` 有修复但需 major 升级(0.7 线无 backport)。
250
251### 7.2 版本固定 / 供应链卫生
252
253- **`ssh-key = "=0.7.0-rc.11"`**、**`russh = "=0.62.2"`**:精确固定,已对照 russh 0.62.2 自身 manifest 验证——russh 上游本就依赖 `ssh-key 0.7.0-rc.x`(`ssh-key/rsa`、`ssh-key/dsa` 特性)。**合法生态约束**,非走私向量。
254- OTLP 组 `=0.31.0` / `=0.32.0` / `=0.2.1` 精确固定 — 有代码注释说明兼容集。**合法**。
255- **`reqwest` 双版本**:workspace 0.13.4 + `lib/obs` 0.12(阻塞客户端给 OTLP batch 线程)— 有注释。可维护性瑕疵,非后门。
256- `dotenvy`、`meilisearch-sdk`、`rdkafka` 均为知名 crate。
257- **零** git 源依赖、**零** `[patch]`/`[source]`/`replace-with` 重定向;两个 registry 缓存(crates.io + rsproxy.cn)字节级一致、校验和匹配。
258- **无 build.rs**(全 workspace);`features = "0.10.0"` 声明未使用(Cargo.toml:117)。
259- `cargo machete`:80 处未用依赖(29 个 crate,多为 central-config 迁移遗留),建议清理以缩小攻击面。
260
261---
262
263## 8. 后门与 C2 扫描结论
264
265**整体结论:无后门、无 C2 信标、无混淆、无供应链篡改。**
266
267- **`unsafe`:全库 0 处**(1367 个 `.rs` 文件,`grep -rl` 与 `-rc` 双重验证)。无 `transmute`、`#[no_mangle]`、`extern "C"`、`link_arg`、隐藏 `cfg_attr`、`std::ptr` 转换、长 0x-hex blob、XOR/Blowfish 字节处理。
268- base64/hex 仅用于:JWT 签名+claims、OAuth PKCE/摘要、argon2 密码哈希、OTLP Basic auth、Git LFS、altcha PoW、git SHA 的 `ObjectId::from_hex` — 全部合法。
269- **可执行载荷 / 写后执行**仅 4 处,全部合法:
270 - `dvcs/gitsync/post_receive.rs:30-36` — 写固定常量 `#!/bin/sh` capture hook,唯一插值是常量 `{CAPTURE_FILE}`,**无用户输入插值**。
271 - `dvcs/git/hook/usecase.rs:52` — 执行 hooks 脚本但拒绝绝对路径与 `ParentDir`/`RootDir` 组件。
272 - `dvcs/git/gitcli.rs:86-94` — 写 0600 askpass helper(token 走 env 不经 argv),Drop 删除;标准 git credential-helper 模式。
273 - `gitsmart/protocol/rpc.rs` / `gitssh` — spawn 真实 `git`/`gpg` 静态参数。
274- **网络回调**:所有出站 HTTP 均配置驱动或文档化(Apple/Google/GitHub OAuth、LiveKit RoomService、Airwallex、OpenObserve OTLP、Webhook dispatcher HMAC-SHA256 签名、Meilisearch)。硬编码域名仅 `git-lfs.github.com`、`appleid.apple.com` 等引用。**无硬编码 beacon IP/域名、无 DNS-TXT/lookup 外渗**。
275- **dev/test 混入生产**:`grep dev-dependencies` → 无;`dvcs/codesearch` 是空 stub 占位。
276- **Cargo.lock 完整性**:成员与 manifest 精确匹配(`1.94.0`、resolver "3");lock 历史新增仅为 otel/prost/tonic 栈(commit `d192872`),与 `lib/obs`/`lib/webhook` 接线一致。
277
278---
279
280## 9. 合规与隐私(GDPR/CCPA/ISO 27001)
281
282| 严重度 | 问题 | 条款 | 状态 |
283|---|---|---|---|
284| CRITICAL | 仓库 REST 授权绕过 → 元数据外泄 + 破坏性写 | ISO 27001 A.9.2、GDPR 机密性 | **未修复** |
285| HIGH | `/user/users/{uid}/profile-detail` 暴露真名/出生日期 | GDPR Art. 5(1)(c) 最小化、Art. 6 | **未修复** |
286| HIGH | 无"被遗忘权"实现;"删除"只撤销会话 | GDPR Art. 17(擦除)、Art. 15/20(访问/可携带) | **未实现**(无导出端点、无级联删除、无 `scheduled_for` 处理器) |
287| HIGH | 同意接受记录可伪造(IDOR) | GDPR Art. 7(可归属同意) | **未修复** |
288| MEDIUM | 邮箱/IP 导出到 OpenObserve 遥测 | GDPR Art. 4(1)、跨数据转移无法律基础/DPIA | **未修复** |
289| MEDIUM | git commit 邮箱可枚举 | 隐私泄露 | **未修复** |
290| MEDIUM/LOW | Webhook secret 明文、deliveries 无限期保留 | ISO 27001 A.8.24 / A.10.1.1 | **未修复** |
291
292**已确认的正向控制(无问题)**:
293- 密码:argon2 + 每用户随机盐;密码策略 + `pwd_version` 轮换使 JWT 失效。
294- access token / recovery code:加盐 SHA-256 落库;recovery code 哈希;仅存后 8 位前缀。
295- 会话行不存原始 IP/UA;`last_login_ip_hash` 未设置 — 良好的最小化。
296- 邮箱验证码哈希落库、限流(5 次尝试、60s 发码冷却)、常量时间比对。
297- 聊天存储层对每条消息/会话操作执行 `require_member`。
298- Avatar/attachment/LFS 对象走带签名/授权路径(presigned URL + TTL)。
299- Cookie:`HttpOnly`、`Secure`、`SameSite=Strict`,域名来自配置。
300- OAuth provider 行只存哈希 token + provider 身份。
301- 支付 crate 大部分 stub;Airwallex 客户端在本代码中不存卡号 PAN(DB 模型仅 last4/exp)。
302
303---
304
305## 10. 完整攻击链
306
307### 链 A — 任意仓库接管(远程 → 全写)— **CRITICAL,live**
3081. 任意注册用户 JWT 认证。
3092. `PUT /repo/{channel}/{repo}/collaborators/{self}` → handler(`lib/repo/handler/collab/add.rs:26-33`)→ `add_collaborator` 中 `require_admin(actor)` 仅 nil 检查,任意认证用户通过(`lib/repo/features/collab/usecase.rs:33-36`,调用于 105 行)。
3103. 攻击者设 `mode=admin` → git `receive-pack` 经 write-collab 升级放行(`lib/repo/permission.rs:36-42`)→ 部署完整代码、转移/删除仓库、翻转分支保护、投毒下游消费者。
311
312### 链 B — 私有仓库内容经 SSH 读取 — **HIGH,live**
3131. 任意注册用户添加 SSH key;exec 时 actor 由 `HyperContext::for_user` 构建(`dvcs/gitssh/handler/command.rs:47-50`)。
3142. `for_user` 授予 `RepoRead` → `effective_access` 不经任何成员/可见性检查返回 `Read`(`lib/repo/permission.rs:35-43`)。
3153. `git clone` 任意私有仓库成功;提交与 push 历史(含内嵌 secret)外泄。(HTTP smart 路径因 gitauth 传空 scopes 不受影响。)
316
317### 链 C — 私有仓库 PR/评论/审阅经 REST 读取 — **HIGH,live**
3181. 处理器认证后把 `actor` 直传无视权限的 usecase:`list_pull_requests`(`lib/repo/features/pull_request/usecase.rs:330`)、`get_pull_request`(383)、`list_pull_request_comments`(840)、`list_pull_request_reviews`(913)、`list_pull_request_events`(939)。`let _ = actor;` — 无成员检查。
3192. 任意认证攻击者可枚举任意仓库(含私有)的 PR body/评论/审阅/事件。
320
321### 附赠链 — 私有仓库元数据预认证可枚举
322`get_repo` 仅对私有仓库挡 nil actor,且成员检查缺失(`lib/repo/features/lifecycle/usecase/get.rs:15-20`);`list_collaborators` 不按 actor 收窄 — 为链 A 供料。
323
324---
325
326## 11. 确认已有的防护措施
327
328- **智能 HTTP**:`MAX_RPC_BODY = 1<<30`;gzip 解压炸弹上限 `GzDecoder::take(cap)` + 长度复查;LFS 流式 `enforce_limit`。
329- **LFS**:OID 固定 64 位 hex 校验;存储路径由已校验 OID + UUID 前缀派生 — 无遍历;`put_object`/`put_object_stream` 都强制 SHA-256 vs OID。
330- **SSH**:exec 命令白名单(upload-pack/receive-pack/upload-archive)+ 仓库路径严格字符集(`[a-z0-9._/-]`、≤512、拒绝 `..`/控制字节);`max_auth_attempts=10`、`auth_rejection_time`、`inactivity_timeout`、`keepalive`;`sanitize_for_log` 应用于 SSH exec 命令日志。
331- **HTTP dumb**:`resolve_within_root` 拒绝 `ParentDir` + `canonicalize` 前缀检查。
332- **JWT**:算法白名单仅 EdDSA/ES256/RS256(无 `none`/HS256 混淆);`kid` → 服务器侧 DB key;`exp/iss/aud` 强制;leeway 受配置封顶。
333- **OAuth**:state 32 字节随机绑定 SameSite=Strict cookie + PKCE S256;callback 验证并一次性消费 state;账户绑定要求 provider **已验证**邮箱;JSON 响应 — 无开放重定向。
334- **密码重置**:32 字节 CSPRNG token;重置 bump `pwd_version`、删除会话、deny-list 用户;forgot 流程响应形状恒定 — 无账户枚举。
335- **邮箱验证码**:5 次尝试上限 + 按邮箱发码限流;1M 空间使暴力不可行。
336- **Refresh token**:DB 会话行 + `pwd_version` 检查;撤销/替换会话(grace 外)拒绝。
337- **对象存储**:avatar/attachment/LFS 全部走带签名授权路径(presigned TTL)。
338- **聊天存储层**:`require_member`/`require_admin` 在消息/会话/pin/comment 仓库层强制执行。
339- **智能 HTTP 端点映射正确**:`info/refs` upload-pack→Read、receive-pack→Write、`None`→Read(dumb 也要求认证);`rpc` 要求 `service.required_access()`。
340
341---
342
343## 12. 修复优先级路线图
344
345**跨层根因**:`lib/repo` Web API 不把任何读写决策送过 `effective_access`,用 nil-actor-only 桩检查(`collab`、`contents` `require_write`),且 git 授权助手有优先级缺陷。**先做以下 5 项**:
346
347### P0 — 立即(本周)
3481. **`effective_access` 优先级修复** — 令牌作用域(及 `collaborator_mode` 升级)授予读权限必须要求 `is_public` 或 actor 为空间成员/列出的协作者。一处改动但杠杆极高:直接消除链 B,约束链 A/C。(对应 C3)
3492. **仓库 REST API 鉴权** — 用 `repo::permission::require(actor, ...)` 替换所有 `require_admin`/`require_write` 桩。关闭链 A/C,消除元数据枚举。(对应 C1/H1/H2)
3503. **修复 gitauth 身份混合** — actor 完全从验证后的身份构建;拒绝 `claims.sub ≠ username`。(对应 C2)
3514. **执行推送路径分支/标签保护** — 安装真实 `pre-receive` hook 拦截受保护 ref 的强制更新/删除。(对应 H6)
3525. **补齐 deny-list 覆盖** — 让 `access_blacklist()` 成为必覆写而非默认 `None`,并给 git/SSH/chat 的 verify 路径加 blacklist + `pwd_version` 检查。(对应 H5)
353
354### P1 — 短期(1-2 周)
3556. SSH 通道缓冲设上限 / 流式喂 git stdin;`max_sessions` 信号量真正拒绝超额连接。(CWE-770,对应 H7)
3567. 全服务 TLS 终止 + 生产 bind 收紧到回环/内网;明文 HTTP 拒绝 Basic。(对应 H10)
3578. Issue 层补 channel/space 成员校验;profile 强制 `can_view` + `show_*` 标志。(对应 H3/H4)
3589. 聊天幂等键加 actor 作用域;Live `end_room` 先鉴权后副作用。(对应 H8/H9)
35910. 签名密钥:启动即加载、周期重载、强制 `expires_at`/`grace`/撤销、按算法选 Key 构造器、实现 `rotate_keys_handler`、给 `private_key_encrypted` 做真实信封加密。(对应 M1/M10)
360
361### P2 — 中期(1 个月)
36211. 限流器改 IP 键控(处理受信代理 `X-Forwarded-For`)+ 每账户指数退避;聊天路由加限流层。(对应 L4/M12)
36312. Webhook SSRF 防护(私有 IP 黑名单 + 禁重定向)+ 加密 secret 列 + delivery 保留策略。(对应 M5)
36413. Redis/Kafka/OTLP TLS + 认证;obs 端点 `http://` 带 token 时启动即拒绝。(对应 M2/M3/M17)
36514. 上传改严格白名单 + finalize 时 magic-bytes 嗅探 + 下载强制 `Content-Disposition: attachment` + `nosniff`。(对应 M13)
36615. 生产 `auto_migrate: false` + 迁移门禁;清理 `cargo machete` 未用依赖;加 `deny.toml` + CI audit 门。(对应 M4)
367
368### P3 — 规划中
36916. 实现 GDPR 擦除(用户 + 级联自有数据)+ `scheduled_for` 处理器 + 数据访问/可携带端点;同意绑定 `claims.sub`。(对应合规 §9)
37017. git 远程 mirror/import SSRF 白名单 + known_hosts/StrictHostKeyChecking + `tempfile` O_EXCL askpass;SSH `prohibit_login` 对齐。(对应 L5/L7)
37118. 修复 Apple OAuth `kid` 读取位置;Argon2 参数化增强;`pow` 配置接线 + 空 secret 启动即失败。(对应 L2/L3/M11)
372
373---
374
375## 13. 交叉验证修订记录
376
377> 2026-08-08 修订:独立多 agent 交叉验证——7 个 review agent 逐条核验所有 HIGH/CRITICAL 发现的 `file:line` 引用与攻击路径(每个均主动搜索报告可能遗漏的缓解控制),3 个对抗性反驳 agent 攻击 C1/C2/C3(持怀疑立场、找不到有效防御即默认反驳),外加独立供应链复核(在线 `cargo audit`/`cargo deny` + Cargo.lock/manifest 逐项比对 + registry 缓存 sha256 抽样)。
378
379### 13.1 逐项验证结论
380
381| # | 严重程度 | 交叉验证结论 | 应用修订 |
382|---|---|---|---|
383| C1 | CRITICAL | **CONFIRMED**(对抗反驳未推翻,high 置信) | — |
384| C2 | CRITICAL | **CONFIRMED**(对抗反驳未推翻,high 置信) | 提权点行号 130-132 → **25-27**(§3) |
385| C3 | CRITICAL | **CONFIRMED**(对抗反驳未推翻,high 置信) | — |
386| H1 | HIGH | CONFIRMED | — |
387| H2 | HIGH | CONFIRMED | — |
388| H3 | HIGH | CONFIRMED | — |
389| H4 | HIGH | CONFIRMED | 行号 20-36 → **23-30**;fetch_profile 110-123 → **124-137**(§4) |
390| H5 | HIGH | **PARTIALLY CONFIRMED** | `lib/project` 已正确接线、从受害面移除;缺口=repo/issues(HTTP)+git HTTP/SSH+chat WS;严重度建议 MEDIUM–HIGH(§2/§4) |
391| H6 | HIGH | CONFIRMED | — |
392| H7 | HIGH | CONFIRMED | 补充 `max_sessions` 信号量为死代码、缓冲在 exec 前、仅需注册 key 无需仓库权限(§4) |
393| H8 | HIGH | CONFIRMED((a) 视 `client_msg_id` 可预测性可至 Medium–High;(b) 无条件) | 补严重度说明(§4) |
394| H9 | HIGH | CONFIRMED(瞬时可用性 DoS,可至 Medium–High) | 补严重度说明(§4) |
395| H10 | HIGH | CONFIRMED | — |
396| 供应链 | — | 总体 CONFIRMED | rsa 可达性 #2/#3 对调(rsa 0.9.10 在 JWT 认证路径);rkyv 修复版 ≥0.8.17(需 major);machete 80 处/29 crate(§1/§7) |
397
398### 13.2 未被推翻的关键结论
399
400- **CRITICAL ×3 全部经独立对抗性复核成立**:`permission::require` 在 `lib/repo/features` 零调用(唯一调用点 `dvcs/gitauth/permission.rs:98`)、SSH 作用域短路(`for_user` 无条件授予 `RepoRead`)、gitauth JWT-as-password 身份混合(actor 以攻击者 `user_id` 配他人 `is_site_admin`)均获源码级确认;3 条攻击链(任意仓库删除/接管、SSH 读全库私有仓库、git Basic 提权为站点管理员)的枚举前提(`GET /repos` 泄漏 `channel_id`+`id`)亦复现。
401- **供应链卫生结论成立**:`unsafe` 全库 0 处(1367 个 `.rs` 文件)、零 git 源依赖、零 `[patch]`/`[source]`/`replace-with` 重定向、无 `build.rs`;精确固定(`russh=0.62.2`/`ssh-key=0.7.0-rc.11`/OTLP)经 russh 上游 manifest 核实为生态约束;无后门/C2/混淆结论成立。
402- **CVE 清单在线复核一致**:RUSTSEC-2026-0235(rkyv 0.7.46)、RUSTSEC-2023-0071(rsa 0.9.10 + 0.10.0-rc.18)、RUSTSEC-2024-0436(paste 1.0.15,informational);`cargo deny check advisories` FAILED 复现。
403- **未发现报告漏报的有效缓解**:13 项发现均未在调用链/中间件/配置中找到能阻断攻击的防护;H8 声称的「存储层 `require_member` 严格」属实但只作用于 DB 持久化,不覆盖 Redis 幂等键与 lookup 路径。
404
405---
406
407*报告由 8 个专职安全 agent 并行审计 + 主会话源码复核 + `cargo audit`/`cargo deny` 依赖审计综合生成,并经独立多 agent 交叉验证修订(2026-08-08)。所有 `file:line` 引用均指向本仓库当前源码。*