use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use git_lfs_api::{
Auth, BatchRequest, Client as ApiClient, ObjectSpec, Operation, Ref, SharedSshResolver,
SshAuth as ApiSshAuth, SshOperation as ApiSshOperation, SshResolver,
};
use git_lfs_creds::{
AskpassHelper, CachingHelper, GitCredentialHelper, Helper, HelperChain, NetrcCredentialHelper,
SshAuthClient, SshOperation as CredsSshOperation,
};
use git_lfs_filter::FetchError;
use git_lfs_git::ConfigScope;
use git_lfs_git::endpoint::SshInfo;
use git_lfs_pointer::Pointer;
use git_lfs_store::Store;
use git_lfs_transfer::{Report, Transfer, TransferConfig};
use tokio::runtime::Runtime;
pub struct LfsFetcher {
runtime: Runtime,
transfer: Result<Transfer, String>,
api: Result<ApiClient, String>,
refspec: Option<Ref>,
cwd: PathBuf,
}
impl LfsFetcher {
pub fn from_repo(cwd: &Path, store: &Store) -> std::io::Result<Self> {
Self::from_repo_with_remote(cwd, store, None)
}
pub fn from_repo_with_remote(
cwd: &Path,
store: &Store,
remote: Option<&str>,
) -> std::io::Result<Self> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let endpoint_url = git_lfs_git::endpoint::endpoint_for_remote(cwd, remote);
let http = endpoint_url
.as_ref()
.map(|u| crate::http_client::build(cwd, u))
.unwrap_or_default();
let api = build_api_client_with(cwd, remote, http.clone());
let config = transfer_config_for(cwd, endpoint_url.as_deref().ok());
let transfer = api
.clone()
.map(|c| Transfer::with_http_client(c, store.clone(), config, http));
let refspec = git_lfs_git::refs::current_refspec(cwd).map(Ref::new);
Ok(Self {
runtime,
transfer,
api,
refspec,
cwd: cwd.to_path_buf(),
})
}
pub fn persist_access_mode(&self) {
let Ok(api) = self.api.as_ref() else { return };
if !api.used_basic_auth() {
return;
}
let url = api.endpoint().as_str();
let key = format!("lfs.{url}.access");
if let Ok(Some(existing)) = git_lfs_git::config::get_effective(&self.cwd, &key)
&& existing.eq_ignore_ascii_case("basic")
{
return;
}
let _ = git_lfs_git::config::set(&self.cwd, ConfigScope::Local, &key, "basic");
}
#[must_use]
pub fn with_refspec(mut self, refspec: Option<String>) -> Self {
self.refspec = refspec.map(Ref::new);
self
}
pub fn fetch(&self, pointer: &Pointer) -> Result<(), FetchError> {
let report = self.download_many(vec![ObjectSpec {
oid: pointer.oid.to_string(),
size: pointer.size,
}])?;
if let Some((failed_oid, err)) = report.failed.into_iter().next() {
return Err(format!("download failed for {failed_oid}: {err}").into());
}
Ok(())
}
pub fn download_many(&self, specs: Vec<ObjectSpec>) -> Result<Report, FetchError> {
let transfer = self.transfer()?;
let report = self
.runtime
.block_on(transfer.download(specs, self.refspec.clone(), None))?;
Ok(report)
}
pub fn upload_many(&self, specs: Vec<ObjectSpec>) -> Result<Report, FetchError> {
let transfer = self.transfer()?;
let report = self
.runtime
.block_on(transfer.upload(specs, self.refspec.clone(), None))?;
Ok(report)
}
pub fn api_client(&self) -> Result<&ApiClient, FetchError> {
self.api
.as_ref()
.map_err(|m| -> FetchError { m.clone().into() })
}
pub fn runtime_block_on<F: std::future::Future>(&self, fut: F) -> F::Output {
self.runtime.block_on(fut)
}
pub fn preflight_verify_locks(
&self,
cwd: &Path,
remote_label: &str,
endpoint: &str,
) -> Result<crate::locks_verify::Outcome, crate::push::PushCommandError> {
let api = self
.api
.as_ref()
.map_err(|m| -> FetchError { m.clone().into() })
.map_err(crate::push::PushCommandError::Fetch)?;
crate::locks_verify::run(
&self.runtime,
api,
cwd,
remote_label,
endpoint,
self.refspec.as_ref(),
)
}
pub fn check_server_has(&self, specs: Vec<ObjectSpec>) -> Result<HashSet<String>, FetchError> {
if specs.is_empty() {
return Ok(HashSet::new());
}
let api = self
.api
.as_ref()
.map_err(|m| -> FetchError { m.clone().into() })?;
let mut req = BatchRequest::new(Operation::Upload, specs);
if let Some(r) = self.refspec.clone() {
req = req.with_ref(r);
}
let resp = self
.runtime
.block_on(api.batch(&req))
.map_err(|e| -> FetchError { e.to_string().into() })?;
Ok(resp
.objects
.into_iter()
.filter(|o| o.actions.is_none() && o.error.is_none())
.map(|o| o.oid)
.collect())
}
pub fn check_server_can_download(
&self,
specs: Vec<ObjectSpec>,
) -> Result<HashSet<String>, FetchError> {
if specs.is_empty() {
return Ok(HashSet::new());
}
let api = self
.api
.as_ref()
.map_err(|m| -> FetchError { m.clone().into() })?;
let mut req = BatchRequest::new(Operation::Download, specs);
if let Some(r) = self.refspec.clone() {
req = req.with_ref(r);
}
let resp = self
.runtime
.block_on(api.batch(&req))
.map_err(|e| -> FetchError { e.to_string().into() })?;
Ok(resp
.objects
.into_iter()
.filter(|o| o.actions.is_some() && o.error.is_none())
.map(|o| o.oid)
.collect())
}
fn transfer(&self) -> Result<&Transfer, FetchError> {
self.transfer
.as_ref()
.map_err(|msg| -> FetchError { msg.clone().into() })
}
}
pub fn build_api_client(cwd: &Path, remote: Option<&str>) -> Result<ApiClient, String> {
let endpoint = git_lfs_git::endpoint::endpoint_for_remote(cwd, remote)
.map_err(|e| format!("resolving LFS endpoint: {e}"))?;
let http = crate::http_client::build(cwd, &endpoint);
build_api_client_with(cwd, remote, http)
}
fn build_api_client_with(
cwd: &Path,
remote: Option<&str>,
http: reqwest::Client,
) -> Result<ApiClient, String> {
let info = git_lfs_git::endpoint::resolve_endpoint(cwd, remote)
.map_err(|e| format!("resolving LFS endpoint: {e}"))?;
let endpoint =
http_compatible_endpoint(&info.url).map_err(|e| format!("invalid LFS endpoint: {e}"))?;
let mut url = url::Url::parse(&endpoint).map_err(|e| format!("invalid LFS endpoint: {e}"))?;
let initial_auth = take_url_basic_auth(&mut url);
let use_http_path = read_bool_default(cwd, "credential.useHttpPath", false);
let resolved_cred_url = remote
.and_then(|r| git_lfs_git::endpoint::remote_url(cwd, r).ok().flatten())
.and_then(|raw| url::Url::parse(&raw).ok())
.filter(|gu| {
gu.scheme() == url.scheme()
&& gu.host_str() == url.host_str()
&& gu.port() == url.port()
});
let cred_url_for_helper = resolved_cred_url.clone().unwrap_or_else(|| url.clone());
let extras = git_lfs_git::extra_headers_for(cwd, url.as_str());
let mut client = ApiClient::with_http_client(url.clone(), initial_auth, http)
.with_credential_helper(default_helper_chain(cwd, &cred_url_for_helper))
.with_use_http_path(use_http_path)
.with_extra_headers_for_verbose(extras);
if let Some(git_url) = resolved_cred_url {
client = client.with_cred_url(git_url);
}
if let Some(ssh_info) = info.ssh {
let sshtransfer = read_sshtransfer_for(cwd, &info.url);
match sshtransfer.as_deref() {
Some("always") => {
ssh_trace(format_args!(
"git-lfs-authenticate has been disabled by request"
));
client = client.with_ssh_resolver(Arc::new(DisabledSshResolver));
}
Some("never") => {
ssh_trace(format_args!("skipping pure SSH protocol"));
client = client.with_ssh_resolver(build_ssh_resolver(ssh_info));
}
_ => {
client = client.with_ssh_resolver(build_ssh_resolver(ssh_info));
}
}
}
Ok(client)
}
fn read_sshtransfer_for(cwd: &Path, endpoint: &str) -> Option<String> {
let endpoint_key = format!("lfs.{endpoint}.sshtransfer");
if let Ok(Some(v)) = git_lfs_git::config::get_effective(cwd, &endpoint_key) {
return Some(v.trim().to_ascii_lowercase());
}
if let Ok(Some(v)) = git_lfs_git::config::get_effective(cwd, "lfs.sshtransfer") {
return Some(v.trim().to_ascii_lowercase());
}
None
}
fn ssh_trace(args: std::fmt::Arguments) {
if !ssh_trace_enabled() {
return;
}
use std::io::Write as _;
let mut e = std::io::stderr().lock();
let _ = writeln!(e, "{args}");
}
fn ssh_trace_enabled() -> bool {
match std::env::var_os("GIT_TRACE") {
None => false,
Some(v) => {
let s = v.to_string_lossy().trim().to_lowercase();
!matches!(s.as_str(), "" | "0" | "false" | "no" | "off")
}
}
}
struct DisabledSshResolver;
impl SshResolver for DisabledSshResolver {
fn resolve(&self, _op: ApiSshOperation) -> Result<ApiSshAuth, git_lfs_api::ApiError> {
Err(git_lfs_api::ApiError::Decode(
"git-lfs-authenticate has been disabled by request".into(),
))
}
}
fn http_compatible_endpoint(url_str: &str) -> Result<String, git_lfs_git::endpoint::EndpointError> {
if url_str.starts_with("http://") || url_str.starts_with("https://") {
return Ok(url_str.to_owned());
}
git_lfs_git::endpoint::derive_lfs_url(url_str)
}
fn build_ssh_resolver(info: SshInfo) -> SharedSshResolver {
let program = resolve_ssh_program();
Arc::new(SshAuthAdapter {
client: Arc::new(SshAuthClient::new(program)),
ssh: info,
})
}
fn resolve_ssh_program() -> String {
if let Some(v) = std::env::var_os("GIT_SSH_COMMAND")
&& !v.is_empty()
{
return v.to_string_lossy().into_owned();
}
if let Some(v) = std::env::var_os("GIT_SSH")
&& !v.is_empty()
{
return v.to_string_lossy().into_owned();
}
"ssh".to_owned()
}
struct SshAuthAdapter {
client: Arc<SshAuthClient>,
ssh: SshInfo,
}
impl SshResolver for SshAuthAdapter {
fn resolve(&self, op: ApiSshOperation) -> Result<ApiSshAuth, git_lfs_api::ApiError> {
let creds_op = match op {
ApiSshOperation::Upload => CredsSshOperation::Upload,
ApiSshOperation::Download => CredsSshOperation::Download,
};
let resolved = self
.client
.resolve(
&self.ssh.user_and_host,
self.ssh.port.as_deref(),
&self.ssh.path,
creds_op,
)
.map_err(|e| git_lfs_api::ApiError::Decode(format!("ssh git-lfs-authenticate: {e}")))?;
Ok(ApiSshAuth {
href: resolved.href,
headers: resolved.header,
})
}
}
fn take_url_basic_auth(url: &mut url::Url) -> Auth {
let user = url.username().to_owned();
let pass = url.password().map(str::to_owned).unwrap_or_default();
if user.is_empty() && pass.is_empty() {
return Auth::None;
}
let _ = url.set_username("");
let _ = url.set_password(None);
Auth::Basic {
username: user,
password: pass,
}
}
fn transfer_config_for(cwd: &Path, endpoint_url: Option<&str>) -> TransferConfig {
let mut config = TransferConfig::default();
if href_rewrite_enabled(cwd) {
let aliases = git_lfs_git::aliases::load_aliases(cwd).unwrap_or_default();
let push_aliases = git_lfs_git::aliases::load_push_aliases(cwd).unwrap_or_default();
if !aliases.is_empty() {
let download_aliases = aliases.clone();
config.url_rewriter = Some(Arc::new(move |url: &str| {
git_lfs_git::aliases::apply(&download_aliases, url)
}));
}
if !push_aliases.is_empty() {
let upload_push = push_aliases;
let upload_fallback = aliases;
config.upload_url_rewriter = Some(Arc::new(move |url: &str| {
let rewritten = git_lfs_git::aliases::apply(&upload_push, url);
if rewritten != url {
rewritten
} else {
git_lfs_git::aliases::apply(&upload_fallback, url)
}
}));
}
}
if let Ok(Some(raw)) = git_lfs_git::config::get_effective(cwd, "lfs.transfer.batchSize")
&& let Ok(n) = raw.trim().parse::<usize>()
&& n > 0
{
config.batch_size = n;
}
config.detect_content_type = match endpoint_url {
Some(u) => git_lfs_git::lfs_url_bool(cwd, u, "contenttype", true),
None => match git_lfs_git::config::get_effective(cwd, "lfs.contenttype") {
Ok(Some(v)) => !matches!(
v.trim().to_ascii_lowercase().as_str(),
"false" | "0" | "no" | "off"
),
_ => true,
},
};
config
}
fn href_rewrite_enabled(cwd: &Path) -> bool {
let raw = git_lfs_git::config::get_effective(cwd, "lfs.transfer.enablehrefrewrite")
.ok()
.flatten()
.unwrap_or_default();
matches!(
raw.to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "on"
)
}
fn default_helper_chain(cwd: &Path, cred_url: &url::Url) -> Arc<dyn Helper> {
let protect_protocol = read_bool_default(cwd, "credential.protectProtocol", true);
let mut helpers: Vec<Box<dyn Helper>> = Vec::new();
if let Some(netrc) = NetrcCredentialHelper::from_default_location() {
helpers.push(Box::new(netrc));
}
helpers.push(Box::new(CachingHelper::new()));
if let Some(askpass) = resolve_askpass_program(cwd)
&& !has_credential_helper(cwd, cred_url)
{
helpers.push(Box::new(AskpassHelper::new(askpass)));
}
helpers.push(Box::new(
GitCredentialHelper::new().with_protect_protocol(protect_protocol),
));
Arc::new(HelperChain::new(helpers))
}
fn resolve_askpass_program(cwd: &Path) -> Option<String> {
if let Some(v) = std::env::var_os("GIT_ASKPASS")
&& !v.is_empty()
{
return Some(v.to_string_lossy().into_owned());
}
if let Ok(Some(v)) = git_lfs_git::config::get_effective(cwd, "core.askpass")
&& !v.trim().is_empty()
{
return Some(v.trim().to_owned());
}
if let Some(v) = std::env::var_os("SSH_ASKPASS")
&& !v.is_empty()
{
return Some(v.to_string_lossy().into_owned());
}
None
}
fn has_credential_helper(cwd: &Path, cred_url: &url::Url) -> bool {
let mut keys = Vec::with_capacity(3);
let host_authority = match (cred_url.host_str(), cred_url.port()) {
(Some(h), Some(p)) => Some(format!("{}://{h}:{p}", cred_url.scheme())),
(Some(h), None) => Some(format!("{}://{h}", cred_url.scheme())),
_ => None,
};
if let Some(h) = &host_authority {
if !cred_url.path().is_empty() && cred_url.path() != "/" {
keys.push(format!(
"credential.{}{}.helper",
h,
cred_url.path().trim_end_matches('/')
));
}
keys.push(format!("credential.{h}.helper"));
}
keys.push("credential.helper".to_string());
for key in &keys {
if let Ok(Some(v)) = git_lfs_git::config::get_effective(cwd, key)
&& !v.trim().is_empty()
{
return true;
}
}
false
}
fn read_bool_default(cwd: &Path, key: &str, default: bool) -> bool {
let Ok(Some(raw)) = git_lfs_git::config::get_effective(cwd, key) else {
return default;
};
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => default,
}
}