Skip to main content

git_zero_rustix/
storage.rs

1//! Raw object bodies in immutable regular files, read by offset with rustix.
2use core::ffi::CStr;
3#[cfg(feature = "server")]
4use git_zero::http::{self, Request, Response};
5
6use git_zero::{
7    Error, Head, ObjectFormat, ObjectId, ObjectInfo, ObjectKind, Repository, Result, io::Write,
8    pack, repository::validate_refs,
9};
10use rustix::{
11    fd::{AsFd, OwnedFd},
12    fs::{self, FileType, Mode, OFlags},
13    io::{self, Errno},
14};
15
16#[cfg(feature = "server")]
17mod publication;
18#[cfg(feature = "server")]
19pub use publication::{FilePublication, FilePublishedResponse};
20
21/// One object's uncompressed body in an owned file descriptor.
22///
23/// The body may occupy the whole file or a byte range in a shared data file.
24/// Git loose zlib files and compressed pack entries require decoding first.
25/// IDs and kinds come from the caller's manifest; use ValidatedRepository to
26/// verify them and the complete graph. An open descriptor pins the file across
27/// renames, replacement and unlinking, but the host must prevent in-place writes
28/// for the lifetime of the repository and its URL. This is not a disk snapshot.
29pub struct FileObject {
30    file: Region,
31    id: ObjectId,
32    kind: ObjectKind,
33}
34
35impl FileObject {
36    /// Open relative to a directory without allocating a path. The final path
37    /// component must be a regular file rather than a symlink. This is ordinary
38    /// openat, not confinement of arbitrary paths to the supplied directory.
39    pub fn open_at(
40        directory: impl AsFd,
41        path: &CStr,
42        kind: ObjectKind,
43        id: ObjectId,
44    ) -> Result<Self> {
45        let fd = fs::openat(
46            directory,
47            path,
48            OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK,
49            Mode::empty(),
50        )
51        .map_err(|error| {
52            if error == Errno::NOENT {
53                Error::ObjectNotFound
54            } else {
55                Error::Backend
56            }
57        })?;
58        Self::from_fd(fd, kind, id)
59    }
60
61    /// Take ownership of an open regular file; its whole current body is used.
62    pub fn from_fd(fd: OwnedFd, kind: ObjectKind, id: ObjectId) -> Result<Self> {
63        let size = file_size(&fd)?;
64        Self::from_range(fd, 0, ObjectInfo { kind, size }, id)
65    }
66
67    /// Take ownership of an open descriptor and a raw body range. Ranges must
68    /// fit the current file length and signed 64-bit native file offsets.
69    pub fn from_range(fd: OwnedFd, offset: u64, info: ObjectInfo, id: ObjectId) -> Result<Self> {
70        let end = offset.checked_add(info.size).ok_or(Error::InvalidObject)?;
71        if end > i64::MAX as u64 || end > file_size(&fd)? {
72            return Err(Error::InvalidObject);
73        }
74        Ok(Self {
75            file: Region {
76                fd,
77                offset,
78                size: info.size,
79            },
80            id,
81            kind: info.kind,
82        })
83    }
84
85    pub fn id(&self) -> ObjectId {
86        self.id
87    }
88    pub fn info(&self) -> ObjectInfo {
89        ObjectInfo {
90            kind: self.kind,
91            size: self.file.size,
92        }
93    }
94}
95
96/// A caller-owned manifest of immutable file-backed objects and commit aliases.
97///
98/// No filesystem scan, allocation, or object-sized buffer is needed. Keep the
99/// FileObject array alive; each element owns one descriptor. Object enumeration
100/// follows manifest order; lookup is linear unless wrapped in ValidatedRepository.
101/// Construction does not check IDs or graph closure. Those remain Repository
102/// preconditions until checked with the core validator.
103pub struct FileRepository<'a> {
104    objects: &'a [FileObject],
105    head: ObjectId,
106    head_ref: &'a str,
107    detached: bool,
108    refs: &'a [&'a str],
109    prepacked: Option<Region>,
110}
111
112impl<'a> FileRepository<'a> {
113    pub fn new(head: ObjectId, objects: &'a [FileObject]) -> Self {
114        Self {
115            objects,
116            head,
117            head_ref: "refs/heads/main",
118            detached: false,
119            refs: &["refs/heads/main"],
120            prepacked: None,
121        }
122    }
123
124    /// Advertise sorted, unique refs that all alias head, including head_ref.
125    pub fn with_refs(mut self, head_ref: &'a str, refs: &'a [&'a str]) -> Result<Self> {
126        self.head_ref = head_ref;
127        self.detached = false;
128        self.refs = refs;
129        validate_refs(&self)?;
130        Ok(self)
131    }
132
133    /// Serve a direct commit HEAD without a symbolic branch. Refs remain aliases
134    /// of that commit, and may be empty. The graph still needs core validation.
135    pub fn with_detached_head(mut self, refs: &'a [&'a str]) -> Result<Self> {
136        self.detached = true;
137        self.refs = refs;
138        validate_refs(&self)?;
139        Ok(self)
140    }
141
142    /// Attach an immutable, already-generated complete pack in a regular file.
143    /// Compare every byte with pack::write_with_scratch, including framing,
144    /// object closure/order, encoder bytes and checksum, before accepting it.
145    /// Only this encoder's exact output is accepted; arbitrary compressed/delta
146    /// packs remain future work. No pack-sized buffer is needed. Both scratch
147    /// buffers need at least one byte for nonempty streamed data. A mismatch,
148    /// extra/truncated bytes or a nonregular file returns CorruptPack.
149    /// Verification costs one encoding pass; subsequent writes only read the
150    /// pack file. Refs may change afterwards without changing pack identity.
151    pub fn with_prepacked(
152        mut self,
153        fd: OwnedFd,
154        object_scratch: &mut [u8],
155        compare_scratch: &mut [u8],
156    ) -> Result<Self> {
157        let size = file_size(&fd).map_err(pack_error)?;
158        let file = Region {
159            fd,
160            offset: 0,
161            size,
162        };
163        let mut compare = Compare {
164            file: &file,
165            position: 0,
166            scratch: compare_scratch,
167        };
168        pack::write_with_scratch(&self, object_scratch, &mut compare)?;
169        if compare.position != file.size {
170            return Err(Error::CorruptPack);
171        }
172        self.prepacked = Some(file);
173        Ok(self)
174    }
175
176    pub fn has_prepacked(&self) -> bool {
177        self.prepacked.is_some()
178    }
179
180    /// Stream the verified stored pack, or generate one from the raw files.
181    /// Scratch needs at least one byte. Discard partial output on error.
182    pub fn write_pack_with_scratch(
183        &self,
184        scratch: &mut [u8],
185        output: &mut impl Write,
186    ) -> Result<()> {
187        if let Some(file) = &self.prepacked {
188            file.copy_to(scratch, output)
189        } else {
190            pack::write_with_scratch(self, scratch, output).map(|_| ())
191        }
192    }
193
194    /// Core HTTP routes with verified prepacked reuse for upload-pack responses.
195    /// Dumb loose objects continue to be encoded from their raw file bodies.
196    /// Use core http::serve_with_pack to pair this writer with a validated index.
197    #[cfg(feature = "server")]
198    pub fn serve_with_scratch(
199        &self,
200        request: Request<'_>,
201        scratch: &mut [u8],
202        output: &mut impl Write,
203    ) -> Result<Response> {
204        http::serve_with_pack(self, request, scratch, output, |scratch, output| {
205            self.write_pack_with_scratch(scratch, output)
206        })
207    }
208}
209
210impl Repository for FileRepository<'_> {
211    fn object_format(&self) -> ObjectFormat {
212        self.head.format()
213    }
214    fn head(&self) -> ObjectId {
215        self.head
216    }
217    fn head_state(&self) -> Head<'_> {
218        if self.detached {
219            Head::Detached(self.head)
220        } else {
221            Head::Symbolic {
222                target: self.head_ref,
223                id: self.head,
224            }
225        }
226    }
227    fn object_count(&self) -> usize {
228        self.objects.len()
229    }
230    fn object_info(&self, index: usize) -> Result<ObjectInfo> {
231        self.objects
232            .get(index)
233            .map(FileObject::info)
234            .ok_or(Error::ObjectNotFound)
235    }
236    fn object_id(&self, index: usize) -> Option<ObjectId> {
237        self.objects.get(index).map(FileObject::id)
238    }
239    fn read_object(&self, index: usize, offset: u64, storage: &mut [u8]) -> Result<usize> {
240        self.objects
241            .get(index)
242            .ok_or(Error::ObjectNotFound)?
243            .file
244            .read(offset, storage)
245    }
246    fn head_ref(&self) -> &str {
247        self.head_ref
248    }
249    fn reference_count(&self) -> usize {
250        self.refs.len()
251    }
252    fn reference_name(&self, index: usize) -> Option<&str> {
253        self.refs.get(index).copied()
254    }
255}
256
257pub(crate) fn file_size(fd: &impl AsFd) -> Result<u64> {
258    let stat = fs::fstat(fd).map_err(|_| Error::Backend)?;
259    if !FileType::from_raw_mode(stat.st_mode).is_file() {
260        return Err(Error::InvalidObject);
261    }
262    u64::try_from(stat.st_size).map_err(|_| Error::InvalidObject)
263}
264
265fn pack_error(error: Error) -> Error {
266    match error {
267        Error::InvalidObject => Error::CorruptPack,
268        other => other,
269    }
270}
271
272struct Region {
273    fd: OwnedFd,
274    offset: u64,
275    size: u64,
276}
277
278impl Region {
279    fn copy_to(&self, scratch: &mut [u8], output: &mut impl Write) -> Result<()> {
280        let mut offset = 0;
281        while offset < self.size {
282            if scratch.is_empty() {
283                return Err(Error::BufferTooSmall);
284            }
285            let length = self.read(offset, scratch)?;
286            output.write_all(&scratch[..length])?;
287            offset += length as u64;
288        }
289        Ok(())
290    }
291
292    fn read(&self, offset: u64, storage: &mut [u8]) -> Result<usize> {
293        if offset >= self.size || storage.is_empty() {
294            return Ok(0);
295        }
296        let capacity = (self.size - offset).min(storage.len() as u64) as usize;
297        loop {
298            match io::pread(&self.fd, &mut storage[..capacity], self.offset + offset) {
299                Ok(0) => return Err(Error::InvalidObject), // File was truncated after construction.
300                Ok(length) => return Ok(length),
301                Err(Errno::INTR) => continue,
302                Err(_) => return Err(Error::Backend),
303            }
304        }
305    }
306}
307
308struct Compare<'a> {
309    file: &'a Region,
310    position: u64,
311    scratch: &'a mut [u8],
312}
313impl Write for Compare<'_> {
314    fn write_all(&mut self, mut bytes: &[u8]) -> Result<()> {
315        while !bytes.is_empty() {
316            if self.scratch.is_empty() {
317                return Err(Error::BufferTooSmall);
318            }
319            let capacity = self.scratch.len().min(bytes.len());
320            let length = self
321                .file
322                .read(self.position, &mut self.scratch[..capacity])
323                .map_err(pack_error)?;
324            if length == 0 || self.scratch[..length] != bytes[..length] {
325                return Err(Error::CorruptPack);
326            }
327            self.position += length as u64;
328            bytes = &bytes[length..];
329        }
330        Ok(())
331    }
332}