From 6095ead99248963ae70091c5bb3399eed49c0826 Mon Sep 17 00:00:00 2001 From: Rutger Broekhoff Date: Wed, 24 Jan 2024 18:58:58 +0100 Subject: Remove Go and C source The Rust implementation now implements all features I need --- git-lfs-authenticate/Cargo.toml | 8 ++ git-lfs-authenticate/src/main.rs | 236 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 git-lfs-authenticate/Cargo.toml create mode 100644 git-lfs-authenticate/src/main.rs (limited to 'git-lfs-authenticate') 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 @@ +[package] +name = "git-lfs-authenticate" +version = "0.1.0" +edition = "2021" + +[dependencies] +chrono = "0.4" +common = { 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 @@ +use std::{fmt, process::ExitCode, time::Duration}; + +use chrono::Utc; +use common::{Operation, ParseOperationError}; + +fn help() { + eprintln!("Usage: git-lfs-authenticate upload/download"); +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +enum RepoNameError { + TooLong, + UnresolvedPath, + AbsolutePath, + MissingGitSuffix, +} + +impl fmt::Display for RepoNameError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::TooLong => write!(f, "too long (more than 100 characters)"), + Self::UnresolvedPath => { + write!(f, "contains path one or more path elements '.' and '..'") + } + Self::AbsolutePath => { + write!(f, "starts with '/', which is not allowed") + } + Self::MissingGitSuffix => write!(f, "misses '.git' suffix"), + } + } +} + +// Using `Result<(), E>` here instead of `Option` because `None` typically signifies some error +// state with no further details provided. If we were to return an `Option` type, the user would +// have to first transform it into a `Result` type in order to use the `?` operator, meaning that +// they would have to the following operation to get the type into the right shape: +// `validate_repo_path(path).map_or(Ok(()), Err)`. That would not be very ergonomic. +fn validate_repo_path(path: &str) -> Result<(), RepoNameError> { + if path.len() > 100 { + return Err(RepoNameError::TooLong); + } + if path.contains("//") + || path.contains("/./") + || path.contains("/../") + || path.starts_with("./") + || path.starts_with("../") + { + return Err(RepoNameError::UnresolvedPath); + } + if path.starts_with('/') { + return Err(RepoNameError::AbsolutePath); + } + if !path.ends_with(".git") { + return Err(RepoNameError::MissingGitSuffix); + } + Ok(()) +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +enum ParseCmdlineError { + UnknownOperation(ParseOperationError), + InvalidRepoName(RepoNameError), + UnexpectedArgCount(ArgCountError), +} + +impl From for ParseCmdlineError { + fn from(value: RepoNameError) -> Self { + Self::InvalidRepoName(value) + } +} + +impl From for ParseCmdlineError { + fn from(value: ParseOperationError) -> Self { + Self::UnknownOperation(value) + } +} + +impl From for ParseCmdlineError { + fn from(value: ArgCountError) -> Self { + Self::UnexpectedArgCount(value) + } +} + +impl fmt::Display for ParseCmdlineError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::UnknownOperation(e) => write!(f, "unknown operation: {e}"), + Self::InvalidRepoName(e) => write!(f, "invalid repository name: {e}"), + Self::UnexpectedArgCount(e) => e.fmt(f), + } + } +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +struct ArgCountError { + provided: usize, + expected: usize, +} + +impl fmt::Display for ArgCountError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "got {} argument(s), expected {}", + self.provided, self.expected + ) + } +} + +fn get_cmdline_args() -> Result<[String; N], ArgCountError> { + let args = std::env::args(); + if args.len() - 1 != N { + return Err(ArgCountError { + provided: args.len() - 1, + expected: N, + }); + } + + // Does not allocate. + const EMPTY_STRING: String = String::new(); + let mut values = [EMPTY_STRING; N]; + + // Skip the first element; we do not care about the program name. + for (i, arg) in args.skip(1).enumerate() { + values[i] = arg + } + Ok(values) +} + +fn parse_cmdline() -> Result<(String, Operation), ParseCmdlineError> { + let [repo_path, op_str] = get_cmdline_args::<2>()?; + let op: Operation = op_str.parse()?; + validate_repo_path(&repo_path)?; + Ok((repo_path.to_string(), op)) +} + +fn repo_exists(name: &str) -> bool { + match std::fs::metadata(name) { + Ok(metadata) => metadata.is_dir(), + _ => false, + } +} + +struct Config { + href_base: String, + key_path: String, +} + +#[derive(Debug, Eq, PartialEq, Copy, Clone)] +enum LoadConfigError { + BaseUrlNotProvided, + BaseUrlSlashSuffixMissing, + KeyPathNotProvided, +} + +impl fmt::Display for LoadConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BaseUrlNotProvided => write!(f, "base URL not provided"), + Self::BaseUrlSlashSuffixMissing => write!(f, "base URL does not end with slash"), + Self::KeyPathNotProvided => write!(f, "key path not provided"), + } + } +} + +fn load_config() -> Result { + let Ok(href_base) = std::env::var("GITOLFS3_HREF_BASE") else { + return Err(LoadConfigError::BaseUrlNotProvided); + }; + if !href_base.ends_with('/') { + return Err(LoadConfigError::BaseUrlSlashSuffixMissing); + } + let Ok(key_path) = std::env::var("GITOLFS3_KEY_PATH") else { + return Err(LoadConfigError::KeyPathNotProvided); + }; + Ok(Config { + href_base, + key_path, + }) +} + +fn main() -> ExitCode { + let config = match load_config() { + Ok(config) => config, + Err(e) => { + eprintln!("Failed to load config: {e}"); + return ExitCode::FAILURE; + } + }; + let key = match common::load_key(&config.key_path) { + Ok(key) => key, + Err(e) => { + eprintln!("Failed to load key: {e}"); + return ExitCode::FAILURE; + } + }; + + let (repo_name, operation) = match parse_cmdline() { + Ok(args) => args, + Err(e) => { + eprintln!("Error: {e}\n"); + help(); + // Exit code 2 signifies bad usage of CLI. + return ExitCode::from(2); + } + }; + + if !repo_exists(&repo_name) { + eprintln!("Error: repository does not exist"); + return ExitCode::FAILURE; + } + + let expires_at = Utc::now() + Duration::from_secs(5 * 60); + let Some(tag) = common::generate_tag( + common::Claims { + specific_claims: common::SpecificClaims::BatchApi(operation), + repo_path: &repo_name, + expires_at, + }, + key, + ) else { + eprintln!("Failed to generate validation tag"); + return ExitCode::FAILURE; + }; + + println!( + "{{\"header\":{{\"Authorization\":\"Gitolfs3-Hmac-Sha256 {tag} {}\"}},\ + \"expires_at\":\"{}\",\"href\":\"{}{}/info/lfs\"}}", + expires_at.timestamp(), + common::EscJsonFmt(&expires_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), + common::EscJsonFmt(&config.href_base), + common::EscJsonFmt(&repo_name), + ); + + ExitCode::SUCCESS +} -- cgit v1.2.3