Skip to main content

git_zero_rustix/storage/
publication.rs

1//! Verified immutable file downloads for packed dumb HTTP publication.
2use super::{Compare, Region, file_size, pack_error};
3use git_zero::{
4    Error, Repository, Result,
5    http::{
6        Request, Response,
7        dumb::{LooseObjects, Publication, PublishedResponse},
8    },
9    io::{SliceWriter, Write},
10    pack::IndexEntry,
11};
12use rustix::fd::OwnedFd;
13
14/// A complete publication backed by pinned regular-file descriptors. Unlike a
15/// selected-cache entry, its names are the actual checksums of complete pack
16/// bytes. Neither mutable pathnames nor a shared file cursor are retained.
17///
18/// Construction compares BOTH files byte-for-byte with a fresh publication of
19/// this exact repository, including index offsets/CRCs and both trailers. The
20/// supplied files must be the stored encoder's output; arbitrary foreign delta
21/// packs are not accepted here. IDs/graph validation remain the provider's
22/// contract; validate an untrusted repository first.
23///
24/// Open descriptors survive rename/unlink but do not prevent in-place writes.
25/// The host must keep the raw repository AND both files immutable for the URL's
26/// lifetime, publish refs/HEAD and pack/index together only after success, and
27/// isolate each new generation under its own URL. This is not a disk quota,
28/// staging cleanup, filesystem durability or distributed-lock implementation.
29pub struct FilePublication<'a, R> {
30    publication: Publication<'a, R>,
31    pack: Region,
32    index: Region,
33}
34
35impl<'a, R: Repository> FilePublication<'a, R> {
36    pub fn from_fds(
37        repository: &'a R,
38        entries: &'a mut [IndexEntry],
39        pack: OwnedFd,
40        index: OwnedFd,
41        encode_scratch: &mut [u8],
42        compare_scratch: &mut [u8],
43        loose: LooseObjects,
44    ) -> Result<Self> {
45        let pack = Region {
46            size: file_size(&pack).map_err(pack_error)?,
47            fd: pack,
48            offset: 0,
49        };
50        let index = Region {
51            size: file_size(&index).map_err(pack_error)?,
52            fd: index,
53            offset: 0,
54        };
55        let publication = Publication::new(repository, entries, encode_scratch, loose)?;
56        if pack.size != publication.pack_size() || index.size != publication.index_size() {
57            return Err(Error::CorruptPack);
58        }
59        for (file, extension) in [(&pack, b"pack".as_slice()), (&index, b"idx".as_slice())] {
60            let mut bytes = [0; 96];
61            let mut path = SliceWriter::new(&mut bytes);
62            path.write_all(b"objects/pack/pack-")?;
63            path.write_all(publication.checksum().to_hex().as_bytes())?;
64            path.write_all(b".")?;
65            path.write_all(extension)?;
66            let path = core::str::from_utf8(path.as_bytes()).map_err(|_| Error::InvalidRequest)?;
67            let request = Request {
68                method: "GET",
69                path,
70                query: None,
71                body: b"",
72                git_protocol: None,
73                content_type: None,
74            };
75            let mut compare = Compare {
76                file,
77                position: 0,
78                scratch: compare_scratch,
79            };
80            publication.serve(request, encode_scratch, &mut compare)?;
81            if compare.position != file.size {
82                return Err(Error::CorruptPack);
83            }
84        }
85        Ok(Self {
86            publication,
87            pack,
88            index,
89        })
90    }
91
92    pub fn checksum(&self) -> git_zero::ObjectId {
93        self.publication.checksum()
94    }
95    pub fn pack_size(&self) -> u64 {
96        self.pack.size
97    }
98    pub fn index_size(&self) -> u64 {
99        self.index.size
100    }
101
102    /// Same routing/fallthrough as `Publication::prepare`. Successful pack/index
103    /// downloads use only positional reads of the verified files; listing bytes
104    /// remain generated. Every cursor owns its own offset and cancellation state.
105    pub fn prepare(&self, request: Request<'_>) -> Result<Option<FilePublishedResponse<'_, R>>> {
106        let Some(cursor) = self.publication.prepare(request)? else {
107            return Ok(None);
108        };
109        let response = cursor.response();
110        let size = cursor.size();
111        let body = match response.content_type {
112            "application/x-git-packed-objects" => FileBody::File {
113                file: &self.pack,
114                position: 0,
115            },
116            "application/x-git-packed-objects-toc" => FileBody::File {
117                file: &self.index,
118                position: 0,
119            },
120            _ => FileBody::Generated(cursor),
121        };
122        Ok(Some(FilePublishedResponse {
123            response,
124            size,
125            body,
126            cancelled: false,
127        }))
128    }
129}
130
131#[allow(clippy::large_enum_variant)]
132enum FileBody<'a, R> {
133    File { file: &'a Region, position: u64 },
134    Generated(PublishedResponse<'a, R>),
135}
136
137/// A verified file response with independent positional I/O. Keep the publication
138/// alive until completion; never resume after an error or reissue response headers.
139pub struct FilePublishedResponse<'a, R> {
140    response: Response,
141    size: u64,
142    body: FileBody<'a, R>,
143    cancelled: bool,
144}
145impl<R: Repository> FilePublishedResponse<'_, R> {
146    pub fn response(&self) -> Response {
147        self.response
148    }
149    pub fn size(&self) -> u64 {
150        self.size
151    }
152    pub fn cancel(&mut self) {
153        self.cancelled = true;
154    }
155    pub fn read(&mut self, output: &mut [u8]) -> Result<usize> {
156        if self.cancelled {
157            return Err(Error::InvalidRequest);
158        }
159        if output.is_empty() {
160            return Err(Error::BufferTooSmall);
161        }
162        let result = match &mut self.body {
163            FileBody::Generated(cursor) => cursor.read(output),
164            FileBody::File { file, position } => file.read(*position, output).inspect(|&count| {
165                *position += count as u64;
166            }),
167        };
168        if result.is_err() {
169            self.cancel();
170        }
171        result
172    }
173}