anvilsign in

collin/anvil

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright (c) 2026 WJQSERVER
6
7use std::io::{
8 BufRead,
9 BufReader,
10 Read,
11};
12use std::path::Path;
13use std::sync::atomic::AtomicBool;
14
15use gix::objs::bstr::BString;
16use gix::prelude::ObjectIdExt;
17use gix::progress::Discard;
18use gix::refs::Target;
19use gix::refs::transaction::{
20 Change,
21 LogChange,
22 PreviousValue,
23 RefEdit,
24 RefLog,
25};
26
27use crate::error::{
28 Error,
29 Result,
30};
31use crate::pktline;
32
33const ZERO_ID: &str = "0000000000000000000000000000000000000000";
34const CAPABILITIES: &str = concat!(
35 "report-status report-status-v2 side-band-64k quiet ofs-delta object-format=sha1 agent=gitserver/",
36 env!("CARGO_PKG_VERSION")
37);
38
39pub fn advertise_receive_refs(repo_path: &Path) -> Result<Vec<u8>> {
40 let repo = gix::open(repo_path)?;
41 let mut out = Vec::new();
42 let head_name = repo.head_name().ok().flatten();
43 let mut refs: Vec<(String, gix::ObjectId)> = repo
44 .references()
45 .map_err(|e| Error::Protocol(format!("failed to open refs: {e}")))?
46 .all()
47 .map_err(|e| Error::Protocol(format!("failed to iterate refs: {e}")))?
48 .flatten()
49 .filter_map(|mut reference| {
50 reference
51 .peel_to_id()
52 .ok()
53 .map(|id| (reference.name().as_bstr().to_string(), id.detach()))
54 })
55 .collect();
56 refs.sort_by(|a, b| a.0.cmp(&b.0));
57
58 if refs.is_empty() {
59 out.extend_from_slice(&pktline::encode(
60 format!("{ZERO_ID} capabilities^{{}}\0{CAPABILITIES}\n").as_bytes(),
61 ));
62 } else {
63 let (first_name, first_id) = &refs[0];
64 let mut first = format!("{} {}\0{CAPABILITIES}", first_id, first_name);
65 if head_name
66 .as_ref()
67 .is_some_and(|head| head.as_bstr() == first_name.as_str())
68 {
69 first.push_str(&format!(" symref=HEAD:{first_name}"));
70 }
71 first.push('\n');
72 out.extend_from_slice(&pktline::encode(first.as_bytes()));
73
74 for (name, id) in refs.into_iter().skip(1) {
75 out.extend_from_slice(&pktline::encode(format!("{id} {name}\n").as_bytes()));
76 }
77 }
78
79 out.extend_from_slice(pktline::flush());
80 Ok(out)
81}
82
83pub fn receive_pack<R: Read>(repo_path: &Path, request: R) -> Result<Vec<u8>> {
84 let interrupt = AtomicBool::new(false);
85 receive_pack_with_interrupt(repo_path, request, &interrupt)
86}
87
88pub fn receive_pack_with_interrupt<R: Read>(
89 repo_path: &Path,
90 request: R,
91 interrupt: &AtomicBool,
92) -> Result<Vec<u8>> {
93 let repo = gix::open(repo_path)?;
94 let mut parsed = parse_request(request, interrupt)?;
95 let status = apply_commands(&repo, repo_path, &mut parsed, interrupt)?;
96 Ok(encode_report_status(&parsed.capabilities, &status))
97}
98
99#[derive(Default)]
100struct ReceivePackCapabilities {
101 report_status: bool,
102 report_status_v2: bool,
103}
104
105struct ReceivePackRequest<R> {
106 commands: Vec<UpdateCommand>,
107 pack: R,
108 capabilities: ReceivePackCapabilities,
109}
110
111struct UpdateCommand {
112 old_id: String,
113 new_id: String,
114 refname: String,
115}
116
117enum CommandStatus {
118 Ok(String),
119 Ng(String, String),
120}
121
122fn parse_request<R: Read>(
123 request: R,
124 interrupt: &AtomicBool,
125) -> Result<ReceivePackRequest<BufReader<R>>> {
126 let mut request = BufReader::new(request);
127 let mut commands = Vec::new();
128 let mut capabilities = ReceivePackCapabilities::default();
129
130 loop {
131 check_interrupt(interrupt)?;
132 let mut prefix = [0u8; 4];
133 match request.read_exact(&mut prefix) {
134 Ok(()) => {}
135 Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => break,
136 Err(err) => return Err(Error::Io(err)),
137 }
138
139 let len_str = std::str::from_utf8(&prefix)
140 .map_err(|_| Error::Protocol("invalid pkt-line length prefix".into()))?;
141
142 if len_str == "0000" {
143 break;
144 }
145
146 let len = usize::from_str_radix(len_str, 16)
147 .map_err(|_| Error::Protocol("invalid pkt-line length".into()))?;
148 if len < 4 {
149 return Err(Error::Protocol("invalid pkt-line frame length".into()));
150 }
151
152 check_interrupt(interrupt)?;
153 let mut payload = vec![0u8; len - 4];
154 request.read_exact(&mut payload)?;
155
156 let (command_bytes, capability_bytes) =
157 if let Some(nul) = payload.iter().position(|b| *b == 0) {
158 (&payload[..nul], Some(&payload[nul + 1..]))
159 } else {
160 (&payload[..], None)
161 };
162
163 if let Some(capability_bytes) = capability_bytes {
164 let capabilities_line = std::str::from_utf8(capability_bytes)
165 .map_err(|_| Error::Protocol("invalid UTF-8 in receive-pack capabilities".into()))?
166 .trim_end_matches('\n');
167 for capability in capabilities_line.split_ascii_whitespace() {
168 match capability {
169 "report-status" => capabilities.report_status = true,
170 "report-status-v2" => {
171 capabilities.report_status = true;
172 capabilities.report_status_v2 = true;
173 }
174 _ => {}
175 }
176 }
177 }
178
179 let line = std::str::from_utf8(command_bytes)
180 .map_err(|_| Error::Protocol("invalid UTF-8 in update command".into()))?
181 .trim_end_matches('\n');
182 let mut parts = line.split_ascii_whitespace();
183 let Some(old_id) = parts.next() else { continue };
184 let Some(new_id) = parts.next() else { continue };
185 let Some(refname) = parts.next() else {
186 continue;
187 };
188
189 commands.push(UpdateCommand {
190 old_id: old_id.to_owned(),
191 new_id: new_id.to_owned(),
192 refname: refname.to_owned(),
193 });
194 }
195
196 Ok(ReceivePackRequest {
197 commands,
198 pack: request,
199 capabilities,
200 })
201}
202
203fn apply_commands<R: BufRead>(
204 repo: &gix::Repository,
205 repo_path: &Path,
206 request: &mut ReceivePackRequest<R>,
207 interrupt: &AtomicBool,
208) -> Result<Vec<CommandStatus>> {
209 check_interrupt(interrupt)?;
210 if request.pack.fill_buf().map(|buf: &[u8]| !buf.is_empty())? {
211 write_pack(repo_path, &mut request.pack, interrupt)?;
212 }
213
214 let mut edits = Vec::with_capacity(request.commands.len());
215 for (index, command) in request.commands.iter().enumerate() {
216 check_interrupt(interrupt)?;
217 match validate_ref_update(repo, command, interrupt) {
218 Ok(edit) => edits.push((command.refname.clone(), edit)),
219 Err(err) => {
220 return Ok(request
221 .commands
222 .iter()
223 .enumerate()
224 .map(|(cmd_index, cmd)| {
225 if cmd_index == index {
226 CommandStatus::Ng(cmd.refname.clone(), err.to_string())
227 } else {
228 CommandStatus::Ng(
229 cmd.refname.clone(),
230 "transaction aborted due to another command failing validation"
231 .into(),
232 )
233 }
234 })
235 .collect());
236 }
237 }
238 }
239
240 check_interrupt(interrupt)?;
241 match repo.edit_references(edits.into_iter().map(|(_, edit)| edit)) {
242 Ok(_) => Ok(request
243 .commands
244 .iter()
245 .map(|cmd| CommandStatus::Ok(cmd.refname.clone()))
246 .collect()),
247 Err(err) => Ok(request
248 .commands
249 .iter()
250 .map(|cmd| CommandStatus::Ng(cmd.refname.clone(), format!("transaction failed: {err}")))
251 .collect()),
252 }
253}
254
255fn write_pack<R: BufRead>(repo_path: &Path, pack: &mut R, interrupt: &AtomicBool) -> Result<()> {
256 let mut progress = Discard;
257 let outcome = gix_pack::Bundle::write_to_directory(
258 pack,
259 Some(repo_path.join("objects/pack").as_path()),
260 &mut progress,
261 interrupt,
262 None::<&gix::Repository>,
263 Default::default(),
264 );
265 if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
266 return Err(Error::Io(std::io::Error::new(
267 std::io::ErrorKind::TimedOut,
268 "receive-pack timed out",
269 )));
270 }
271 let outcome =
272 outcome.map_err(|e| Error::Protocol(format!("failed to write incoming pack: {e}")))?;
273
274 if let Some(keep) = outcome.keep_path {
275 let _ = std::fs::remove_file(keep);
276 }
277 Ok(())
278}
279
280fn check_interrupt(interrupt: &AtomicBool) -> Result<()> {
281 if interrupt.load(std::sync::atomic::Ordering::Relaxed) {
282 Err(Error::Io(std::io::Error::new(
283 std::io::ErrorKind::TimedOut,
284 "receive-pack timed out",
285 )))
286 } else {
287 Ok(())
288 }
289}
290
291fn validate_ref_update(
292 repo: &gix::Repository,
293 command: &UpdateCommand,
294 interrupt: &AtomicBool,
295) -> Result<RefEdit> {
296 if command.new_id == ZERO_ID {
297 return Err(Error::Protocol(format!(
298 "deletion prohibited for {}",
299 command.refname
300 )));
301 }
302
303 let is_branch = command.refname.starts_with("refs/heads/");
304 let is_tag = command.refname.starts_with("refs/tags/");
305 let new_id = gix::ObjectId::from_hex(command.new_id.as_bytes())
306 .map_err(|_| Error::Protocol(format!("invalid new object id: {}", command.new_id)))?;
307 let new_header = repo
308 .find_header(new_id)
309 .map_err(|e| Error::Protocol(format!("missing new object {}: {e}", command.new_id)))?;
310 if is_branch && new_header.kind() != gix::objs::Kind::Commit {
311 return Err(Error::Protocol(format!(
312 "updates to {} must point to a commit",
313 command.refname
314 )));
315 }
316
317 let name: gix::refs::FullName = command
318 .refname
319 .as_str()
320 .try_into()
321 .map_err(|e| Error::Protocol(format!("invalid ref name {}: {e}", command.refname)))?;
322
323 let (expected, log_message) = if command.old_id == ZERO_ID {
324 (PreviousValue::MustNotExist, BString::from("push create"))
325 } else {
326 if is_tag {
327 return Err(Error::Protocol(format!(
328 "updating existing tag {} is not allowed",
329 command.refname
330 )));
331 }
332
333 let old_id = gix::ObjectId::from_hex(command.old_id.as_bytes())
334 .map_err(|_| Error::Protocol(format!("invalid old object id: {}", command.old_id)))?;
335 if is_branch {
336 ensure_fast_forward(repo, old_id, new_id, &command.refname, interrupt)?;
337 }
338 (
339 PreviousValue::MustExistAndMatch(Target::Object(old_id)),
340 BString::from("push"),
341 )
342 };
343
344 Ok(RefEdit {
345 change: Change::Update {
346 log: LogChange {
347 mode: RefLog::AndReference,
348 force_create_reflog: false,
349 message: log_message,
350 },
351 expected,
352 new: Target::Object(new_id),
353 },
354 name,
355 deref: false,
356 })
357}
358
359fn ensure_fast_forward(
360 repo: &gix::Repository,
361 old_id: gix::ObjectId,
362 new_id: gix::ObjectId,
363 refname: &str,
364 interrupt: &AtomicBool,
365) -> Result<()> {
366 check_interrupt(interrupt)?;
367 if old_id == new_id {
368 return Ok(());
369 }
370
371 let old_commit_time = repo
372 .find_object(old_id)
373 .map_err(|e| Error::Protocol(format!("failed to inspect current tip for {refname}: {e}")))?
374 .try_into_commit()
375 .map_err(|_| Error::Protocol(format!("current tip of {refname} is not a commit")))?
376 .committer()
377 .map_err(|e| Error::Protocol(format!("failed to read commit metadata for {refname}: {e}")))?
378 .seconds();
379
380 let ancestors = new_id
381 .attach(repo)
382 .ancestors()
383 .sorting(gix::revision::walk::Sorting::ByCommitTimeCutoff {
384 order: Default::default(),
385 seconds: old_commit_time,
386 })
387 .all()
388 .map_err(|e| Error::Protocol(format!("failed to walk commits for {refname}: {e}")))?;
389
390 for id in ancestors {
391 check_interrupt(interrupt)?;
392 if id.is_ok_and(|commit| commit.id == old_id) {
393 return Ok(());
394 }
395 }
396
397 Err(Error::Protocol(format!(
398 "non-fast-forward update to {refname} is not allowed"
399 )))
400}
401
402fn encode_report_status(
403 capabilities: &ReceivePackCapabilities,
404 statuses: &[CommandStatus],
405) -> Vec<u8> {
406 if !capabilities.report_status {
407 return pktline::flush().to_vec();
408 }
409
410 let mut status_lines = Vec::new();
411 status_lines.extend_from_slice(&pktline::encode(b"unpack ok\n"));
412
413 for status in statuses {
414 match status {
415 CommandStatus::Ok(refname) => {
416 status_lines
417 .extend_from_slice(&pktline::encode(format!("ok {refname}\n").as_bytes()));
418 }
419 CommandStatus::Ng(refname, message) => {
420 status_lines.extend_from_slice(&pktline::encode(
421 format!("ng {refname} {message}\n").as_bytes(),
422 ));
423 }
424 }
425 }
426 status_lines.extend_from_slice(pktline::flush());
427
428 if capabilities.report_status_v2 {
429 let mut sideband = Vec::new();
430 const MAX_BAND_PAYLOAD: usize = 65519;
431 for chunk in status_lines.chunks(MAX_BAND_PAYLOAD) {
432 let len = 4 + 1 + chunk.len();
433 sideband.extend_from_slice(format!("{len:04x}").as_bytes());
434 sideband.push(0x01);
435 sideband.extend_from_slice(chunk);
436 }
437 sideband.extend_from_slice(pktline::flush());
438 sideband
439 } else {
440 status_lines
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use std::process::Command;
447
448 use tempfile::TempDir;
449
450 use super::*;
451
452 fn create_repo_with_commit(root: &std::path::Path) -> std::path::PathBuf {
453 let repo_path = root.join("test.git");
454 let work_dir = root.join("work");
455 std::fs::create_dir(&work_dir).unwrap();
456 Command::new("git")
457 .args(["init", "--bare", repo_path.to_str().unwrap()])
458 .output()
459 .unwrap();
460 Command::new("git")
461 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
462 .current_dir(&repo_path)
463 .output()
464 .unwrap();
465 Command::new("git")
466 .args([
467 "clone",
468 repo_path.to_str().unwrap(),
469 work_dir.to_str().unwrap(),
470 ])
471 .output()
472 .unwrap();
473 Command::new("git")
474 .current_dir(&work_dir)
475 .args(["commit", "--allow-empty", "-m", "init"])
476 .env("GIT_AUTHOR_NAME", "Test")
477 .env("GIT_AUTHOR_EMAIL", "t@t.com")
478 .env("GIT_COMMITTER_NAME", "Test")
479 .env("GIT_COMMITTER_EMAIL", "t@t.com")
480 .output()
481 .unwrap();
482 Command::new("git")
483 .current_dir(&work_dir)
484 .args(["push", "origin", "main"])
485 .output()
486 .unwrap();
487 repo_path
488 }
489
490 #[test]
491 fn advertise_receive_pack_refs() {
492 let root = TempDir::new().unwrap();
493 let repo_path = create_repo_with_commit(root.path());
494 let output = advertise_receive_refs(&repo_path).unwrap();
495 let output_str = String::from_utf8_lossy(&output);
496 assert!(output_str.contains("refs/heads/main"));
497 assert!(output_str.contains("report-status"));
498 }
499
500 #[test]
501 fn parse_receive_pack_request_with_capabilities() {
502 let payload = b"0000000000000000000000000000000000000000 1111111111111111111111111111111111111111 refs/heads/main\0 report-status-v2 side-band-64k\n";
503 let mut body = format!("{:04x}", payload.len() + 4).into_bytes();
504 body.extend_from_slice(payload);
505 body.extend_from_slice(b"0000PACK");
506
507 let interrupt = AtomicBool::new(false);
508 let parsed = parse_request(std::io::Cursor::new(&body), &interrupt).unwrap();
509 assert_eq!(parsed.commands.len(), 1);
510 assert!(parsed.capabilities.report_status);
511 assert!(parsed.capabilities.report_status_v2);
512 let mut pack = String::new();
513 let mut reader = parsed.pack;
514 reader.read_to_string(&mut pack).unwrap();
515 assert_eq!(pack.as_bytes(), b"PACK");
516 }
517
518 #[test]
519 fn branch_updates_require_commit_target() {
520 let root = TempDir::new().unwrap();
521 let repo_path = create_repo_with_commit(root.path());
522 let repo = gix::open(repo_path).unwrap();
523 let tree_id = Command::new("git")
524 .args(["rev-parse", "HEAD^{tree}"])
525 .current_dir(root.path().join("work"))
526 .output()
527 .unwrap();
528 let tree_id = String::from_utf8(tree_id.stdout)
529 .unwrap()
530 .trim()
531 .to_string();
532
533 let err = validate_ref_update(
534 &repo,
535 &UpdateCommand {
536 old_id: ZERO_ID.into(),
537 new_id: tree_id,
538 refname: "refs/heads/feature".into(),
539 },
540 &AtomicBool::new(false),
541 )
542 .unwrap_err();
543
544 assert!(err.to_string().contains("must point to a commit"));
545 }
546
547 #[test]
548 fn ensure_fast_forward_respects_interrupt() {
549 let root = TempDir::new().unwrap();
550 let repo_path = create_repo_with_commit(root.path());
551 let repo = gix::open(repo_path).unwrap();
552 let head = repo.head_id().unwrap().detach();
553 let interrupt = AtomicBool::new(true);
554
555 let err =
556 ensure_fast_forward(&repo, head, head, "refs/heads/main", &interrupt).unwrap_err();
557 match err {
558 Error::Io(inner) => assert_eq!(inner.kind(), std::io::ErrorKind::TimedOut),
559 other => panic!("expected timeout io error, got {other}"),
560 }
561 }
562}