Skip to main content

ephemeris_files/
repository.rs

1use std::collections::BTreeSet;
2use std::io::Read as _;
3use std::os::unix::ffi::OsStrExt as _;
4use std::path::{Path, PathBuf};
5use std::{fs, io, str};
6
7use git2::{Repository as GitRepository, StatusOptions};
8
9use super::entry::{FileEntry, FileMode};
10use super::error::IoResultExt as _;
11use super::path::{RepositoryPath, RepositoryRoot};
12use super::planner::Operation;
13use crate::{FilesError, PathError};
14
15/// How much of a file is read to look for the generated header.
16const LEADING_BYTES: u64 = 256;
17
18pub(crate) struct Repository {
19    git: GitRepository,
20}
21impl Repository {
22    pub(crate) fn open(root: &Path) -> Result<Self, FilesError> {
23        let git = GitRepository::open(root).map_err(|source| FilesError::Open {
24            path: root.to_path_buf(),
25            source,
26        })?;
27        Ok(Self { git })
28    }
29    /// The full path and metadata of `path` below `root`, or `None` when
30    /// nothing exists there. A symlink in a parent directory is an error.
31    fn stat(
32        root: &Path,
33        path: &RepositoryPath,
34    ) -> Result<Option<(PathBuf, fs::Metadata)>, FilesError> {
35        let full = root.join(path.as_path());
36        super::path::ensure_no_symlink_parent(root, &full)?;
37        match full.symlink_metadata() {
38            Ok(metadata) => Ok(Some((full, metadata))),
39            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
40            Err(error) => Err(error).at_path(full),
41        }
42    }
43
44    pub(crate) fn current_entry(
45        root: &Path,
46        path: &RepositoryPath,
47    ) -> Result<Option<FileEntry>, FilesError> {
48        let Some((full, metadata)) = Self::stat(root, path)? else {
49            return Ok(None);
50        };
51        if metadata.file_type().is_symlink() {
52            let target = fs::read_link(&full).at_path(&full)?;
53            return Ok(Some(FileEntry {
54                content: target.as_os_str().as_bytes().to_vec(),
55                mode: FileMode::Symlink,
56            }));
57        }
58        if !metadata.is_file() {
59            return Ok(None);
60        }
61        Ok(Some(FileEntry {
62            content: fs::read(&full).at_path(&full)?,
63            mode: FileMode::from_metadata(&metadata),
64        }))
65    }
66    /// Paths tracked in the index.
67    ///
68    /// Untracked files are never discovered.
69    pub(crate) fn tracked(&self) -> Result<BTreeSet<RepositoryPath>, FilesError> {
70        if self.git.workdir().is_none() {
71            return Err(FilesError::NoWorktree);
72        }
73        let index = self
74            .git
75            .index()
76            .map_err(|source| FilesError::ReadIndex { source })?;
77        index
78            .iter()
79            .map(|entry| Ok(Self::decode_path(&entry.path)?))
80            .collect()
81    }
82
83    /// The first bytes of the file at `path`, empty for anything that is not
84    /// a regular file, or `None` when nothing exists there.
85    pub(crate) fn leading_bytes(
86        root: &Path,
87        path: &RepositoryPath,
88    ) -> Result<Option<Vec<u8>>, FilesError> {
89        let Some((full, metadata)) = Self::stat(root, path)? else {
90            return Ok(None);
91        };
92        let mut leading = Vec::new();
93        if metadata.is_file() {
94            fs::File::open(&full)
95                .and_then(|file| file.take(LEADING_BYTES).read_to_end(&mut leading))
96                .at_path(&full)?;
97        }
98        Ok(Some(leading))
99    }
100
101    /// Regular files that are tracked or untracked and not ignored.
102    pub(crate) fn visible_files(&self) -> Result<BTreeSet<RepositoryPath>, FilesError> {
103        let root = self.git.workdir().ok_or(FilesError::NoWorktree)?;
104        let index = self
105            .git
106            .index()
107            .map_err(|source| FilesError::ReadIndex { source })?;
108        let mut candidates = index
109            .iter()
110            .filter(|entry| FileMode::from_git(entry.mode) != FileMode::Symlink)
111            .map(|entry| Self::decode_path(&entry.path))
112            .collect::<Result<BTreeSet<_>, _>>()?;
113        let mut options = StatusOptions::new();
114        options
115            .include_untracked(true)
116            .recurse_untracked_dirs(true)
117            .include_ignored(false);
118        let statuses = self
119            .git
120            .statuses(Some(&mut options))
121            .map_err(|source| FilesError::ReadStatus { source })?;
122        for entry in statuses.iter().filter(|entry| entry.status().is_wt_new()) {
123            candidates.insert(Self::decode_path(entry.path_bytes())?);
124        }
125        let mut files = BTreeSet::new();
126        for path in candidates {
127            let full = root.join(path.as_path());
128            if full
129                .symlink_metadata()
130                .is_ok_and(|metadata| metadata.is_file())
131            {
132                files.insert(path);
133            }
134        }
135        Ok(files)
136    }
137
138    pub(crate) fn stage(&self, operations: &[Operation]) -> Result<(), FilesError> {
139        let mut index = self
140            .git
141            .index()
142            .map_err(|source| FilesError::ReadIndex { source })?;
143        for operation in operations {
144            match operation {
145                Operation::Create { path, entry: _ } | Operation::Replace { path, entry: _ } => {
146                    index.add_path(path.as_path())
147                }
148                Operation::Delete { path } => index.remove_path(path.as_path()),
149            }
150            .map_err(|source| FilesError::Stage {
151                path: operation.path().clone(),
152                source,
153            })?;
154        }
155        index
156            .write()
157            .map_err(|source| FilesError::WriteIndex { source })
158    }
159
160    pub(crate) fn ensure_recoverable(
161        &self,
162        path: &RepositoryPath,
163        current: &FileEntry,
164    ) -> Result<(), FilesError> {
165        if self.is_recoverable(path, current) {
166            Ok(())
167        } else {
168            Err(FilesError::Unsafe { path: path.clone() })
169        }
170    }
171    pub(crate) fn is_recoverable(&self, path: &RepositoryPath, current: &FileEntry) -> bool {
172        let index_match = self
173            .git
174            .index()
175            .ok()
176            .and_then(|index| index.get_path(path.as_path(), 0))
177            .is_some_and(|entry| {
178                self.git
179                    .find_blob(entry.id)
180                    .is_ok_and(|blob| blob.content() == current.content)
181                    && FileMode::from_git(entry.mode) == current.mode
182            });
183        let head_match = self
184            .git
185            .head()
186            .ok()
187            .and_then(|head| head.peel_to_tree().ok())
188            .and_then(|tree| tree.get_path(path.as_path()).ok())
189            .is_some_and(|entry| {
190                self.git
191                    .find_blob(entry.id())
192                    .is_ok_and(|blob| blob.content() == current.content)
193                    && u32::try_from(entry.filemode())
194                        .is_ok_and(|raw| FileMode::from_git(raw) == current.mode)
195            });
196        index_match || head_match
197    }
198
199    /// Decode a Git-reported path, rejecting non-UTF-8 bytes the same way
200    /// [`RepositoryPath::try_from`] rejects a structurally invalid path.
201    fn decode_path(bytes: &[u8]) -> Result<RepositoryPath, PathError> {
202        match str::from_utf8(bytes) {
203            Ok(text) => RepositoryPath::try_from(text),
204            Err(_utf8_error) => Err(PathError::Invalid {
205                path: String::from_utf8_lossy(bytes).into_owned(),
206            }),
207        }
208    }
209}
210
211impl RepositoryRoot {
212    /// The content of the file at `path`, or `None` when nothing exists
213    /// there.
214    ///
215    /// # Errors
216    /// Returns an error when a parent or the file itself is a symlink or the
217    /// file cannot be read.
218    pub fn read(&self, path: &RepositoryPath) -> Result<Option<Vec<u8>>, FilesError> {
219        let Some((full, metadata)) = Repository::stat(self.as_path(), path)? else {
220            return Ok(None);
221        };
222        if metadata.file_type().is_symlink() {
223            return Err(PathError::Symlink(full).into());
224        }
225        match fs::read(&full) {
226            Ok(content) => Ok(Some(content)),
227            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
228            Err(error) => Err(error).at_path(full),
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::fs;
236    #[cfg(unix)]
237    use std::os::unix::fs::symlink;
238
239    use super::{RepositoryPath, RepositoryRoot};
240
241    #[test]
242    fn read_returns_the_content_or_none_for_a_missing_file() {
243        let directory = tempfile::tempdir().unwrap();
244        fs::write(directory.path().join("present"), "content").unwrap();
245        let root = RepositoryRoot::new(directory.path()).unwrap();
246        let read = |path: &str| root.read(&RepositoryPath::try_from(path).unwrap());
247        assert_eq!(read("present").unwrap(), Some(b"content".to_vec()));
248        assert_eq!(read("absent").unwrap(), None);
249        assert_eq!(read("absent/nested").unwrap(), None);
250    }
251
252    #[cfg(unix)]
253    #[test]
254    fn read_refuses_a_symlink() {
255        let directory = tempfile::tempdir().unwrap();
256        fs::write(directory.path().join("target"), "content").unwrap();
257        symlink("target", directory.path().join("link")).unwrap();
258        let root = RepositoryRoot::new(directory.path()).unwrap();
259        assert!(
260            root.read(&RepositoryPath::try_from("link").unwrap())
261                .is_err()
262        );
263    }
264}