Skip to main content

git_zero_server/
dumb.rs

1//! Immutable packed dumb HTTP publication. Pack names use wire checksums, never
2//! selected-cache keys. The host pins the publication's URL and repository.
3use crate::{Request, Response, route};
4use git_zero_core::{
5    Error, ObjectId, Repository, Result,
6    io::Write,
7    pack::{self, IndexEntry},
8};
9
10/// Whether raw loose-object routes remain available alongside the published pack.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum LooseObjects {
13    Enabled,
14    Disabled,
15}
16
17/// One complete stored pack and its matching index, generated from an immutable
18/// repository. Preparation encodes every object once into a counting sink and
19/// retains one caller-owned metadata slot per object, rather than pack bytes.
20/// Downloads replay that deterministic encoding; index downloads use the retained
21/// metadata and never read object bodies. Separate response cursors can overlap.
22///
23/// The repository must satisfy `Repository`'s accurate-ID, complete visible graph
24/// and stability contracts. Use `ValidatedRepository` for untrusted providers.
25/// Keep refs, HEAD, object enumeration, metadata and bodies unchanged throughout
26/// this value's life AND the lifetime of its published URL. A new repository
27/// requires a new publication/URL; replacing a live URL breaks dumb HTTP retries.
28/// Preparation failures publish nothing. A failed response must be discarded or
29/// its transport aborted. This API publishes the stored encoder's full graph;
30/// arbitrary compressed/delta input and selected-pack reuse are separate APIs.
31pub struct Publication<'a, R> {
32    repository: &'a R,
33    entries: &'a [IndexEntry],
34    checksum: ObjectId,
35    pack_size: u64,
36    index_size: u64,
37    loose: LooseObjects,
38    listing: [u8; 80],
39    listing_length: usize,
40}
41
42impl<'a, R: Repository> Publication<'a, R> {
43    pub fn new(
44        repository: &'a R,
45        entries: &'a mut [IndexEntry],
46        scratch: &mut [u8],
47        loose: LooseObjects,
48    ) -> Result<Self> {
49        let mut pack_output = Count(0);
50        let mut index_output = Count(0);
51        let checksum = pack::write_indexed_with_scratch(
52            repository,
53            None,
54            entries,
55            scratch,
56            &mut pack_output,
57            &mut index_output,
58        )?;
59        let hex = checksum.to_hex();
60        let mut listing = [0; 80];
61        let prefix = b"P pack-";
62        listing[..prefix.len()].copy_from_slice(prefix);
63        listing[prefix.len()..prefix.len() + hex.len()].copy_from_slice(&hex);
64        let suffix = b".pack\n\n";
65        let end = prefix.len() + hex.len();
66        listing[end..end + suffix.len()].copy_from_slice(suffix);
67        Ok(Self {
68            repository,
69            entries: &entries[..repository.object_count()],
70            checksum,
71            pack_size: pack_output.0,
72            index_size: index_output.0,
73            loose,
74            listing,
75            listing_length: end + suffix.len(),
76        })
77    }
78
79    pub fn checksum(&self) -> ObjectId {
80        self.checksum
81    }
82    pub fn pack_size(&self) -> u64 {
83        self.pack_size
84    }
85    pub fn index_size(&self) -> u64 {
86        self.index_size
87    }
88
89    /// Dispatch publication routes before ordinary smart/loose serving. `None`
90    /// means the caller must use its normal handler (including authorization).
91    /// Shared route validation applies even to fallthrough requests. Discovery
92    /// and refs still come from this exact immutable repository via that handler.
93    /// Headers may include Content-Length from the cursor. Keep mutable refs and
94    /// discovery uncached; immutable pack/index bytes may be cached by the host.
95    pub fn prepare(&self, request: Request<'_>) -> Result<Option<PublishedResponse<'_, R>>> {
96        let route = route::validate(request)?;
97        if let Some(response) = route.rejection {
98            return Ok(Some(PublishedResponse::new(
99                self.repository,
100                response,
101                Body::Empty,
102                0,
103            )));
104        }
105        let (body, content_type, length) = match route.path {
106            "objects/info/packs" => (
107                Body::Bytes {
108                    bytes: &self.listing[..self.listing_length],
109                    position: 0,
110                },
111                "text/plain; charset=utf-8",
112                self.listing_length as u64,
113            ),
114            path if path.starts_with("objects/pack/") => {
115                let name = &path.as_bytes()[13..];
116                let hex = self.checksum.to_hex();
117                let Some(suffix) = name
118                    .strip_prefix(b"pack-")
119                    .and_then(|name| name.strip_prefix(hex.as_bytes()))
120                else {
121                    return Ok(Some(self.not_found()));
122                };
123                match suffix {
124                    b".pack" => (
125                        Body::Pack(pack::Encoder::new(self.repository, None)?),
126                        "application/x-git-packed-objects",
127                        self.pack_size,
128                    ),
129                    b".idx" => (
130                        Body::Index(pack::IndexEncoder::new(self.entries, self.checksum)?),
131                        "application/x-git-packed-objects-toc",
132                        self.index_size,
133                    ),
134                    _ => return Ok(Some(self.not_found())),
135                }
136            }
137            path if path.starts_with("objects/") && self.loose == LooseObjects::Disabled => {
138                return Ok(Some(self.not_found()));
139            }
140            _ => return Ok(None),
141        };
142        Ok(Some(PublishedResponse::new(
143            self.repository,
144            Response {
145                status: 200,
146                content_type,
147            },
148            body,
149            length,
150        )))
151    }
152
153    fn not_found(&self) -> PublishedResponse<'_, R> {
154        PublishedResponse::new(
155            self.repository,
156            Response {
157                status: 404,
158                content_type: "text/plain; charset=utf-8",
159            },
160            Body::Empty,
161            0,
162        )
163    }
164
165    /// Buffered-writer convenience over the same independent pull cursor. On a
166    /// sink/provider error all output is invalid. No bytes are written on `None`.
167    pub fn serve(
168        &self,
169        request: Request<'_>,
170        scratch: &mut [u8],
171        output: &mut impl Write,
172    ) -> Result<Option<Response>> {
173        let Some(mut cursor) = self.prepare(request)? else {
174            return Ok(None);
175        };
176        loop {
177            let length = cursor.read(scratch)?;
178            if length == 0 {
179                return Ok(Some(cursor.response()));
180            }
181            output.write_all(&scratch[..length])?;
182        }
183    }
184}
185
186struct Count(u64);
187impl Write for Count {
188    fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
189        self.0 = self
190            .0
191            .checked_add(bytes.len() as u64)
192            .ok_or(Error::LimitExceeded)?;
193        Ok(())
194    }
195}
196
197#[allow(clippy::large_enum_variant)]
198enum Body<'a> {
199    Empty,
200    Bytes { bytes: &'a [u8], position: usize },
201    Pack(pack::Encoder<'a>),
202    Index(pack::IndexEncoder<'a>),
203}
204
205/// A publication-bound response: each cursor pins the same repository and entry
206/// metadata. Dropping/cancelling one cursor cannot advance or cancel another.
207pub struct PublishedResponse<'a, R> {
208    repository: &'a R,
209    response: Response,
210    body: Body<'a>,
211    size: u64,
212    cancelled: bool,
213}
214impl<'a, R: Repository> PublishedResponse<'a, R> {
215    fn new(repository: &'a R, response: Response, body: Body<'a>, size: u64) -> Self {
216        Self {
217            repository,
218            response,
219            body,
220            size,
221            cancelled: false,
222        }
223    }
224    pub fn response(&self) -> Response {
225        self.response
226    }
227    pub fn size(&self) -> u64 {
228        self.size
229    }
230    pub fn cancel(&mut self) {
231        self.cancelled = true;
232    }
233
234    /// Zero is clean EOF. An empty buffer is rejected without advancing active
235    /// output. Provider errors terminally cancel the cursor; abort the transport.
236    pub fn read(&mut self, output: &mut [u8]) -> Result<usize> {
237        if self.cancelled {
238            return Err(Error::InvalidRequest);
239        }
240        // Empty output is a caller capacity error, not a provider failure.
241        if output.is_empty() && !matches!(self.body, Body::Empty) {
242            return Err(Error::BufferTooSmall);
243        }
244        let result = match &mut self.body {
245            Body::Empty => Ok(0),
246            Body::Bytes { bytes, position } => {
247                let length = output.len().min(bytes.len() - *position);
248                output[..length].copy_from_slice(&bytes[*position..*position + length]);
249                *position += length;
250                Ok(length)
251            }
252            Body::Pack(encoder) => encoder.read(self.repository, output),
253            Body::Index(encoder) => encoder.read(output),
254        };
255        if result.is_err() {
256            self.cancel();
257        }
258        result
259    }
260}