]> git.nega.tv - josh/git-shell-multiplex/blob - src/main.rs
db2d9cbc1c3dbf763a6204c8fc0a2b39f6c7e30f
[josh/git-shell-multiplex] / src / main.rs
1 //! git-shell-multiplex
2 //!
3 //! An implementation of `git-shell` which allows multiple users to access git
4 //! repositories through a single linux user account. It works by using the
5 //! `command` parameter of `authorized_keys` entries to associate a username
6 //! with a public key, then correlating that with the permissions of the git
7 //! repository.
8 //!
9 //! There's no support for an interactive shell, and only the following git
10 //! commands are available.
11 //! * `git-receive-pack`
12 //! * `git-upload-pack`
13 //! * `git-upload-archive`
14 //!
15 //! Control Files
16 //! ===
17 //!
18 //! `PUBLIC_ACCESS_FILE`
19 //! ---
20 //! `git-shell-multiplex` looks for the file `PUBLIC_ACCESS_FILE` (default:
21 //! `git-daemon-export-ok`) within the git repository, if found all users will be
22 //! granted read-access to the repository.
23 //!
24 //! `CONTRIBUTORS_FILE`
25 //! ---
26 //! `git-shell-multiplex` looks for the file `PUBLIC_ACCESS_FILE` (default:
27 //! `git-shell-multiplex-contributors`) within the git repository. The file is
28 //! expected to contain a newline-separated list of additional contributor
29 //! usernames. These users will be granted write access to the repository, in
30 //! addition to the repository owner.
31 //!
32 //! Example `authorized_keys` file
33 //! ===
34 //!
35 //! ```text
36 //! command="/home/git/git-shell-multiplex josh",restrict <pubkey>
37 //! command="/home/git/git-shell-multiplex sarah",restrict <pubkey>
38 //! ```
39 //!
40 //! Accepts paths of the form `<user>/<repo>.git` only.
41
42 use std::fs::File;
43 use std::io::{BufRead as _, BufReader, Lines};
44 use std::path::{Path, PathBuf};
45
46 enum Error {
47 Usage,
48 NotAShell { username: String },
49 NoPermission,
50 }
51
52 /// Repositories should be found at `<GIT_DIR>/<user>/<repo>`. Note the lack of `.git`.
53 const GIT_DIR: &str = "/var/git/";
54
55 /// If a file with this name is present in a git repository, read-access will be
56 /// granted to anybody with ssh access.
57 const PUBLIC_ACCESS_FILE: &str = "git-daemon-export-ok";
58
59 /// This file, if it exists, should contain a newline-separated list of additional
60 /// contributor usernames. These users will be granted write access to the
61 /// repository.
62 ///
63 /// If the file does not exist, only the owner of the repository will be able to
64 /// write to the repository.
65 const CONTRIBUTORS_FILE: &str = "git-shell-multiplex-contributors";
66
67 fn read_lines<P>(path: P) -> std::io::Result<Lines<BufReader<File>>>
68 where
69 P: AsRef<Path>,
70 {
71 let file = File::open(path)?;
72 Ok(BufReader::new(file).lines())
73 }
74
75 fn git_shell() -> Result<(), Error> {
76 let mut args = std::env::args();
77
78 // First arg is the process name.
79 let _ = args.next().ok_or(Error::Usage)?;
80 let username = args.next().ok_or(Error::Usage)?;
81
82 // Usage error if we pass extra arguments.
83 if args.next().is_some() {
84 return Err(Error::Usage);
85 }
86
87 // If sshd hasn't set the original command, some goober is trying to use the
88 // git account as a shell.
89 let Some(ssh_original_command) = std::env::var_os("SSH_ORIGINAL_COMMAND") else {
90 return Err(Error::NotAShell { username });
91 };
92
93 // If the incoming command isn't valid unicode.
94 let ssh_original_command = ssh_original_command.to_str().ok_or(Error::NoPermission)?;
95
96 // The `SSH_ORIGINAL_COMMAND` is escaped, so we need to undo that.
97 let command;
98 let path;
99 {
100 let mut words = shlex::Shlex::new(ssh_original_command);
101 command = words.next().ok_or(Error::NoPermission)?;
102 path = words.next().ok_or(Error::NoPermission)?;
103 if words.next().is_some() {
104 return Err(Error::NoPermission);
105 }
106 };
107
108 // We want to support both `git-blah-blah` and `git blah-blah`.
109 let command = if let Some(rest) = command.strip_prefix("git-") {
110 rest
111 } else if let Some(rest) = command.strip_prefix("git ") {
112 rest
113 } else {
114 return Err(Error::NoPermission);
115 };
116
117 // We exclusively support paths that look like `<user>/<repo>.git`.
118 let (repo_owner, repo_name) = path.split_once('/').ok_or(Error::NoPermission)?;
119 let repo_name = repo_name.strip_suffix(".git").ok_or(Error::NoPermission)?;
120
121 // Hopefully this is enough...
122 const UNSAFE_CHARS: &[char] = &['\\', '.', '/', ':', '?'];
123 if repo_owner.contains(UNSAFE_CHARS) || repo_name.contains(UNSAFE_CHARS) {
124 return Err(Error::NoPermission);
125 }
126
127 // On the filesystem we don't have a `.git` suffix for repositories.
128 let repo_path = PathBuf::from(format!("{GIT_DIR}/{repo_owner}/{repo_name}"));
129
130 // Is this actually a git repo?
131 let has_head_file = matches!(std::fs::exists(repo_path.join("HEAD")), Ok(true));
132 if !has_head_file {
133 return Err(Error::NoPermission);
134 }
135
136 // If the repo contains a file named `git-daemon-export-ok` we allow public access.
137 let is_repo_public = matches!(
138 std::fs::exists(repo_path.join(PUBLIC_ACCESS_FILE)),
139 Ok(true)
140 );
141
142 let is_user_a_contributor =
143 if let Ok(contributors) = read_lines(repo_path.join(CONTRIBUTORS_FILE)) {
144 contributors
145 .map_while(Result::ok)
146 .any(|contributor_username| contributor_username == username)
147 } else {
148 false
149 };
150
151 // Check the permissions...
152 let is_user_the_repo_owner = repo_owner == username;
153 let write_access = is_user_the_repo_owner || is_user_a_contributor;
154 let read_access = is_user_the_repo_owner || is_user_a_contributor || is_repo_public;
155
156 let has_permission = match command {
157 "receive-pack" => write_access,
158 "upload-pack" | "upload-archive" => read_access,
159
160 // Spooky unknown commands ~~~
161 _ => false,
162 };
163
164 if !has_permission {
165 return Err(Error::NoPermission);
166 }
167
168 // Finally we can actually run the git command.
169 let program = format!("git-{command}");
170 let mut child = std::process::Command::new(program)
171 .arg(repo_path)
172 .spawn()
173 .map_err(|_| Error::NoPermission)?;
174 let exit_status = child.wait().map_err(|_| Error::NoPermission)?;
175
176 if exit_status.success() {
177 Ok(())
178 } else {
179 Err(Error::NoPermission)
180 }
181 }
182
183 pub fn main() -> std::process::ExitCode {
184 match git_shell() {
185 Ok(_) => std::process::ExitCode::SUCCESS,
186 Err(error) => match error {
187 Error::Usage => {
188 eprintln!("usage: `git-shell-multiplex <username>");
189 std::process::ExitCode::FAILURE
190 }
191 Error::NotAShell { username } => {
192 eprintln!("Hello {username}! This isn't the shell you're looking for.");
193 std::process::ExitCode::SUCCESS
194 }
195 Error::NoPermission => std::process::ExitCode::FAILURE,
196 },
197 }
198 }