diff options
| -rw-r--r-- | Cargo.lock | 7 | ||||
| -rw-r--r-- | common/src/lib.rs | 11 | ||||
| -rw-r--r-- | git-lfs-authenticate/Cargo.toml | 1 | ||||
| -rw-r--r-- | git-lfs-authenticate/src/main.rs | 275 | ||||
| -rw-r--r-- | server/src/main.rs | 257 | ||||
| -rw-r--r-- | shell/src/main.rs | 158 |
6 files changed, 306 insertions, 403 deletions
| @@ -42,6 +42,12 @@ dependencies = [ | |||
| 42 | ] | 42 | ] |
| 43 | 43 | ||
| 44 | [[package]] | 44 | [[package]] |
| 45 | name = "anyhow" | ||
| 46 | version = "1.0.79" | ||
| 47 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 48 | checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" | ||
| 49 | |||
| 50 | [[package]] | ||
| 45 | name = "async-trait" | 51 | name = "async-trait" |
| 46 | version = "0.1.77" | 52 | version = "0.1.77" |
| 47 | source = "registry+https://github.com/rust-lang/crates.io-index" | 53 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -869,6 +875,7 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" | |||
| 869 | name = "git-lfs-authenticate" | 875 | name = "git-lfs-authenticate" |
| 870 | version = "0.1.0" | 876 | version = "0.1.0" |
| 871 | dependencies = [ | 877 | dependencies = [ |
| 878 | "anyhow", | ||
| 872 | "chrono", | 879 | "chrono", |
| 873 | "common", | 880 | "common", |
| 874 | ] | 881 | ] |
diff --git a/common/src/lib.rs b/common/src/lib.rs index 995352d..0a538a5 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs | |||
| @@ -1,9 +1,10 @@ | |||
| 1 | use chrono::{DateTime, Utc}; | 1 | use chrono::{DateTime, Utc}; |
| 2 | use serde::de; | 2 | use serde::{de, Deserialize, Serialize}; |
| 3 | use serde::{Deserialize, Serialize}; | 3 | use std::{ |
| 4 | use std::fmt::Write; | 4 | fmt::{self, Write}, |
| 5 | use std::ops; | 5 | ops, |
| 6 | use std::{fmt, str::FromStr}; | 6 | str::FromStr, |
| 7 | }; | ||
| 7 | use subtle::ConstantTimeEq; | 8 | use subtle::ConstantTimeEq; |
| 8 | 9 | ||
| 9 | #[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)] | 10 | #[derive(Debug, Eq, PartialEq, Copy, Clone, Serialize, Deserialize)] |
diff --git a/git-lfs-authenticate/Cargo.toml b/git-lfs-authenticate/Cargo.toml index 217250f..f4ab4d7 100644 --- a/git-lfs-authenticate/Cargo.toml +++ b/git-lfs-authenticate/Cargo.toml | |||
| @@ -4,5 +4,6 @@ version = "0.1.0" | |||
| 4 | edition = "2021" | 4 | edition = "2021" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
| 7 | anyhow = "1.0" | ||
| 7 | chrono = "0.4" | 8 | chrono = "0.4" |
| 8 | common = { path = "../common" } | 9 | common = { path = "../common" } |
diff --git a/git-lfs-authenticate/src/main.rs b/git-lfs-authenticate/src/main.rs index 36d7818..accc37f 100644 --- a/git-lfs-authenticate/src/main.rs +++ b/git-lfs-authenticate/src/main.rs | |||
| @@ -1,197 +1,13 @@ | |||
| 1 | use std::{fmt, process::ExitCode, time::Duration}; | 1 | use anyhow::{anyhow, bail, Result}; |
| 2 | |||
| 3 | use chrono::Utc; | 2 | use chrono::Utc; |
| 4 | use common::{Operation, ParseOperationError}; | 3 | use std::{process::ExitCode, time::Duration}; |
| 5 | |||
| 6 | fn help() { | ||
| 7 | eprintln!("Usage: git-lfs-authenticate <REPO> upload/download"); | ||
| 8 | } | ||
| 9 | |||
| 10 | #[derive(Debug, Eq, PartialEq, Copy, Clone)] | ||
| 11 | enum RepoNameError { | ||
| 12 | TooLong, | ||
| 13 | UnresolvedPath, | ||
| 14 | AbsolutePath, | ||
| 15 | MissingGitSuffix, | ||
| 16 | } | ||
| 17 | |||
| 18 | impl 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. | ||
| 38 | fn 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)] | ||
| 60 | enum ParseCmdlineError { | ||
| 61 | UnknownOperation(ParseOperationError), | ||
| 62 | InvalidRepoName(RepoNameError), | ||
| 63 | UnexpectedArgCount(ArgCountError), | ||
| 64 | } | ||
| 65 | |||
| 66 | impl From<RepoNameError> for ParseCmdlineError { | ||
| 67 | fn from(value: RepoNameError) -> Self { | ||
| 68 | Self::InvalidRepoName(value) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | impl From<ParseOperationError> for ParseCmdlineError { | ||
| 73 | fn from(value: ParseOperationError) -> Self { | ||
| 74 | Self::UnknownOperation(value) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | impl From<ArgCountError> for ParseCmdlineError { | ||
| 79 | fn from(value: ArgCountError) -> Self { | ||
| 80 | Self::UnexpectedArgCount(value) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | impl 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)] | ||
| 95 | struct ArgCountError { | ||
| 96 | provided: usize, | ||
| 97 | expected: usize, | ||
| 98 | } | ||
| 99 | |||
| 100 | impl 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 | |||
| 110 | fn 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 | |||
| 130 | fn 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 | |||
| 137 | fn repo_exists(name: &str) -> bool { | ||
| 138 | match std::fs::metadata(name) { | ||
| 139 | Ok(metadata) => metadata.is_dir(), | ||
| 140 | _ => false, | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | struct Config { | ||
| 145 | href_base: String, | ||
| 146 | key_path: String, | ||
| 147 | } | ||
| 148 | |||
| 149 | #[derive(Debug, Eq, PartialEq, Copy, Clone)] | ||
| 150 | enum LoadConfigError { | ||
| 151 | BaseUrlNotProvided, | ||
| 152 | BaseUrlSlashSuffixMissing, | ||
| 153 | KeyPathNotProvided, | ||
| 154 | } | ||
| 155 | |||
| 156 | impl 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 | |||
| 166 | fn 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 | 4 | ||
| 182 | fn main() -> ExitCode { | 5 | fn main() -> ExitCode { |
| 183 | let config = match load_config() { | 6 | let config = match Config::load() { |
| 184 | Ok(config) => config, | 7 | Ok(config) => config, |
| 185 | Err(e) => { | 8 | Err(e) => { |
| 186 | eprintln!("Failed to load config: {e}"); | 9 | eprintln!("Error: {e}"); |
| 187 | return ExitCode::FAILURE; | 10 | return ExitCode::from(2); |
| 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 | } | 11 | } |
| 196 | }; | 12 | }; |
| 197 | 13 | ||
| @@ -199,7 +15,7 @@ fn main() -> ExitCode { | |||
| 199 | Ok(args) => args, | 15 | Ok(args) => args, |
| 200 | Err(e) => { | 16 | Err(e) => { |
| 201 | eprintln!("Error: {e}\n"); | 17 | eprintln!("Error: {e}\n"); |
| 202 | help(); | 18 | eprintln!("Usage: git-lfs-authenticate <REPO> upload/download"); |
| 203 | // Exit code 2 signifies bad usage of CLI. | 19 | // Exit code 2 signifies bad usage of CLI. |
| 204 | return ExitCode::from(2); | 20 | return ExitCode::from(2); |
| 205 | } | 21 | } |
| @@ -217,7 +33,7 @@ fn main() -> ExitCode { | |||
| 217 | repo_path: &repo_name, | 33 | repo_path: &repo_name, |
| 218 | expires_at, | 34 | expires_at, |
| 219 | }, | 35 | }, |
| 220 | key, | 36 | config.key, |
| 221 | ) else { | 37 | ) else { |
| 222 | eprintln!("Failed to generate validation tag"); | 38 | eprintln!("Failed to generate validation tag"); |
| 223 | return ExitCode::FAILURE; | 39 | return ExitCode::FAILURE; |
| @@ -234,3 +50,80 @@ fn main() -> ExitCode { | |||
| 234 | 50 | ||
| 235 | ExitCode::SUCCESS | 51 | ExitCode::SUCCESS |
| 236 | } | 52 | } |
| 53 | |||
| 54 | struct Config { | ||
| 55 | href_base: String, | ||
| 56 | key: common::Key, | ||
| 57 | } | ||
| 58 | |||
| 59 | impl Config { | ||
| 60 | fn load() -> Result<Self> { | ||
| 61 | let Ok(href_base) = std::env::var("GITOLFS3_HREF_BASE") else { | ||
| 62 | bail!("configured base URL not provided"); | ||
| 63 | }; | ||
| 64 | if !href_base.ends_with('/') { | ||
| 65 | bail!("configured base URL does not end with a slash"); | ||
| 66 | } | ||
| 67 | |||
| 68 | let Ok(key_path) = std::env::var("GITOLFS3_KEY_PATH") else { | ||
| 69 | bail!("key path not provided"); | ||
| 70 | }; | ||
| 71 | let key = common::load_key(&key_path).map_err(|e| anyhow!("failed to load key: {e}"))?; | ||
| 72 | |||
| 73 | Ok(Self { href_base, key }) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | fn parse_cmdline() -> Result<(String, common::Operation)> { | ||
| 78 | let [repo_path, op_str] = get_cmdline_args::<2>()?; | ||
| 79 | let op: common::Operation = op_str | ||
| 80 | .parse() | ||
| 81 | .map_err(|e| anyhow!("unknown operation: {e}"))?; | ||
| 82 | validate_repo_path(&repo_path).map_err(|e| anyhow!("invalid repository name: {e}"))?; | ||
| 83 | Ok((repo_path.to_string(), op)) | ||
| 84 | } | ||
| 85 | |||
| 86 | fn get_cmdline_args<const N: usize>() -> Result<[String; N]> { | ||
| 87 | let args = std::env::args(); | ||
| 88 | if args.len() - 1 != N { | ||
| 89 | bail!("got {} argument(s), expected {}", args.len() - 1, N); | ||
| 90 | } | ||
| 91 | |||
| 92 | // Does not allocate. | ||
| 93 | const EMPTY_STRING: String = String::new(); | ||
| 94 | let mut values = [EMPTY_STRING; N]; | ||
| 95 | |||
| 96 | // Skip the first element; we do not care about the program name. | ||
| 97 | for (i, arg) in args.skip(1).enumerate() { | ||
| 98 | values[i] = arg | ||
| 99 | } | ||
| 100 | Ok(values) | ||
| 101 | } | ||
| 102 | |||
| 103 | fn validate_repo_path(path: &str) -> Result<()> { | ||
| 104 | if path.len() > 100 { | ||
| 105 | bail!("too long (more than 100 characters)"); | ||
| 106 | } | ||
| 107 | if path.contains("//") | ||
| 108 | || path.contains("/./") | ||
| 109 | || path.contains("/../") | ||
| 110 | || path.starts_with("./") | ||
| 111 | || path.starts_with("../") | ||
| 112 | { | ||
| 113 | bail!("contains one or more path elements '.' and '..'"); | ||
| 114 | } | ||
| 115 | if path.starts_with('/') { | ||
| 116 | bail!("starts with '/', which is not allowed"); | ||
| 117 | } | ||
| 118 | if !path.ends_with(".git") { | ||
| 119 | bail!("missed '.git' suffix"); | ||
| 120 | } | ||
| 121 | Ok(()) | ||
| 122 | } | ||
| 123 | |||
| 124 | fn repo_exists(name: &str) -> bool { | ||
| 125 | match std::fs::metadata(name) { | ||
| 126 | Ok(metadata) => metadata.is_dir(), | ||
| 127 | _ => false, | ||
| 128 | } | ||
| 129 | } | ||
diff --git a/server/src/main.rs b/server/src/main.rs index db37d14..4a88dcd 100644 --- a/server/src/main.rs +++ b/server/src/main.rs | |||
| @@ -1,39 +1,82 @@ | |||
| 1 | use std::collections::HashMap; | 1 | use aws_sdk_s3::{error::SdkError, operation::head_object::HeadObjectOutput}; |
| 2 | use std::collections::HashSet; | ||
| 3 | use std::process::ExitCode; | ||
| 4 | use std::sync::Arc; | ||
| 5 | |||
| 6 | use aws_sdk_s3::error::SdkError; | ||
| 7 | use aws_sdk_s3::operation::head_object::HeadObjectOutput; | ||
| 8 | use axum::extract::rejection; | ||
| 9 | use axum::extract::FromRequest; | ||
| 10 | use axum::extract::Path; | ||
| 11 | use axum::extract::State; | ||
| 12 | use axum::http::header; | ||
| 13 | use axum::http::HeaderMap; | ||
| 14 | use axum::http::HeaderValue; | ||
| 15 | use axum::response::Response; | ||
| 16 | use axum::Json; | ||
| 17 | use axum::ServiceExt; | ||
| 18 | use base64::prelude::*; | ||
| 19 | use chrono::DateTime; | ||
| 20 | use chrono::Utc; | ||
| 21 | use common::HexByte; | ||
| 22 | use serde::de; | ||
| 23 | use serde::de::DeserializeOwned; | ||
| 24 | use serde::Deserialize; | ||
| 25 | use serde::Serialize; | ||
| 26 | use tokio::io::AsyncWriteExt; | ||
| 27 | use tower::Layer; | ||
| 28 | |||
| 29 | use axum::{ | 2 | use axum::{ |
| 30 | async_trait, | 3 | async_trait, |
| 31 | extract::{FromRequestParts, OriginalUri, Request}, | 4 | extract::{rejection, FromRequest, FromRequestParts, OriginalUri, Path, Request, State}, |
| 32 | http::{request::Parts, StatusCode, Uri}, | 5 | http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode, Uri}, |
| 33 | response::IntoResponse, | 6 | response::{IntoResponse, Response}, |
| 34 | routing::{get, post}, | 7 | routing::{get, post}, |
| 35 | Extension, Router, | 8 | Extension, Json, Router, ServiceExt, |
| 36 | }; | 9 | }; |
| 10 | use base64::prelude::*; | ||
| 11 | use chrono::{DateTime, Utc}; | ||
| 12 | use serde::{ | ||
| 13 | de::{self, DeserializeOwned}, | ||
| 14 | Deserialize, Serialize, | ||
| 15 | }; | ||
| 16 | use std::{ | ||
| 17 | collections::{HashMap, HashSet}, | ||
| 18 | process::ExitCode, | ||
| 19 | sync::Arc, | ||
| 20 | }; | ||
| 21 | use tokio::io::AsyncWriteExt; | ||
| 22 | use tower::Layer; | ||
| 23 | |||
| 24 | #[tokio::main] | ||
| 25 | async fn main() -> ExitCode { | ||
| 26 | tracing_subscriber::fmt::init(); | ||
| 27 | |||
| 28 | let conf = match Config::load() { | ||
| 29 | Ok(conf) => conf, | ||
| 30 | Err(e) => { | ||
| 31 | println!("Error: {e}"); | ||
| 32 | return ExitCode::from(2); | ||
| 33 | } | ||
| 34 | }; | ||
| 35 | |||
| 36 | let dl_limiter = DownloadLimiter::new(conf.download_limit).await; | ||
| 37 | let dl_limiter = Arc::new(tokio::sync::Mutex::new(dl_limiter)); | ||
| 38 | |||
| 39 | let resetter_dl_limiter = dl_limiter.clone(); | ||
| 40 | tokio::spawn(async move { | ||
| 41 | loop { | ||
| 42 | println!("Resetting download counter in one hour"); | ||
| 43 | tokio::time::sleep(std::time::Duration::from_secs(3600)).await; | ||
| 44 | println!("Resetting download counter"); | ||
| 45 | resetter_dl_limiter.lock().await.reset().await; | ||
| 46 | } | ||
| 47 | }); | ||
| 48 | |||
| 49 | let shared_state = Arc::new(AppState { | ||
| 50 | s3_client: conf.s3_client, | ||
| 51 | s3_bucket: conf.s3_bucket, | ||
| 52 | authz_conf: conf.authz_conf, | ||
| 53 | base_url: conf.base_url, | ||
| 54 | dl_limiter, | ||
| 55 | }); | ||
| 56 | let app = Router::new() | ||
| 57 | .route("/batch", post(batch)) | ||
| 58 | .route("/:oid0/:oid1/:oid", get(obj_download)) | ||
| 59 | .with_state(shared_state); | ||
| 60 | |||
| 61 | let middleware = axum::middleware::map_request(rewrite_url); | ||
| 62 | let app_with_middleware = middleware.layer(app); | ||
| 63 | |||
| 64 | let listener = match tokio::net::TcpListener::bind(conf.listen_addr).await { | ||
| 65 | Ok(listener) => listener, | ||
| 66 | Err(e) => { | ||
| 67 | println!("Failed to listen: {e}"); | ||
| 68 | return ExitCode::FAILURE; | ||
| 69 | } | ||
| 70 | }; | ||
| 71 | |||
| 72 | match axum::serve(listener, app_with_middleware.into_make_service()).await { | ||
| 73 | Ok(_) => ExitCode::SUCCESS, | ||
| 74 | Err(e) => { | ||
| 75 | println!("Error serving: {e}"); | ||
| 76 | ExitCode::FAILURE | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 37 | 80 | ||
| 38 | #[derive(Clone)] | 81 | #[derive(Clone)] |
| 39 | struct RepositoryName(String); | 82 | struct RepositoryName(String); |
| @@ -165,99 +208,57 @@ fn get_s3_client(env: &Env) -> Result<aws_sdk_s3::Client, std::io::Error> { | |||
| 165 | Ok(aws_sdk_s3::Client::new(&config)) | 208 | Ok(aws_sdk_s3::Client::new(&config)) |
| 166 | } | 209 | } |
| 167 | 210 | ||
| 168 | #[tokio::main] | 211 | struct Config { |
| 169 | async fn main() -> ExitCode { | 212 | listen_addr: (String, u16), |
| 170 | tracing_subscriber::fmt::init(); | 213 | base_url: String, |
| 171 | 214 | authz_conf: AuthorizationConfig, | |
| 172 | let env = match Env::load() { | 215 | s3_client: aws_sdk_s3::Client, |
| 173 | Ok(env) => env, | 216 | s3_bucket: String, |
| 174 | Err(e) => { | 217 | download_limit: u64, |
| 175 | println!("Failed to load configuration: {e}"); | 218 | } |
| 176 | return ExitCode::from(2); | ||
| 177 | } | ||
| 178 | }; | ||
| 179 | |||
| 180 | let s3_client = match get_s3_client(&env) { | ||
| 181 | Ok(s3_client) => s3_client, | ||
| 182 | Err(e) => { | ||
| 183 | println!("Failed to create S3 client: {e}"); | ||
| 184 | return ExitCode::FAILURE; | ||
| 185 | } | ||
| 186 | }; | ||
| 187 | let key = match common::load_key(&env.key_path) { | ||
| 188 | Ok(key) => key, | ||
| 189 | Err(e) => { | ||
| 190 | println!("Failed to load Gitolfs3 key: {e}"); | ||
| 191 | return ExitCode::FAILURE; | ||
| 192 | } | ||
| 193 | }; | ||
| 194 | |||
| 195 | let trusted_forwarded_hosts: HashSet<String> = env | ||
| 196 | .trusted_forwarded_hosts | ||
| 197 | .split(',') | ||
| 198 | .map(|s| s.to_owned()) | ||
| 199 | .filter(|s| !s.is_empty()) | ||
| 200 | .collect(); | ||
| 201 | let base_url = env.base_url.trim_end_matches('/').to_string(); | ||
| 202 | |||
| 203 | let Ok(download_limit): Result<u64, _> = env.download_limit.parse() else { | ||
| 204 | println!("Configured GITOLFS3_DOWNLOAD_LIMIT should be a 64-bit unsigned integer"); | ||
| 205 | return ExitCode::from(2); | ||
| 206 | }; | ||
| 207 | let dl_limiter = DownloadLimiter::new(download_limit).await; | ||
| 208 | let dl_limiter = Arc::new(tokio::sync::Mutex::new(dl_limiter)); | ||
| 209 | |||
| 210 | let resetter_dl_limiter = dl_limiter.clone(); | ||
| 211 | tokio::spawn(async move { | ||
| 212 | loop { | ||
| 213 | println!("Resetting download counter in one hour"); | ||
| 214 | tokio::time::sleep(std::time::Duration::from_secs(3600)).await; | ||
| 215 | println!("Resetting download counter"); | ||
| 216 | resetter_dl_limiter.lock().await.reset().await; | ||
| 217 | } | ||
| 218 | }); | ||
| 219 | 219 | ||
| 220 | let authz_conf = AuthorizationConfig { | 220 | impl Config { |
| 221 | key, | 221 | fn load() -> Result<Self, String> { |
| 222 | trusted_forwarded_hosts, | 222 | let env = match Env::load() { |
| 223 | }; | 223 | Ok(env) => env, |
| 224 | Err(e) => return Err(format!("failed to load configuration: {e}")), | ||
| 225 | }; | ||
| 224 | 226 | ||
| 225 | let shared_state = Arc::new(AppState { | 227 | let s3_client = match get_s3_client(&env) { |
| 226 | s3_client, | 228 | Ok(s3_client) => s3_client, |
| 227 | s3_bucket: env.s3_bucket, | 229 | Err(e) => return Err(format!("failed to create S3 client: {e}")), |
| 228 | authz_conf, | 230 | }; |
| 229 | base_url, | 231 | let key = match common::load_key(&env.key_path) { |
| 230 | dl_limiter, | 232 | Ok(key) => key, |
| 231 | }); | 233 | Err(e) => return Err(format!("failed to load Gitolfs3 key: {e}")), |
| 232 | let app = Router::new() | 234 | }; |
| 233 | .route("/batch", post(batch)) | ||
| 234 | .route("/:oid0/:oid1/:oid", get(obj_download)) | ||
| 235 | .with_state(shared_state); | ||
| 236 | 235 | ||
| 237 | let middleware = axum::middleware::map_request(rewrite_url); | 236 | let trusted_forwarded_hosts: HashSet<String> = env |
| 238 | let app_with_middleware = middleware.layer(app); | 237 | .trusted_forwarded_hosts |
| 238 | .split(',') | ||
| 239 | .map(|s| s.to_owned()) | ||
| 240 | .filter(|s| !s.is_empty()) | ||
| 241 | .collect(); | ||
| 242 | let base_url = env.base_url.trim_end_matches('/').to_string(); | ||
| 239 | 243 | ||
| 240 | let Ok(listen_port): Result<u16, _> = env.listen_port.parse() else { | 244 | let Ok(listen_port): Result<u16, _> = env.listen_port.parse() else { |
| 241 | println!( | 245 | return Err("configured GITOLFS3_LISTEN_PORT is invalid".to_string()); |
| 242 | "Configured GITOLFS3_LISTEN_PORT should be an unsigned integer no higher than 65535" | 246 | }; |
| 243 | ); | 247 | let Ok(download_limit): Result<u64, _> = env.download_limit.parse() else { |
| 244 | return ExitCode::from(2); | 248 | return Err("configured GITOLFS3_DOWNLOAD_LIMIT is invalid".to_string()); |
| 245 | }; | 249 | }; |
| 246 | let addr: (String, u16) = (env.listen_host, listen_port); | ||
| 247 | let listener = match tokio::net::TcpListener::bind(addr).await { | ||
| 248 | Ok(listener) => listener, | ||
| 249 | Err(e) => { | ||
| 250 | println!("Failed to listen: {e}"); | ||
| 251 | return ExitCode::FAILURE; | ||
| 252 | } | ||
| 253 | }; | ||
| 254 | 250 | ||
| 255 | match axum::serve(listener, app_with_middleware.into_make_service()).await { | 251 | Ok(Self { |
| 256 | Ok(_) => ExitCode::SUCCESS, | 252 | listen_addr: (env.listen_host, listen_port), |
| 257 | Err(e) => { | 253 | base_url, |
| 258 | println!("Error serving: {e}"); | 254 | authz_conf: AuthorizationConfig { |
| 259 | ExitCode::FAILURE | 255 | key, |
| 260 | } | 256 | trusted_forwarded_hosts, |
| 257 | }, | ||
| 258 | s3_client, | ||
| 259 | s3_bucket: env.s3_bucket, | ||
| 260 | download_limit, | ||
| 261 | }) | ||
| 261 | } | 262 | } |
| 262 | } | 263 | } |
| 263 | 264 | ||
| @@ -479,7 +480,7 @@ async fn handle_upload_object( | |||
| 479 | repo: &str, | 480 | repo: &str, |
| 480 | obj: &BatchRequestObject, | 481 | obj: &BatchRequestObject, |
| 481 | ) -> Option<BatchResponseObject> { | 482 | ) -> Option<BatchResponseObject> { |
| 482 | let (oid0, oid1) = (HexByte(obj.oid[0]), HexByte(obj.oid[1])); | 483 | let (oid0, oid1) = (common::HexByte(obj.oid[0]), common::HexByte(obj.oid[1])); |
| 483 | let full_path = format!("{repo}/lfs/objects/{}/{}/{}", oid0, oid1, obj.oid); | 484 | let full_path = format!("{repo}/lfs/objects/{}/{}/{}", oid0, oid1, obj.oid); |
| 484 | 485 | ||
| 485 | match state | 486 | match state |
| @@ -558,7 +559,7 @@ async fn handle_download_object( | |||
| 558 | obj: &BatchRequestObject, | 559 | obj: &BatchRequestObject, |
| 559 | trusted: bool, | 560 | trusted: bool, |
| 560 | ) -> BatchResponseObject { | 561 | ) -> BatchResponseObject { |
| 561 | let (oid0, oid1) = (HexByte(obj.oid[0]), HexByte(obj.oid[1])); | 562 | let (oid0, oid1) = (common::HexByte(obj.oid[0]), common::HexByte(obj.oid[1])); |
| 562 | let full_path = format!("{repo}/lfs/objects/{}/{}/{}", oid0, oid1, obj.oid); | 563 | let full_path = format!("{repo}/lfs/objects/{}/{}/{}", oid0, oid1, obj.oid); |
| 563 | 564 | ||
| 564 | let result = match state | 565 | let result = match state |
| @@ -687,8 +688,8 @@ async fn handle_download_object( | |||
| 687 | 688 | ||
| 688 | let upload_path = format!( | 689 | let upload_path = format!( |
| 689 | "{repo}/info/lfs/objects/{}/{}/{}", | 690 | "{repo}/info/lfs/objects/{}/{}/{}", |
| 690 | HexByte(obj.oid[0]), | 691 | common::HexByte(obj.oid[0]), |
| 691 | HexByte(obj.oid[1]), | 692 | common::HexByte(obj.oid[1]), |
| 692 | obj.oid, | 693 | obj.oid, |
| 693 | ); | 694 | ); |
| 694 | 695 | ||
| @@ -866,8 +867,8 @@ async fn batch( | |||
| 866 | #[derive(Deserialize, Copy, Clone)] | 867 | #[derive(Deserialize, Copy, Clone)] |
| 867 | #[serde(remote = "Self")] | 868 | #[serde(remote = "Self")] |
| 868 | struct FileParams { | 869 | struct FileParams { |
| 869 | oid0: HexByte, | 870 | oid0: common::HexByte, |
| 870 | oid1: HexByte, | 871 | oid1: common::HexByte, |
| 871 | oid: common::Oid, | 872 | oid: common::Oid, |
| 872 | } | 873 | } |
| 873 | 874 | ||
| @@ -877,8 +878,8 @@ impl<'de> Deserialize<'de> for FileParams { | |||
| 877 | D: serde::Deserializer<'de>, | 878 | D: serde::Deserializer<'de>, |
| 878 | { | 879 | { |
| 879 | let unchecked @ FileParams { | 880 | let unchecked @ FileParams { |
| 880 | oid0: HexByte(oid0), | 881 | oid0: common::HexByte(oid0), |
| 881 | oid1: HexByte(oid1), | 882 | oid1: common::HexByte(oid1), |
| 882 | oid, | 883 | oid, |
| 883 | } = FileParams::deserialize(deserializer)?; | 884 | } = FileParams::deserialize(deserializer)?; |
| 884 | if oid0 != oid.as_bytes()[0] { | 885 | if oid0 != oid.as_bytes()[0] { |
diff --git a/shell/src/main.rs b/shell/src/main.rs index 4901e7f..4a98828 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs | |||
| @@ -1,84 +1,5 @@ | |||
| 1 | use std::{os::unix::process::CommandExt, process::ExitCode}; | 1 | use std::{os::unix::process::CommandExt, process::ExitCode}; |
| 2 | 2 | ||
| 3 | fn parse_sq(s: &str) -> Option<(String, &str)> { | ||
| 4 | #[derive(PartialEq, Eq)] | ||
| 5 | enum SqState { | ||
| 6 | Quoted, | ||
| 7 | Unquoted { may_escape: bool }, | ||
| 8 | UnquotedEscaped, | ||
| 9 | } | ||
| 10 | |||
| 11 | let mut result = String::new(); | ||
| 12 | let mut state = SqState::Unquoted { may_escape: false }; | ||
| 13 | let mut remaining = ""; | ||
| 14 | for (i, c) in s.char_indices() { | ||
| 15 | match state { | ||
| 16 | SqState::Unquoted { may_escape: false } => { | ||
| 17 | if c != '\'' { | ||
| 18 | return None; | ||
| 19 | } | ||
| 20 | state = SqState::Quoted | ||
| 21 | } | ||
| 22 | SqState::Quoted => { | ||
| 23 | if c == '\'' { | ||
| 24 | state = SqState::Unquoted { may_escape: true }; | ||
| 25 | continue; | ||
| 26 | } | ||
| 27 | result.push(c); | ||
| 28 | } | ||
| 29 | SqState::Unquoted { may_escape: true } => { | ||
| 30 | if is_posix_space(c) { | ||
| 31 | remaining = &s[i..]; | ||
| 32 | break; | ||
| 33 | } | ||
| 34 | if c != '\\' { | ||
| 35 | return None; | ||
| 36 | } | ||
| 37 | state = SqState::UnquotedEscaped; | ||
| 38 | } | ||
| 39 | SqState::UnquotedEscaped => { | ||
| 40 | if c != '\\' && c != '!' { | ||
| 41 | return None; | ||
| 42 | } | ||
| 43 | result.push(c); | ||
| 44 | state = SqState::Unquoted { may_escape: false }; | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | if state != (SqState::Unquoted { may_escape: true }) { | ||
| 50 | return None; | ||
| 51 | } | ||
| 52 | Some((result, remaining)) | ||
| 53 | } | ||
| 54 | |||
| 55 | fn parse_cmd(mut cmd: &str) -> Option<Vec<String>> { | ||
| 56 | let mut args = Vec::<String>::new(); | ||
| 57 | |||
| 58 | cmd = cmd.trim_matches(is_posix_space); | ||
| 59 | while !cmd.is_empty() { | ||
| 60 | if cmd.starts_with('\'') { | ||
| 61 | let (arg, remaining) = parse_sq(cmd)?; | ||
| 62 | args.push(arg); | ||
| 63 | cmd = remaining.trim_start_matches(is_posix_space); | ||
| 64 | } else if let Some((arg, remaining)) = cmd.split_once(is_posix_space) { | ||
| 65 | args.push(arg.to_owned()); | ||
| 66 | cmd = remaining.trim_start_matches(is_posix_space); | ||
| 67 | } else { | ||
| 68 | args.push(cmd.to_owned()); | ||
| 69 | cmd = ""; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | Some(args) | ||
| 74 | } | ||
| 75 | |||
| 76 | fn is_posix_space(c: char) -> bool { | ||
| 77 | // Form feed: 0x0c | ||
| 78 | // Vertical tab: 0x0b | ||
| 79 | c == ' ' || c == '\x0c' || c == '\n' || c == '\r' || c == '\t' || c == '\x0b' | ||
| 80 | } | ||
| 81 | |||
| 82 | fn main() -> ExitCode { | 3 | fn main() -> ExitCode { |
| 83 | let bad_usage = ExitCode::from(2); | 4 | let bad_usage = ExitCode::from(2); |
| 84 | 5 | ||
| @@ -141,3 +62,82 @@ fn main() -> ExitCode { | |||
| 141 | eprintln!("Error: {e}"); | 62 | eprintln!("Error: {e}"); |
| 142 | ExitCode::FAILURE | 63 | ExitCode::FAILURE |
| 143 | } | 64 | } |
| 65 | |||
| 66 | fn parse_cmd(mut cmd: &str) -> Option<Vec<String>> { | ||
| 67 | let mut args = Vec::<String>::new(); | ||
| 68 | |||
| 69 | cmd = cmd.trim_matches(is_posix_space); | ||
| 70 | while !cmd.is_empty() { | ||
| 71 | if cmd.starts_with('\'') { | ||
| 72 | let (arg, remaining) = parse_sq(cmd)?; | ||
| 73 | args.push(arg); | ||
| 74 | cmd = remaining.trim_start_matches(is_posix_space); | ||
| 75 | } else if let Some((arg, remaining)) = cmd.split_once(is_posix_space) { | ||
| 76 | args.push(arg.to_owned()); | ||
| 77 | cmd = remaining.trim_start_matches(is_posix_space); | ||
| 78 | } else { | ||
| 79 | args.push(cmd.to_owned()); | ||
| 80 | cmd = ""; | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | Some(args) | ||
| 85 | } | ||
| 86 | |||
| 87 | fn is_posix_space(c: char) -> bool { | ||
| 88 | // Form feed: 0x0c | ||
| 89 | // Vertical tab: 0x0b | ||
| 90 | c == ' ' || c == '\x0c' || c == '\n' || c == '\r' || c == '\t' || c == '\x0b' | ||
| 91 | } | ||
| 92 | |||
| 93 | fn parse_sq(s: &str) -> Option<(String, &str)> { | ||
| 94 | #[derive(PartialEq, Eq)] | ||
| 95 | enum SqState { | ||
| 96 | Quoted, | ||
| 97 | Unquoted { may_escape: bool }, | ||
| 98 | UnquotedEscaped, | ||
| 99 | } | ||
| 100 | |||
| 101 | let mut result = String::new(); | ||
| 102 | let mut state = SqState::Unquoted { may_escape: false }; | ||
| 103 | let mut remaining = ""; | ||
| 104 | for (i, c) in s.char_indices() { | ||
| 105 | match state { | ||
| 106 | SqState::Unquoted { may_escape: false } => { | ||
| 107 | if c != '\'' { | ||
| 108 | return None; | ||
| 109 | } | ||
| 110 | state = SqState::Quoted | ||
| 111 | } | ||
| 112 | SqState::Quoted => { | ||
| 113 | if c == '\'' { | ||
| 114 | state = SqState::Unquoted { may_escape: true }; | ||
| 115 | continue; | ||
| 116 | } | ||
| 117 | result.push(c); | ||
| 118 | } | ||
| 119 | SqState::Unquoted { may_escape: true } => { | ||
| 120 | if is_posix_space(c) { | ||
| 121 | remaining = &s[i..]; | ||
| 122 | break; | ||
| 123 | } | ||
| 124 | if c != '\\' { | ||
| 125 | return None; | ||
| 126 | } | ||
| 127 | state = SqState::UnquotedEscaped; | ||
| 128 | } | ||
| 129 | SqState::UnquotedEscaped => { | ||
| 130 | if c != '\\' && c != '!' { | ||
| 131 | return None; | ||
| 132 | } | ||
| 133 | result.push(c); | ||
| 134 | state = SqState::Unquoted { may_escape: false }; | ||
| 135 | } | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | if state != (SqState::Unquoted { may_escape: true }) { | ||
| 140 | return None; | ||
| 141 | } | ||
| 142 | Some((result, remaining)) | ||
| 143 | } | ||