shackle_shell/vcs/
git.rs

1use crate::{
2    parser::{GitReceivePackArgs, GitUploadPackArgs},
3    user_info::{get_gid, get_user_groups, get_username},
4    ShackleError,
5};
6use git2::{ErrorCode, Repository, RepositoryInitMode, RepositoryInitOptions};
7use std::{
8    fs,
9    os::unix::fs::PermissionsExt,
10    path::{Path, PathBuf},
11    process::Command,
12};
13
14pub struct GitInitResult {
15    pub path: PathBuf,
16}
17
18fn git_dir_prefix() -> PathBuf {
19    PathBuf::from("git")
20}
21
22fn personal_git_dir() -> Result<PathBuf, ShackleError> {
23    let username = get_username().ok_or(ShackleError::UserReadError)?;
24    Ok(git_dir_prefix().join(username))
25}
26
27fn verify_user_is_in_group(group: &str) -> bool {
28    let user_groups = get_user_groups();
29    user_groups.iter().any(|g| g == group)
30}
31
32fn group_git_dir(group: &str) -> PathBuf {
33    git_dir_prefix().join(group)
34}
35
36fn is_valid_git_repo_path(path: &Path) -> Result<bool, ShackleError> {
37    let prefix = git_dir_prefix();
38    let relative_path = match path.strip_prefix(&prefix) {
39        Ok(relative_path) => relative_path,
40        Err(_) => {
41            return Ok(false);
42        }
43    };
44
45    let mut it = relative_path.iter();
46    let group = it.next();
47    let repo_name = it.next();
48    let end = it.next();
49
50    match (group, repo_name, end) {
51        (_, _, Some(_)) | (None, _, _) | (_, None, _) => Ok(false),
52        (Some(group_name), Some(_repo_name), _) => {
53            if relative_path.extension().map(|ext| ext == "git") != Some(true) {
54                Ok(false)
55            } else {
56                let group_name = group_name.to_string_lossy();
57
58                let user_name = get_username();
59                let is_valid_personal_repo_path = user_name
60                    .map(|user_name| user_name == group_name)
61                    .unwrap_or(false);
62
63                let user_groups = get_user_groups();
64                let is_valid_shared_repo_path =
65                    user_groups.iter().any(|group| group.as_str() == group_name);
66
67                Ok(is_valid_personal_repo_path || is_valid_shared_repo_path)
68            }
69        }
70    }
71}
72
73pub fn init(
74    repo_name: &str,
75    group: &Option<String>,
76    description: &Option<String>,
77    branch: &str,
78    mirror: &Option<String>,
79) -> Result<GitInitResult, ShackleError> {
80    if let Some(group) = &group {
81        if !verify_user_is_in_group(group) {
82            return Err(ShackleError::InvalidGroup);
83        }
84    }
85
86    let git_prefix = git_dir_prefix();
87    let collection_dir = match group {
88        Some(group) => group_git_dir(group),
89        None => personal_git_dir()?,
90    };
91    let path = collection_dir.join(repo_name).with_extension("git");
92
93    if !git_prefix.is_dir() {
94        fs::create_dir(&git_prefix)?;
95    }
96
97    if !collection_dir.is_dir() {
98        fs::create_dir(&collection_dir)?;
99
100        if let Some(group) = group {
101            let gid = get_gid(group).expect("User is in group but no group ID?");
102            nix::unistd::chown(&collection_dir, None, Some(gid))?;
103        }
104
105        let mut perms = collection_dir.metadata()?.permissions();
106        perms.set_mode(match group {
107            Some(_) => 0o2770,
108            None => 0o700,
109        });
110        fs::set_permissions(&collection_dir, perms)?;
111    }
112
113    let mut init_opts = RepositoryInitOptions::new();
114    init_opts
115        .bare(true)
116        .mkdir(false)
117        .no_reinit(true)
118        .initial_head(branch);
119    if group.is_some() {
120        init_opts.mode(RepositoryInitMode::SHARED_GROUP);
121    }
122
123    let repo = Repository::init_opts(&path, &init_opts)?;
124
125    if let Some(description) = description {
126        // There is an init option for setting the description but it seems to
127        // just do nothing?
128        set_description(&path, description)?;
129    }
130
131    if let Some(mirror) = mirror {
132        if description.is_none() {
133            set_description(&path, &format!("Mirror of {}", mirror))?;
134        }
135
136        repo.remote_with_fetch("origin", mirror, "+refs/*:refs/*")?;
137        let mut config = repo.config()?;
138        config.set_bool("remote.origin.mirror", true)?;
139
140        Command::new("git")
141            .arg("remote")
142            .arg("update")
143            .arg("--prune")
144            .current_dir(&path)
145            .spawn()?
146            .wait()?;
147    }
148
149    Ok(GitInitResult { path })
150}
151
152pub struct RepoMetadata {
153    pub path: PathBuf,
154    pub description: String,
155}
156
157pub struct VerboseRepoMetadata {
158    pub path: PathBuf,
159    pub description: String,
160    pub size: u64,
161}
162
163fn get_size(path: impl AsRef<Path>) -> Result<u64, ShackleError> {
164    let path_metadata = path.as_ref().symlink_metadata()?;
165
166    if path_metadata.is_dir() {
167        let mut size_in_bytes = path_metadata.len();
168        for entry in path.as_ref().read_dir()? {
169            let entry = entry?;
170            let entry_metadata = entry.metadata()?;
171
172            if entry_metadata.is_dir() {
173                size_in_bytes += get_size(entry.path())?;
174            } else {
175                size_in_bytes += entry_metadata.len();
176            }
177        }
178        Ok(size_in_bytes)
179    } else {
180        Ok(path_metadata.len())
181    }
182}
183
184pub fn list() -> Result<Vec<RepoMetadata>, ShackleError> {
185    fn add_from_dir(
186        collection_dir: &Path,
187        is_checking_group: bool,
188    ) -> Result<Vec<RepoMetadata>, ShackleError> {
189        let mut results = Vec::new();
190        if !collection_dir.is_dir() {
191            return Ok(results);
192        }
193
194        for dir in collection_dir.read_dir()? {
195            let path = dir?.path();
196            let description_path = path.join("description");
197            let has_git_ext = path.extension().is_some_and(|ext| ext == "git");
198
199            if has_git_ext {
200                if let Ok(repo) = Repository::open_bare(&path) {
201                    let config = repo.config()?.snapshot()?;
202                    let shared_config = config.get_str("core.sharedRepository").or_else(|e| {
203                        if e.code() == ErrorCode::NotFound {
204                            Ok("")
205                        } else {
206                            Err(e)
207                        }
208                    })?;
209                    let is_group_shared =
210                        [Some("group"), Some("1"), Some("true")].contains(&Some(shared_config));
211
212                    if is_group_shared == is_checking_group {
213                        let description = if description_path.is_file() {
214                            fs::read_to_string(description_path)?.trim().to_string()
215                        } else {
216                            String::new()
217                        };
218
219                        results.push(RepoMetadata { path, description });
220                    }
221                }
222            }
223        }
224        Ok(results)
225    }
226
227    let mut results = Vec::new();
228
229    results.append(&mut add_from_dir(&personal_git_dir()?, false)?);
230    let groups = get_user_groups();
231    for group in &groups {
232        results.append(&mut add_from_dir(&group_git_dir(group), true)?);
233    }
234
235    results.sort_unstable_by_key(|r| r.path.clone());
236
237    Ok(results)
238}
239
240pub fn list_verbose() -> Result<Vec<VerboseRepoMetadata>, ShackleError> {
241    list()?
242        .into_iter()
243        .map(|meta| {
244            get_size(&meta.path).map(|size| VerboseRepoMetadata {
245                path: meta.path,
246                description: meta.description,
247                size,
248            })
249        })
250        .collect()
251}
252
253pub fn set_description(directory: &Path, description: &str) -> Result<(), ShackleError> {
254    if !is_valid_git_repo_path(directory)? {
255        return Err(ShackleError::InvalidDirectory);
256    }
257
258    let description_path = directory.join("description");
259    if description_path.is_file() {
260        fs::write(description_path, description).map_err(|e| e.into())
261    } else {
262        Err(ShackleError::InvalidDirectory)
263    }
264}
265
266pub fn set_branch(directory: &Path, branch: &str) -> Result<(), ShackleError> {
267    if !is_valid_git_repo_path(directory)? {
268        return Err(ShackleError::InvalidDirectory);
269    }
270
271    if let Ok(repo) = Repository::open_bare(directory) {
272        repo.reference_symbolic(
273            "HEAD",
274            &format!("refs/heads/{branch}"),
275            true,
276            "shackle set-branch",
277        )?;
278        Ok(())
279    } else {
280        Err(ShackleError::InvalidDirectory)
281    }
282}
283
284pub fn housekeeping(directory: &Path) -> Result<(), ShackleError> {
285    if !is_valid_git_repo_path(directory)? {
286        return Err(ShackleError::InvalidDirectory);
287    }
288
289    Command::new("git")
290        .arg("gc")
291        .arg("--prune=now")
292        .current_dir(directory)
293        .spawn()?
294        .wait()?;
295
296    Ok(())
297}
298
299pub fn delete(directory: &Path) -> Result<(), ShackleError> {
300    if !is_valid_git_repo_path(directory)? {
301        return Err(ShackleError::InvalidDirectory);
302    }
303
304    if Repository::open_bare(directory).is_ok() {
305        fs::remove_dir_all(directory)?;
306        Ok(())
307    } else {
308        Err(ShackleError::InvalidDirectory)
309    }
310}
311
312pub fn upload_pack(upload_pack_args: &GitUploadPackArgs) -> Result<(), ShackleError> {
313    if !is_valid_git_repo_path(&upload_pack_args.directory)? {
314        return Err(ShackleError::InvalidDirectory);
315    }
316
317    let mut command = Command::new("git-upload-pack");
318    command.arg("--strict");
319
320    if let Some(timeout) = upload_pack_args.timeout {
321        command.args(["--timeout", &timeout.to_string()]);
322    }
323    if upload_pack_args.stateless_rpc {
324        command.arg("--stateless-rpc");
325    }
326    if upload_pack_args.advertise_refs {
327        command.arg("--advertise-refs");
328    }
329    command.arg(&upload_pack_args.directory);
330
331    command.spawn()?.wait()?;
332    Ok(())
333}
334
335pub fn receive_pack(receive_pack_args: &GitReceivePackArgs) -> Result<(), ShackleError> {
336    if !is_valid_git_repo_path(&receive_pack_args.directory)? {
337        return Err(ShackleError::InvalidDirectory);
338    }
339
340    let mut command = Command::new("git-receive-pack");
341    command.arg(&receive_pack_args.directory);
342
343    command.spawn()?.wait()?;
344    Ok(())
345}