use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::error::{Error, Result};
use super::Pointer;
use super::batch::{self, Operation};
const LFS_JSON: &str = "application/vnd.git-lfs+json";
const MAX_OBJECT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct LfsClient {
endpoint: String,
}
impl LfsClient {
pub fn new(endpoint: impl Into<String>) -> Self {
LfsClient {
endpoint: endpoint.into(),
}
}
pub fn endpoint_from_remote(remote_url: &str) -> String {
alloc::format!("{}/info/lfs", remote_url.trim_end_matches('/'))
}
fn batch_url(&self) -> String {
alloc::format!("{}/objects/batch", self.endpoint.trim_end_matches('/'))
}
pub fn batch(
&self,
operation: Operation,
pointers: &[Pointer],
) -> Result<Vec<batch::ObjectResult>> {
let body = batch::build_request(operation, pointers);
let response = rsurl::Request::new("POST", &self.batch_url())
.map_err(map_err)?
.header("Accept", LFS_JSON)
.header("Content-Type", LFS_JSON)
.body(body.into_bytes())
.send()
.map_err(map_err)?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"lfs batch: HTTP {}",
response.status
)));
}
let text = String::from_utf8(response.body)
.map_err(|_| Error::Protocol("lfs batch: non-utf8 response".into()))?;
batch::parse_response(&text)
}
pub fn download(&self, pointer: &Pointer, action: &batch::Action) -> Result<Vec<u8>> {
validate_transfer_href(&self.endpoint, &action.href)?;
let mut req = rsurl::Request::new("GET", &action.href).map_err(map_err)?;
for (k, v) in &action.headers {
req = req.header(k, v);
}
let response = req.send().map_err(map_err)?;
if response.status != 200 {
return Err(Error::Protocol(alloc::format!(
"lfs download: HTTP {}",
response.status
)));
}
let content_length = response
.header("content-length")
.and_then(|s| s.parse::<u64>().ok());
check_download_size(pointer.size, content_length, response.body.len())?;
let got = Pointer::for_content(&response.body);
if &got != pointer {
return Err(Error::InvalidOid(
"lfs download: content does not match its pointer".into(),
));
}
Ok(response.body)
}
pub fn upload(&self, action: &batch::Action, content: &[u8]) -> Result<()> {
validate_transfer_href(&self.endpoint, &action.href)?;
let mut req = rsurl::Request::new("PUT", &action.href).map_err(map_err)?;
for (k, v) in &action.headers {
req = req.header(k, v);
}
let response = req.body(content.to_vec()).send().map_err(map_err)?;
if !(200..300).contains(&response.status) {
return Err(Error::Protocol(alloc::format!(
"lfs upload: HTTP {}",
response.status
)));
}
Ok(())
}
}
fn check_download_size(
declared_size: u64,
content_length: Option<u64>,
body_len: usize,
) -> Result<()> {
let limit = declared_size.min(MAX_OBJECT_BYTES);
if let Some(len) = content_length
&& len > limit
{
return Err(Error::Protocol(alloc::format!(
"lfs download: Content-Length {len} exceeds declared size {declared_size}"
)));
}
if body_len as u64 > limit {
return Err(Error::Protocol(alloc::format!(
"lfs download: body of {body_len} bytes exceeds declared size {declared_size}"
)));
}
Ok(())
}
fn scheme_and_host(url: &str) -> Option<(String, String)> {
let (scheme, rest) = url.split_once("://")?;
if scheme.is_empty() {
return None;
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let hostport = authority.rsplit('@').next().unwrap_or(authority);
let host = if let Some(stripped) = hostport.strip_prefix('[') {
let end = stripped.find(']')?;
&stripped[..end]
} else {
hostport.split(':').next().unwrap_or(hostport)
};
if host.is_empty() {
return None;
}
Some((scheme.to_ascii_lowercase(), host.to_ascii_lowercase()))
}
fn validate_transfer_href(endpoint: &str, href: &str) -> Result<()> {
let (href_scheme, href_host) = scheme_and_host(href)
.ok_or_else(|| Error::Protocol(alloc::format!("lfs transfer: malformed href {href:?}")))?;
let (ep_scheme, ep_host) = scheme_and_host(endpoint).ok_or_else(|| {
Error::Protocol(alloc::format!(
"lfs transfer: malformed endpoint {endpoint:?}"
))
})?;
let scheme_ok = match href_scheme.as_str() {
"https" => true,
"http" => ep_scheme == "http",
_ => false,
};
if !scheme_ok {
return Err(Error::Protocol(alloc::format!(
"lfs transfer: refusing href with scheme {href_scheme:?} (endpoint is {ep_scheme:?})"
)));
}
if href_host != ep_host {
return Err(Error::Protocol(alloc::format!(
"lfs transfer: refusing off-origin href host {href_host:?} (endpoint host {ep_host:?})"
)));
}
Ok(())
}
fn map_err(e: rsurl::Error) -> Error {
Error::Io(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_derivation() {
assert_eq!(
LfsClient::endpoint_from_remote("https://host/owner/repo.git"),
"https://host/owner/repo.git/info/lfs"
);
let c = LfsClient::new("https://host/owner/repo.git/info/lfs");
assert_eq!(
c.batch_url(),
"https://host/owner/repo.git/info/lfs/objects/batch"
);
}
#[test]
fn scheme_and_host_parsing() {
assert_eq!(
scheme_and_host("https://Cdn.Example.com:443/a/b?x#y"),
Some(("https".into(), "cdn.example.com".into()))
);
assert_eq!(
scheme_and_host("http://user:pass@host/x"),
Some(("http".into(), "host".into()))
);
assert_eq!(
scheme_and_host("https://[::1]:8443/x"),
Some(("https".into(), "::1".into()))
);
assert_eq!(scheme_and_host("not a url"), None);
assert_eq!(scheme_and_host("https://"), None);
}
#[test]
fn href_validation_blocks_ssrf() {
let ep = "https://host.example.com/owner/repo.git/info/lfs";
assert!(validate_transfer_href(ep, "https://host.example.com/cdn/obj").is_ok());
assert!(validate_transfer_href(ep, "http://host.example.com/cdn/obj").is_err());
assert!(validate_transfer_href(ep, "file:///etc/passwd").is_err());
assert!(validate_transfer_href(ep, "gopher://host.example.com/1").is_err());
assert!(validate_transfer_href(ep, "data:text/plain,hi").is_err());
assert!(validate_transfer_href(ep, "https://169.254.169.254/latest/meta-data").is_err());
assert!(validate_transfer_href(ep, "https://evil.example.net/obj").is_err());
assert!(validate_transfer_href(ep, "://nohost").is_err());
let http_ep = "http://localhost/repo/info/lfs";
assert!(validate_transfer_href(http_ep, "http://localhost/obj").is_ok());
assert!(validate_transfer_href(http_ep, "https://localhost/obj").is_ok());
}
#[test]
fn download_size_bound_is_enforced() {
assert!(check_download_size(4, Some(4), 4).is_ok());
assert!(check_download_size(4, None, 4).is_ok());
assert!(check_download_size(4, None, 5).is_err());
assert!(check_download_size(4, Some(1_000_000), 0).is_err());
assert!(check_download_size(4, Some(5), 5).is_err());
assert!(check_download_size(u64::MAX, Some(MAX_OBJECT_BYTES + 1), 0).is_err());
}
}