1use tonic::Status;
2
3use crate::server::ProtocolServer;
4use crate::{PushMode, PushRequest, PushResponse};
5
6fn 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 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 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 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 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 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
85pub async fn handle_push(
91 server: &ProtocolServer,
92 req: PushRequest,
93) -> Result<PushResponse, Status> {
94 let _session = server.validate_session(&req.session_id)?;
96
97 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(&req.branch_name)?;
107
108 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 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 #[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}