hashtree_git/
http.rs

1//! Git smart HTTP protocol handlers
2//!
3//! Implements:
4//! - GET  /info/refs?service=git-upload-pack
5//! - GET  /info/refs?service=git-receive-pack
6//! - POST /git-upload-pack
7//! - POST /git-receive-pack
8
9use crate::object::ObjectId;
10use crate::pack::{PackBuilder, parse_packfile};
11use crate::protocol::*;
12use crate::refs::Ref;
13use crate::storage::GitStorage;
14use crate::{Error, Result};
15
16/// Service types
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Service {
19    UploadPack,
20    ReceivePack,
21}
22
23impl Service {
24    pub fn from_str(s: &str) -> Option<Self> {
25        match s {
26            "git-upload-pack" => Some(Service::UploadPack),
27            "git-receive-pack" => Some(Service::ReceivePack),
28            _ => None,
29        }
30    }
31
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            Service::UploadPack => "git-upload-pack",
35            Service::ReceivePack => "git-receive-pack",
36        }
37    }
38
39    pub fn content_type(&self) -> &'static str {
40        match self {
41            Service::UploadPack => "application/x-git-upload-pack-advertisement",
42            Service::ReceivePack => "application/x-git-receive-pack-advertisement",
43        }
44    }
45
46    pub fn result_content_type(&self) -> &'static str {
47        match self {
48            Service::UploadPack => "application/x-git-upload-pack-result",
49            Service::ReceivePack => "application/x-git-receive-pack-result",
50        }
51    }
52}
53
54/// Handle GET /info/refs?service=git-upload-pack or git-receive-pack
55pub fn handle_info_refs(storage: &GitStorage, service: Service) -> Result<(String, Vec<u8>)> {
56    let mut writer = PktLineWriter::new();
57
58    // Service announcement
59    writer.write_str(&format!("# service={}", service.as_str()));
60    writer.flush();
61
62    // Get all refs
63    let refs = storage.list_refs()?;
64
65    // Capabilities string for first ref
66    let caps = match service {
67        Service::UploadPack => format_capabilities(UPLOAD_PACK_CAPABILITIES),
68        Service::ReceivePack => format_capabilities(RECEIVE_PACK_CAPABILITIES),
69    };
70
71    if refs.is_empty() {
72        // Empty repo - advertise capabilities with zero-id
73        let zero = ObjectId::ZERO;
74        writer.write_str(&format!("{} capabilities^{{}}\0{}", zero, caps));
75    } else {
76        let mut first = true;
77        // HEAD first if it exists
78        if let Ok(head_oid) = storage.resolve_ref("HEAD") {
79            if first {
80                writer.write_str(&format!("{} HEAD\0{}", head_oid, caps));
81                first = false;
82            } else {
83                writer.write_str(&format!("{} HEAD", head_oid));
84            }
85        }
86
87        // All other refs
88        for named_ref in &refs {
89            if named_ref.name == "HEAD" {
90                continue;
91            }
92            if let Ok(oid) = storage.resolve_ref(&named_ref.name) {
93                if first {
94                    writer.write_str(&format!("{} {}\0{}", oid, named_ref.name, caps));
95                    first = false;
96                } else {
97                    writer.write_str(&format!("{} {}", oid, named_ref.name));
98                }
99            }
100        }
101    }
102
103    writer.flush();
104    Ok((service.content_type().to_string(), writer.into_bytes()))
105}
106
107/// Handle POST /git-upload-pack (client wants to fetch)
108pub fn handle_upload_pack(storage: &GitStorage, body: &[u8]) -> Result<Vec<u8>> {
109    let mut reader = PktLineReader::new(body);
110    let mut wants = Vec::new();
111    let mut haves = Vec::new();
112    let mut _done = false;
113
114    // Parse want/have lines
115    while let Some(pkt) = reader.read()? {
116        match pkt {
117            PktLine::Flush => break,
118            PktLine::Data(data) => {
119                let line = std::str::from_utf8(data)
120                    .map_err(|_| Error::ProtocolError("invalid utf8".into()))?
121                    .trim();
122
123                if let Some(rest) = line.strip_prefix("want ") {
124                    let oid_hex = rest.split(' ').next().unwrap_or(rest);
125                    if let Some(oid) = ObjectId::from_hex(oid_hex) {
126                        wants.push(oid);
127                    }
128                } else if let Some(oid_hex) = line.strip_prefix("have ") {
129                    if let Some(oid) = ObjectId::from_hex(oid_hex.trim()) {
130                        haves.push(oid);
131                    }
132                } else if line == "done" {
133                    _done = true;
134                }
135                // Note: "done" is parsed by protocol but not used in this implementation
136            }
137            _ => {}
138        }
139    }
140
141    // Check for additional "have" lines in remaining data after flush
142    let remaining = reader.remaining();
143    if !remaining.is_empty() {
144        let mut reader2 = PktLineReader::new(remaining);
145        while let Some(pkt) = reader2.read()? {
146            if let PktLine::Data(data) = pkt {
147                let line = std::str::from_utf8(data).unwrap_or("").trim();
148                if line == "done" {
149                    _done = true;
150                } else if let Some(oid_hex) = line.strip_prefix("have ") {
151                    if let Some(oid) = ObjectId::from_hex(oid_hex.trim()) {
152                        haves.push(oid);
153                    }
154                }
155            }
156        }
157    }
158
159    let mut response = PktLineWriter::new();
160
161    if wants.is_empty() {
162        // Nothing to send
163        response.write_str("NAK");
164        response.flush();
165        return Ok(response.into_bytes());
166    }
167
168    // For multi_ack_detailed, we need to ACK common commits
169    // and send "ready" when we have enough in common
170    let mut common_commits = Vec::new();
171    for have in &haves {
172        if storage.has_object(have)? {
173            common_commits.push(*have);
174        }
175    }
176
177    // ACK phase for multi_ack_detailed
178    if !common_commits.is_empty() {
179        for oid in &common_commits {
180            response.write_str(&format!("ACK {} common", oid));
181        }
182        // Send ready to indicate we're done negotiating
183        if let Some(last) = common_commits.last() {
184            response.write_str(&format!("ACK {} ready", last));
185        }
186    }
187
188    // Final NAK to signal end of ACK phase
189    response.write_str("NAK");
190
191    // Build and send packfile
192    let mut builder = PackBuilder::new(storage);
193    for oid in wants {
194        builder.want(oid);
195    }
196    for oid in common_commits {
197        builder.have(oid);
198    }
199
200    let pack = builder.build()?;
201
202    // Send packfile with sideband-64k
203    const CHUNK_SIZE: usize = 65515; // Leave room for sideband byte
204
205    for chunk in pack.chunks(CHUNK_SIZE) {
206        response.write_raw(&sideband_pkt(sideband::DATA, chunk));
207    }
208
209    // Send flush to signal end of packfile
210    response.flush();
211
212    Ok(response.into_bytes())
213}
214
215/// Handle POST /git-receive-pack (client wants to push)
216pub fn handle_receive_pack(storage: &GitStorage, body: &[u8]) -> Result<Vec<u8>> {
217    let mut reader = PktLineReader::new(body);
218    let mut commands = Vec::new();
219    let mut use_sideband = false;
220
221    // Parse ref update commands
222    while let Some(pkt) = reader.read()? {
223        match pkt {
224            PktLine::Flush => break,
225            PktLine::Data(data) => {
226                let line = std::str::from_utf8(data)
227                    .map_err(|_| Error::ProtocolError("invalid utf8".into()))?
228                    .trim();
229
230                // Format: <old-oid> <new-oid> <ref-name>\0<caps>
231                let parts: Vec<&str> = line.splitn(3, ' ').collect();
232                if parts.len() >= 3 {
233                    let old_oid = if parts[0] == ObjectId::ZERO.to_hex() {
234                        None
235                    } else {
236                        ObjectId::from_hex(parts[0])
237                    };
238                    let new_oid = if parts[1] == ObjectId::ZERO.to_hex() {
239                        None
240                    } else {
241                        ObjectId::from_hex(parts[1])
242                    };
243                    // Strip capabilities from ref name
244                    let ref_and_caps = parts[2];
245                    let (ref_name, caps) = ref_and_caps.split_once('\0')
246                        .map(|(r, c)| (r.to_string(), Some(c)))
247                        .unwrap_or_else(|| (ref_and_caps.to_string(), None));
248
249                    // Check for sideband capability
250                    if let Some(caps_str) = caps {
251                        if caps_str.contains("side-band-64k") || caps_str.contains("side-band") {
252                            use_sideband = true;
253                        }
254                    }
255
256                    commands.push(RefCommand { old_oid, new_oid, ref_name });
257                }
258            }
259            _ => {}
260        }
261    }
262
263    // Parse packfile from remaining data
264    let pack_data = reader.remaining();
265    if !pack_data.is_empty() {
266        // Store objects from packfile
267        parse_packfile(storage, pack_data)?;
268    }
269
270    // Apply ref updates and build report
271    let mut report = Vec::new();
272    report.push("unpack ok\n".to_string());
273
274    for cmd in &commands {
275        let result = apply_ref_command(storage, cmd);
276        match result {
277            Ok(()) => {
278                report.push(format!("ok {}\n", cmd.ref_name));
279            }
280            Err(e) => {
281                report.push(format!("ng {} {}\n", cmd.ref_name, e));
282            }
283        }
284    }
285
286    let mut response = PktLineWriter::new();
287
288    if use_sideband {
289        // When sideband is negotiated, send status via sideband channel 1
290        // Build the report as pkt-lines first
291        let mut report_pkt = PktLineWriter::new();
292        for line in &report {
293            report_pkt.write_str(line.trim());
294        }
295        report_pkt.flush();
296
297        // Send the whole report via sideband
298        let report_bytes = report_pkt.into_bytes();
299        response.write_raw(&sideband_pkt(sideband::DATA, &report_bytes));
300    } else {
301        // Plain pkt-lines
302        for line in &report {
303            response.write_str(line.trim());
304        }
305    }
306
307    response.flush();
308    Ok(response.into_bytes())
309}
310
311/// A ref update command
312#[derive(Debug)]
313struct RefCommand {
314    old_oid: Option<ObjectId>,
315    new_oid: Option<ObjectId>,
316    ref_name: String,
317}
318
319/// Apply a ref update command
320fn apply_ref_command(storage: &GitStorage, cmd: &RefCommand) -> Result<()> {
321    match (&cmd.old_oid, &cmd.new_oid) {
322        (None, Some(new)) => {
323            // Create new ref
324            storage.write_ref(&cmd.ref_name, &Ref::Direct(*new))?;
325        }
326        (Some(_old), Some(new)) => {
327            // Update ref (we skip CAS check for simplicity)
328            storage.write_ref(&cmd.ref_name, &Ref::Direct(*new))?;
329        }
330        (Some(_old), None) => {
331            // Delete ref
332            storage.delete_ref(&cmd.ref_name)?;
333        }
334        (None, None) => {
335            // No-op
336        }
337    }
338    Ok(())
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use tempfile::tempdir;
345
346    #[test]
347    fn test_info_refs_empty_repo() {
348        let dir = tempdir().unwrap();
349        let storage = GitStorage::open(dir.path().join("git")).unwrap();
350
351        let (content_type, body) = handle_info_refs(&storage, Service::UploadPack).unwrap();
352        assert_eq!(content_type, "application/x-git-upload-pack-advertisement");
353
354        let body_str = String::from_utf8_lossy(&body);
355        assert!(body_str.contains("# service=git-upload-pack"));
356        assert!(body_str.contains("capabilities^{}"));
357    }
358
359    #[test]
360    fn test_info_refs_with_ref() {
361        let dir = tempdir().unwrap();
362        let storage = GitStorage::open(dir.path().join("git")).unwrap();
363
364        // Create a commit
365        let tree_content = b"";
366        let tree_oid = storage.write_tree(tree_content).unwrap();
367
368        let commit_content = format!(
369            "tree {}\nauthor Test <test@test.com> 1234567890 +0000\ncommitter Test <test@test.com> 1234567890 +0000\n\nInitial commit\n",
370            tree_oid
371        );
372        let commit_oid = storage.write_commit(commit_content.as_bytes()).unwrap();
373
374        // Create ref
375        storage.write_ref("refs/heads/main", &Ref::Direct(commit_oid)).unwrap();
376        storage.write_ref("HEAD", &Ref::Symbolic("refs/heads/main".into())).unwrap();
377
378        let (_, body) = handle_info_refs(&storage, Service::UploadPack).unwrap();
379        let body_str = String::from_utf8_lossy(&body);
380
381        assert!(body_str.contains(&commit_oid.to_hex()));
382        assert!(body_str.contains("refs/heads/main"));
383    }
384}