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
97    // Validate mode
98    let mode = req.mode();
99    if mode == PushMode::Unspecified {
100        return Err(Status::invalid_argument(
101            "mode must be PUSH_MODE_BRANCH or PUSH_MODE_PR",
102        ));
103    }
104
105    // Validate branch_name
106    validate_branch_name(&req.branch_name)?;
107
108    // Validate pr fields when mode is PR
109    if mode == PushMode::Pr && req.pr_title.is_empty() {
110        return Err(Status::invalid_argument(
111            "pr_title is required when mode is PUSH_MODE_PR",
112        ));
113    }
114
115    // Return empty response — the platform wrapper fills in the actual
116    // push results (branch_name, pr_url, commit_hash, changeset_ids).
117    Ok(PushResponse {
118        branch_name: req.branch_name,
119        pr_url: String::new(),
120        commit_hash: String::new(),
121        changeset_ids: vec![],
122    })
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn push_response_fields() {
131        let resp = PushResponse {
132            branch_name: "feat/xyz".to_string(),
133            pr_url: "https://github.com/org/repo/pull/1".to_string(),
134            commit_hash: "abc123".to_string(),
135            changeset_ids: vec!["cs-1".to_string(), "cs-2".to_string()],
136        };
137        assert_eq!(resp.branch_name, "feat/xyz");
138        assert_eq!(resp.changeset_ids.len(), 2);
139    }
140
141    // ── branch_name validation ──
142
143    #[test]
144    fn valid_branch_names() {
145        for name in &[
146            "main",
147            "feat/xyz",
148            "fix/issue-42",
149            "release/v1.0.0",
150            "user/alice/topic",
151        ] {
152            assert!(validate_branch_name(name).is_ok(), "expected ok for {name}");
153        }
154    }
155
156    #[test]
157    fn rejects_empty() {
158        assert!(validate_branch_name("").is_err());
159    }
160
161    #[test]
162    fn rejects_spaces() {
163        assert!(validate_branch_name("feat xyz").is_err());
164    }
165
166    #[test]
167    fn rejects_double_dot() {
168        assert!(validate_branch_name("feat..bar").is_err());
169    }
170
171    #[test]
172    fn rejects_leading_slash() {
173        assert!(validate_branch_name("/feat").is_err());
174    }
175
176    #[test]
177    fn rejects_trailing_slash() {
178        assert!(validate_branch_name("feat/").is_err());
179    }
180
181    #[test]
182    fn rejects_trailing_dot() {
183        assert!(validate_branch_name("feat.").is_err());
184    }
185
186    #[test]
187    fn rejects_leading_dot() {
188        assert!(validate_branch_name(".feat").is_err());
189    }
190
191    #[test]
192    fn rejects_tilde() {
193        assert!(validate_branch_name("feat~1").is_err());
194    }
195
196    #[test]
197    fn rejects_caret() {
198        assert!(validate_branch_name("feat^2").is_err());
199    }
200
201    #[test]
202    fn rejects_colon() {
203        assert!(validate_branch_name("HEAD:path").is_err());
204    }
205
206    #[test]
207    fn rejects_question_mark() {
208        assert!(validate_branch_name("feat?").is_err());
209    }
210
211    #[test]
212    fn rejects_asterisk() {
213        assert!(validate_branch_name("feat*").is_err());
214    }
215
216    #[test]
217    fn rejects_open_bracket() {
218        assert!(validate_branch_name("feat[0]").is_err());
219    }
220
221    #[test]
222    fn rejects_backslash() {
223        assert!(validate_branch_name("feat\\bar").is_err());
224    }
225
226    #[test]
227    fn rejects_at_brace() {
228        assert!(validate_branch_name("feat@{0}").is_err());
229    }
230
231    #[test]
232    fn rejects_control_chars() {
233        assert!(validate_branch_name("feat\x01bar").is_err());
234        assert!(validate_branch_name("feat\x7fbar").is_err());
235    }
236
237    #[test]
238    fn rejects_lock_suffix() {
239        assert!(validate_branch_name("refs/heads/main.lock").is_err());
240        assert!(validate_branch_name("feat.lock/bar").is_err());
241    }
242
243    #[test]
244    fn rejects_consecutive_slashes() {
245        assert!(validate_branch_name("feat//bar").is_err());
246    }
247
248    #[test]
249    fn rejects_hidden_component() {
250        assert!(validate_branch_name("refs/.hidden/branch").is_err());
251    }
252
253    #[test]
254    fn rejects_leading_dash() {
255        assert!(validate_branch_name("-feat").is_err());
256    }
257}