aboutsummaryrefslogtreecommitdiffstats
path: root/git-lfs-authenticate
diff options
context:
space:
mode:
Diffstat (limited to 'git-lfs-authenticate')
-rw-r--r--git-lfs-authenticate/Cargo.toml8
-rw-r--r--git-lfs-authenticate/src/main.rs236
2 files changed, 244 insertions, 0 deletions
diff --git a/git-lfs-authenticate/Cargo.toml b/git-lfs-authenticate/Cargo.toml
new file mode 100644
index 0000000..217250f
--- /dev/null
+++ b/git-lfs-authenticate/Cargo.toml
@@ -0,0 +1,8 @@
1[package]
2name = "git-lfs-authenticate"
3version = "0.1.0"
4edition = "2021"
5
6[dependencies]
7chrono = "0.4"
8common = { path = "../common" }
diff --git a/git-lfs-authenticate/src/main.rs b/git-lfs-authenticate/src/main.rs
new file mode 100644
index 0000000..36d7818
--- /dev/null
+++ b/git-lfs-authenticate/src/main.rs
@@ -0,0 +1,236 @@
1use std::{fmt, process::ExitCode, time::Duration};
2
3use chrono::Utc;
4use common::{Operation, ParseOperationError};
5
6fn help() {
7 eprintln!("Usage: git-lfs-authenticate <REPO> upload/download");
8}
9
10#[derive(Debug, Eq, PartialEq, Copy, Clone)]
11enum RepoNameError {
12 TooLong,
13 UnresolvedPath,
14 AbsolutePath,
15 MissingGitSuffix,
16}
17
18impl fmt::Display for RepoNameError {
19 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
20 match self {
21 Self::TooLong => write!(f, "too long (more than 100 characters)"),
22 Self::UnresolvedPath => {
23 write!(f, "contains path one or more path elements '.' and '..'")
24 }
25 Self::AbsolutePath => {
26 write!(f, "starts with '/', which is not allowed")
27 }
28 Self::MissingGitSuffix => write!(f, "misses '.git' suffix"),
29 }
30 }
31}
32
33// Using `Result<(), E>` here instead of `Option<E>` because `None` typically signifies some error
34// state with no further details provided. If we were to return an `Option` type, the user would
35// have to first transform it into a `Result` type in order to use the `?` operator, meaning that
36// they would have to the following operation to get the type into the right shape:
37// `validate_repo_path(path).map_or(Ok(()), Err)`. That would not be very ergonomic.
38fn validate_repo_path(path: &str) -> Result<(), RepoNameError> {
39 if path.len() > 100 {
40 return Err(RepoNameError::TooLong);
41 }
42 if path.contains("//")
43 || path.contains("/./")
44 || path.contains("/../")
45 || path.starts_with("./")
46 || path.starts_with("../")
47 {
48 return Err(RepoNameError::UnresolvedPath);
49 }
50 if path.starts_with('/') {
51 return Err(RepoNameError::AbsolutePath);
52 }
53 if !path.ends_with(".git") {
54 return Err(RepoNameError::MissingGitSuffix);
55 }
56 Ok(())
57}
58
59#[derive(Debug, Eq, PartialEq, Copy, Clone)]
60enum ParseCmdlineError {
61 UnknownOperation(ParseOperationError),
62 InvalidRepoName(RepoNameError),
63 UnexpectedArgCount(ArgCountError),
64}
65
66impl From<RepoNameError> for ParseCmdlineError {
67 fn from(value: RepoNameError) -> Self {
68 Self::InvalidRepoName(value)
69 }
70}
71
72impl From<ParseOperationError> for ParseCmdlineError {
73 fn from(value: ParseOperationError) -> Self {
74 Self::UnknownOperation(value)
75 }
76}
77
78impl From<ArgCountError> for ParseCmdlineError {
79 fn from(value: ArgCountError) -> Self {
80 Self::UnexpectedArgCount(value)
81 }
82}
83
84impl fmt::Display for ParseCmdlineError {
85 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
86 match self {
87 Self::UnknownOperation(e) => write!(f, "unknown operation: {e}"),
88 Self::InvalidRepoName(e) => write!(f, "invalid repository name: {e}"),
89 Self::UnexpectedArgCount(e) => e.fmt(f),
90 }
91 }
92}
93
94#[derive(Debug, Eq, PartialEq, Copy, Clone)]
95struct ArgCountError {
96 provided: usize,
97 expected: usize,
98}
99
100impl fmt::Display for ArgCountError {
101 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
102 write!(
103 f,
104 "got {} argument(s), expected {}",
105 self.provided, self.expected
106 )
107 }
108}
109
110fn get_cmdline_args<const N: usize>() -> Result<[String; N], ArgCountError> {
111 let args = std::env::args();
112 if args.len() - 1 != N {
113 return Err(ArgCountError {
114 provided: args.len() - 1,
115 expected: N,
116 });
117 }
118
119 // Does not allocate.
120 const EMPTY_STRING: String = String::new();
121 let mut values = [EMPTY_STRING; N];
122
123 // Skip the first element; we do not care about the program name.
124 for (i, arg) in args.skip(1).enumerate() {
125 values[i] = arg
126 }
127 Ok(values)
128}
129
130fn parse_cmdline() -> Result<(String, Operation), ParseCmdlineError> {
131 let [repo_path, op_str] = get_cmdline_args::<2>()?;
132 let op: Operation = op_str.parse()?;
133 validate_repo_path(&repo_path)?;
134 Ok((repo_path.to_string(), op))
135}
136
137fn repo_exists(name: &str) -> bool {
138 match std::fs::metadata(name) {
139 Ok(metadata) => metadata.is_dir(),
140 _ => false,
141 }
142}
143
144struct Config {
145 href_base: String,
146 key_path: String,
147}
148
149#[derive(Debug, Eq, PartialEq, Copy, Clone)]
150enum LoadConfigError {
151 BaseUrlNotProvided,
152 BaseUrlSlashSuffixMissing,
153 KeyPathNotProvided,
154}
155
156impl fmt::Display for LoadConfigError {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 match self {
159 Self::BaseUrlNotProvided => write!(f, "base URL not provided"),
160 Self::BaseUrlSlashSuffixMissing => write!(f, "base URL does not end with slash"),
161 Self::KeyPathNotProvided => write!(f, "key path not provided"),
162 }
163 }
164}
165
166fn load_config() -> Result<Config, LoadConfigError> {
167 let Ok(href_base) = std::env::var("GITOLFS3_HREF_BASE") else {
168 return Err(LoadConfigError::BaseUrlNotProvided);
169 };
170 if !href_base.ends_with('/') {
171 return Err(LoadConfigError::BaseUrlSlashSuffixMissing);
172 }
173 let Ok(key_path) = std::env::var("GITOLFS3_KEY_PATH") else {
174 return Err(LoadConfigError::KeyPathNotProvided);
175 };
176 Ok(Config {
177 href_base,
178 key_path,
179 })
180}
181
182fn main() -> ExitCode {
183 let config = match load_config() {
184 Ok(config) => config,
185 Err(e) => {
186 eprintln!("Failed to load config: {e}");
187 return ExitCode::FAILURE;
188 }
189 };
190 let key = match common::load_key(&config.key_path) {
191 Ok(key) => key,
192 Err(e) => {
193 eprintln!("Failed to load key: {e}");
194 return ExitCode::FAILURE;
195 }
196 };
197
198 let (repo_name, operation) = match parse_cmdline() {
199 Ok(args) => args,
200 Err(e) => {
201 eprintln!("Error: {e}\n");
202 help();
203 // Exit code 2 signifies bad usage of CLI.
204 return ExitCode::from(2);
205 }
206 };
207
208 if !repo_exists(&repo_name) {
209 eprintln!("Error: repository does not exist");
210 return ExitCode::FAILURE;
211 }
212
213 let expires_at = Utc::now() + Duration::from_secs(5 * 60);
214 let Some(tag) = common::generate_tag(
215 common::Claims {
216 specific_claims: common::SpecificClaims::BatchApi(operation),
217 repo_path: &repo_name,
218 expires_at,
219 },
220 key,
221 ) else {
222 eprintln!("Failed to generate validation tag");
223 return ExitCode::FAILURE;
224 };
225
226 println!(
227 "{{\"header\":{{\"Authorization\":\"Gitolfs3-Hmac-Sha256 {tag} {}\"}},\
228 \"expires_at\":\"{}\",\"href\":\"{}{}/info/lfs\"}}",
229 expires_at.timestamp(),
230 common::EscJsonFmt(&expires_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
231 common::EscJsonFmt(&config.href_base),
232 common::EscJsonFmt(&repo_name),
233 );
234
235 ExitCode::SUCCESS
236}