1use super::WorktreeId;
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8pub const REVIEW_API_VERSION: u32 = 1;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ReviewComparison {
13 WorkingAgainstHead,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ReviewSpec {
18 pub api_version: u32,
19 pub priority: i32,
20 pub comparisons: Vec<ReviewComparison>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ReviewLimits {
25 pub files: usize,
26 pub hunks: usize,
27 pub lines: usize,
28}
29
30impl Default for ReviewLimits {
31 fn default() -> Self {
32 Self {
33 files: 1_000,
34 hunks: 256,
35 lines: 10_000,
36 }
37 }
38}
39
40impl ReviewLimits {
41 pub fn is_valid(&self) -> bool {
42 (1..=1_000).contains(&self.files)
43 && (1..=256).contains(&self.hunks)
44 && (1..=10_000).contains(&self.lines)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ReviewRequest {
50 pub api_version: u32,
51 pub request_id: String,
52 pub worktree_id: WorktreeId,
53 pub worktree_path: PathBuf,
54 pub comparison: ReviewComparison,
55 pub limits: ReviewLimits,
56 #[serde(flatten)]
57 pub operation: ReviewOperation,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(tag = "operation", rename_all = "snake_case")]
62pub enum ReviewOperation {
63 ListFiles,
64 FileDiff { snapshot: String, file_id: String },
65}
66
67impl ReviewRequest {
68 pub fn validate(&self) -> Result<(), &'static str> {
69 if self.api_version != REVIEW_API_VERSION
70 || !self.limits.is_valid()
71 || !token(&self.request_id)
72 {
73 return Err("invalid review request");
74 }
75 if let ReviewOperation::FileDiff { snapshot, file_id } = &self.operation {
76 if !token(snapshot) || !token(file_id) {
77 return Err("invalid review request identity");
78 }
79 }
80 Ok(())
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum ReviewFileStatus {
87 Added,
88 Modified,
89 Deleted,
90 Renamed,
91 Copied,
92 Untracked,
93 Unmerged,
94 TypeChanged,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum ReviewContentKind {
100 Text,
101 Binary,
102 Submodule,
103 Unreadable,
104 Oversized,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ReviewFile {
109 pub file_id: String,
110 pub old_path: Option<String>,
111 pub new_path: Option<String>,
112 pub status: ReviewFileStatus,
113 pub content_kind: ReviewContentKind,
114 pub additions: Option<u64>,
115 pub deletions: Option<u64>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ReviewFileList {
120 pub snapshot: String,
121 pub comparison_label: String,
122 pub files: Vec<ReviewFile>,
123 pub omitted_files: Option<usize>,
125 pub truncated: bool,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct ReviewDiff {
130 pub snapshot: String,
131 pub file_id: String,
132 pub content_kind: ReviewContentKind,
133 pub hunks: Vec<ReviewHunk>,
134 pub truncated: bool,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ReviewHunk {
139 pub old_start: u64,
140 pub old_count: u64,
141 pub new_start: u64,
142 pub new_count: u64,
143 pub heading: String,
144 pub lines: Vec<ReviewLine>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(tag = "kind", content = "text", rename_all = "snake_case")]
149pub enum ReviewLine {
150 Context(String),
151 Addition(String),
152 Deletion(String),
153 NoNewline,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum ReviewErrorCode {
159 Unsupported,
160 StaleSnapshot,
161 Unavailable,
162 InvalidRequest,
163 LimitExceeded,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ReviewResponse {
168 pub api_version: u32,
169 pub request_id: String,
170 #[serde(flatten)]
171 pub result: ReviewResult,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "result", content = "data", rename_all = "snake_case")]
176pub enum ReviewResult {
177 Files(ReviewFileList),
178 Diff(ReviewDiff),
179 Error {
180 code: ReviewErrorCode,
181 message: String,
182 },
183}
184
185impl ReviewResponse {
187 pub fn validate_for(&self, request: &ReviewRequest) -> Result<(), &'static str> {
188 if request.validate().is_err()
189 || self.api_version != REVIEW_API_VERSION
190 || self.request_id != request.request_id
191 {
192 return Err("invalid review envelope");
193 }
194 match (&request.operation, &self.result) {
195 (_, ReviewResult::Error { message, .. }) if text(message) => Ok(()),
196 (ReviewOperation::ListFiles, ReviewResult::Files(list)) => {
197 if !token(&list.snapshot)
198 || !text(&list.comparison_label)
199 || list.files.len() > request.limits.files
200 || (!list.truncated && list.omitted_files != Some(0))
201 {
202 return Err("invalid file list");
203 }
204 let mut ids = std::collections::HashSet::new();
205 for file in &list.files {
206 if !token(&file.file_id)
207 || !ids.insert(&file.file_id)
208 || (file.old_path.is_none() && file.new_path.is_none())
209 || file
210 .old_path
211 .iter()
212 .chain(file.new_path.iter())
213 .any(|p| !relative_path(p))
214 {
215 return Err("invalid file identity or path");
216 }
217 }
218 Ok(())
219 }
220 (ReviewOperation::FileDiff { snapshot, file_id }, ReviewResult::Diff(diff)) => {
221 if !token(snapshot)
222 || !token(file_id)
223 || diff.snapshot != *snapshot
224 || diff.file_id != *file_id
225 || diff.hunks.len() > request.limits.hunks
226 || (diff.content_kind != ReviewContentKind::Text && !diff.hunks.is_empty())
227 {
228 return Err("invalid diff identity or kind");
229 }
230 let mut total = 0usize;
231 for hunk in &diff.hunks {
232 total = total
233 .checked_add(hunk.lines.len())
234 .ok_or("too many lines")?;
235 if total > request.limits.lines
236 || !text(&hunk.heading)
237 || hunk.old_start.checked_add(hunk.old_count).is_none()
238 || hunk.new_start.checked_add(hunk.new_count).is_none()
239 {
240 return Err("invalid hunk bounds");
241 }
242 let (mut old, mut new) = (0u64, 0u64);
243 let mut previous_content = false;
244 for line in &hunk.lines {
245 match line {
246 ReviewLine::Context(value) if line_text(value) => {
247 old += 1;
248 new += 1;
249 }
250 ReviewLine::Addition(value) if line_text(value) => new += 1,
251 ReviewLine::Deletion(value) if line_text(value) => old += 1,
252 ReviewLine::NoNewline if previous_content => {}
253 _ => return Err("invalid diff line"),
254 }
255 previous_content = !matches!(line, ReviewLine::NoNewline);
256 }
257 if old != hunk.old_count || new != hunk.new_count {
259 return Err("hunk ranges do not match lines");
260 }
261 }
262 Ok(())
263 }
264 _ => Err("unexpected review result"),
265 }
266 }
267}
268
269fn text(value: &str) -> bool {
270 value.len() <= 4096 && !value.chars().any(char::is_control)
271}
272
273fn line_text(value: &str) -> bool {
274 value.len() <= 4096 && !value.chars().any(|c| c.is_control() && c != '\t')
275}
276
277fn token(value: &str) -> bool {
278 !value.is_empty() && value.len() <= 512 && text(value)
279}
280
281fn relative_path(value: &str) -> bool {
282 !value.is_empty()
283 && text(value)
284 && std::path::Path::new(value)
285 .components()
286 .all(|part| matches!(part, std::path::Component::Normal(_)))
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn raw_stale_snapshot_is_an_error_not_an_empty_diff() {
295 let response: ReviewResponse = serde_json::from_str(
296 r#"{"api_version":1,"request_id":"r1","result":"error","data":{"code":"stale_snapshot","message":"refresh required"}}"#,
297 ).unwrap();
298 assert!(matches!(
299 response.result,
300 ReviewResult::Error {
301 code: ReviewErrorCode::StaleSnapshot,
302 ..
303 }
304 ));
305 }
306
307 #[test]
308 fn raw_diff_preserves_no_newline_and_zero_length_ranges() {
309 let diff: ReviewDiff = serde_json::from_str(
310 r#"{"snapshot":"s1","file_id":"f1","content_kind":"text","truncated":false,"hunks":[{"old_start":0,"old_count":0,"new_start":1,"new_count":1,"heading":"","lines":[{"kind":"addition","text":"hello"},{"kind":"no_newline"}]}]}"#,
311 ).unwrap();
312 assert_eq!(diff.hunks[0].old_count, 0);
313 assert_eq!(diff.hunks[0].lines[1], ReviewLine::NoNewline);
314 }
315
316 #[test]
317 fn response_validation_rejects_mismatched_identity_and_hunk_counts() {
318 let request = ReviewRequest {
319 api_version: 1,
320 request_id: "r1".into(),
321 worktree_id: WorktreeId(1),
322 worktree_path: "/repo".into(),
323 comparison: ReviewComparison::WorkingAgainstHead,
324 limits: ReviewLimits::default(),
325 operation: ReviewOperation::FileDiff {
326 snapshot: "s1".into(),
327 file_id: "f1".into(),
328 },
329 };
330 let mut response = ReviewResponse {
331 api_version: 1,
332 request_id: "r1".into(),
333 result: ReviewResult::Diff(ReviewDiff {
334 snapshot: "s1".into(),
335 file_id: "f1".into(),
336 content_kind: ReviewContentKind::Text,
337 truncated: false,
338 hunks: vec![ReviewHunk {
339 old_start: 0,
340 old_count: 0,
341 new_start: 1,
342 new_count: 1,
343 heading: String::new(),
344 lines: vec![ReviewLine::Addition("hello".into())],
345 }],
346 }),
347 };
348 assert!(response.validate_for(&request).is_ok());
349 response.request_id = "other".into();
350 assert!(response.validate_for(&request).is_err());
351 response.request_id = "r1".into();
352 if let ReviewResult::Diff(diff) = &mut response.result {
353 diff.hunks[0].new_count = 2;
354 }
355 assert!(response.validate_for(&request).is_err());
356 }
357
358 #[test]
359 fn paths_and_terminal_controls_fail_closed() {
360 for path in [
361 "/etc/passwd",
362 "../secret",
363 "src/../../secret",
364 "bad\u{1b}[31m",
365 "",
366 ] {
367 assert!(!relative_path(path), "{path:?}");
368 }
369 assert!(relative_path("src/a file.rs"));
370 assert!(line_text("\tindent"));
371 assert!(!line_text("escape\u{1b}[31m"));
372 assert!(!line_text("two\nlines"));
373 }
374
375 #[test]
376 fn limits_reject_zero_and_excessive_requests() {
377 assert!(ReviewLimits::default().is_valid());
378 assert!(!ReviewLimits {
379 files: 0,
380 ..ReviewLimits::default()
381 }
382 .is_valid());
383 assert!(!ReviewLimits {
384 hunks: 257,
385 ..ReviewLimits::default()
386 }
387 .is_valid());
388 assert!(!ReviewLimits {
389 lines: 10_001,
390 ..ReviewLimits::default()
391 }
392 .is_valid());
393 }
394
395 #[test]
396 fn requests_reject_controls_and_unbounded_operation_tokens() {
397 let mut request = ReviewRequest {
398 api_version: REVIEW_API_VERSION,
399 request_id: "request-1".into(),
400 worktree_id: WorktreeId(1),
401 worktree_path: "/daemon/resolved".into(),
402 comparison: ReviewComparison::WorkingAgainstHead,
403 limits: ReviewLimits::default(),
404 operation: ReviewOperation::FileDiff {
405 snapshot: "snapshot-1".into(),
406 file_id: "file-1".into(),
407 },
408 };
409 assert!(request.validate().is_ok());
410 request.request_id = "bad\nrequest".into();
411 assert!(request.validate().is_err());
412 request.request_id = "request-1".into();
413 request.operation = ReviewOperation::FileDiff {
414 snapshot: "s".repeat(513),
415 file_id: "file-1".into(),
416 };
417 assert!(request.validate().is_err());
418 }
419}