Skip to main content

dk_protocol/
push.rs

1use tonic::Status;
2
3use crate::server::ProtocolServer;
4use crate::{PushMode, PushRequest, PushResponse};
5
6/// Validate that a branch name conforms to `git check-ref-format` rules.
7///
8/// Returns `Ok(())` if valid, or an `Err(Status::invalid_argument)` describing
9/// the problem.  This is intentionally a subset of the full git rules — the
10/// platform layer runs the real `git check-ref-format`, but catching the most
11/// common mistakes here lets us return a clear gRPC error instead of an opaque
12/// git failure.
13fn validate_branch_name(name: &str) -> Result<(), Status> {
14    if name.is_empty() {
15        return Err(Status::invalid_argument("branch_name is required"));
16    }
17
18    // Single-character checks
19    for &ch in &[' ', '~', '^', ':', '?', '*', '[', '\\'] {
20        if name.contains(ch) {
21            return Err(Status::invalid_argument(format!(
22                "branch_name contains invalid character '{ch}'"
23            )));
24        }
25    }
26
27    // Substring / pattern checks
28    if name.contains("..") {
29        return Err(Status::invalid_argument(
30            "branch_name must not contain '..'",
31        ));
32    }
33    if name.contains("@{") {
34        return Err(Status::invalid_argument(
35            "branch_name must not contain '@{{'",
36        ));
37    }
38
39    // Position checks
40    if name.starts_with('/') || name.ends_with('/') {
41        return Err(Status::invalid_argument(
42            "branch_name must not start or end with '/'",
43        ));
44    }
45    if name.starts_with('.') || name.ends_with('.') {
46        return Err(Status::invalid_argument(
47            "branch_name must not start or end with '.'",
48        ));
49    }
50    if name.starts_with('-') {
51        return Err(Status::invalid_argument(
52            "branch_name must not start with '-'",
53        ));
54    }
55
56    // Control characters (0x00–0x1F) and DEL (0x7F)
57    if name.bytes().any(|b| b < 0x20 || b == 0x7f) {
58        return Err(Status::invalid_argument(
59            "branch_name must not contain control characters",
60        ));
61    }
62
63    // .lock suffix on any path component
64    for component in name.split('/') {
65        if component.ends_with(".lock") {
66            return Err(Status::invalid_argument(
67                "branch_name path component must not end with '.lock'",
68            ));
69        }
70        if component.is_empty() {
71            return Err(Status::invalid_argument(
72                "branch_name must not contain consecutive slashes",
73            ));
74        }
75        if component.starts_with('.') {
76            return Err(Status::invalid_argument(
77                "branch_name path component must not start with '.'",
78            ));
79        }
80    }
81
82    Ok(())
83}
84
85/// Handle a Push request.
86///
87/// The engine's role is lightweight: validate the session exists and return
88/// the repo info. The actual GitHub push (git operations, token handling,
89/// PR creation) happens in the platform layer's gRPC wrapper.
90pub async fn handle_push(
91    server: &ProtocolServer,
92    req: PushRequest,
93) -> Result<PushResponse, Status> {
94    // Validate session
95    let _session = server.validate_session(&req.session_id)?;
96    crate::require_live_session::require_live_session(server, &req.session_id).await?;
97
98    // Validate mode
99    let mode = req.mode();
100    if mode == PushMode::Unspecified {
101        return Err(Status::invalid_argument(
102            "mode must be PUSH_MODE_BRANCH or PUSH_MODE_PR",
103        ));
104    }
105
106    // Validate branch_name
107    validate_branch_name(&req.branch_name)?;
108
109    // Validate pr fields when mode is PR
110    if mode == PushMode::Pr && req.pr_title.is_empty() {
111        return Err(Status::invalid_argument(
112            "pr_title is required when mode is PUSH_MODE_PR",
113        ));
114    }
115
116    // Return empty response — the platform wrapper fills in the actual
117    // push results (branch_name, pr_url, commit_hash, changeset_ids).
118    Ok(PushResponse {
119        branch_name: req.branch_name,
120        pr_url: String::new(),
121        commit_hash: String::new(),
122        changeset_ids: vec![],
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn push_response_fields() {
132        let resp = PushResponse {
133            branch_name: "feat/xyz".to_string(),
134            pr_url: "https://github.com/org/repo/pull/1".to_string(),
135            commit_hash: "abc123".to_string(),
136            changeset_ids: vec!["cs-1".to_string(), "cs-2".to_string()],
137        };
138        assert_eq!(resp.branch_name, "feat/xyz");
139        assert_eq!(resp.changeset_ids.len(), 2);
140    }
141
142    // ── branch_name validation ──
143
144    #[test]
145    fn valid_branch_names() {
146        for name in &[
147            "main",
148            "feat/xyz",
149            "fix/issue-42",
150            "release/v1.0.0",
151            "user/alice/topic",
152        ] {
153            assert!(validate_branch_name(name).is_ok(), "expected ok for {name}");
154        }
155    }
156
157    #[test]
158    fn rejects_empty() {
159        assert!(validate_branch_name("").is_err());
160    }
161
162    #[test]
163    fn rejects_spaces() {
164        assert!(validate_branch_name("feat xyz").is_err());
165    }
166
167    #[test]
168    fn rejects_double_dot() {
169        assert!(validate_branch_name("feat..bar").is_err());
170    }
171
172    #[test]
173    fn rejects_leading_slash() {
174        assert!(validate_branch_name("/feat").is_err());
175    }
176
177    #[test]
178    fn rejects_trailing_slash() {
179        assert!(validate_branch_name("feat/").is_err());
180    }
181
182    #[test]
183    fn rejects_trailing_dot() {
184        assert!(validate_branch_name("feat.").is_err());
185    }
186
187    #[test]
188    fn rejects_leading_dot() {
189        assert!(validate_branch_name(".feat").is_err());
190    }
191
192    #[test]
193    fn rejects_tilde() {
194        assert!(validate_branch_name("feat~1").is_err());
195    }
196
197    #[test]
198    fn rejects_caret() {
199        assert!(validate_branch_name("feat^2").is_err());
200    }
201
202    #[test]
203    fn rejects_colon() {
204        assert!(validate_branch_name("HEAD:path").is_err());
205    }
206
207    #[test]
208    fn rejects_question_mark() {
209        assert!(validate_branch_name("feat?").is_err());
210    }
211
212    #[test]
213    fn rejects_asterisk() {
214        assert!(validate_branch_name("feat*").is_err());
215    }
216
217    #[test]
218    fn rejects_open_bracket() {
219        assert!(validate_branch_name("feat[0]").is_err());
220    }
221
222    #[test]
223    fn rejects_backslash() {
224        assert!(validate_branch_name("feat\\bar").is_err());
225    }
226
227    #[test]
228    fn rejects_at_brace() {
229        assert!(validate_branch_name("feat@{0}").is_err());
230    }
231
232    #[test]
233    fn rejects_control_chars() {
234        assert!(validate_branch_name("feat\x01bar").is_err());
235        assert!(validate_branch_name("feat\x7fbar").is_err());
236    }
237
238    #[test]
239    fn rejects_lock_suffix() {
240        assert!(validate_branch_name("refs/heads/main.lock").is_err());
241        assert!(validate_branch_name("feat.lock/bar").is_err());
242    }
243
244    #[test]
245    fn rejects_consecutive_slashes() {
246        assert!(validate_branch_name("feat//bar").is_err());
247    }
248
249    #[test]
250    fn rejects_hidden_component() {
251        assert!(validate_branch_name("refs/.hidden/branch").is_err());
252    }
253
254    #[test]
255    fn rejects_leading_dash() {
256        assert!(validate_branch_name("-feat").is_err());
257    }
258}