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 crate::require_live_session::require_live_session(server, &req.session_id).await?;
97
98 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(&req.branch_name)?;
108
109 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 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 #[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}