Skip to main content

wsx_core/runtime/
review.rs

1//! Structured executable review protocol. See docs/worktree-review.md.
2//! These types do not grant plugin execution or filesystem authority.
3
4use 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    /// None means the producer cannot determine the number omitted.
124    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
185// ^ docs/worktree-review.md: decoding alone does not validate provider output.
186impl 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                    // Truncation drops whole hunks, never silently incomplete ranges.
258                    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}