use alloc::string::ToString;
use crate::lfs::batch::{self, Action, ObjectResult, Operation};
use crate::lfs::{LfsStore, Pointer};
use crate::vfs::Vfs;
use super::http::HttpResponse;
#[derive(Debug, Clone, Copy)]
pub struct LfsConfig {
pub allow_upload: bool,
}
impl Default for LfsConfig {
fn default() -> Self {
LfsConfig { allow_upload: true }
}
}
impl LfsConfig {
pub fn read_only() -> Self {
LfsConfig {
allow_upload: false,
}
}
}
pub fn is_valid_lfs_oid(oid: &str) -> bool {
oid.len() == 64
&& oid
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn handle_lfs_http<V: Vfs>(
store: &LfsStore<V>,
method: &str,
path: &str,
base_url: &str,
body: &[u8],
) -> HttpResponse {
handle_lfs_http_with(store, &LfsConfig::default(), method, path, base_url, body)
}
pub fn handle_lfs_http_with<V: Vfs>(
store: &LfsStore<V>,
config: &LfsConfig,
method: &str,
path: &str,
base_url: &str,
body: &[u8],
) -> HttpResponse {
let path = path.trim_end_matches('/');
if method.eq_ignore_ascii_case("POST") && path.ends_with("/objects/batch") {
return handle_batch(store, base_url, body);
}
if let Some(oid) = path.rsplit_once("/lfs/").map(|(_, o)| o) {
if !is_valid_lfs_oid(oid) {
return http_text(422, "invalid object id");
}
if method.eq_ignore_ascii_case("GET") {
return handle_download(store, oid);
}
if method.eq_ignore_ascii_case("PUT") {
if !config.allow_upload {
return http_text(403, "upload not enabled");
}
return handle_upload(store, oid, body);
}
}
http_text(404, "not found")
}
fn handle_batch<V: Vfs>(store: &LfsStore<V>, base_url: &str, body: &[u8]) -> HttpResponse {
let body = match core::str::from_utf8(body) {
Ok(b) => b,
Err(_) => return http_text(400, "non-utf8 body"),
};
let (operation, pointers) = match batch::parse_request(body) {
Ok(v) => v,
Err(e) => return http_text(400, &e.to_string()),
};
let base = base_url.trim_end_matches('/');
let mut results = alloc::vec::Vec::with_capacity(pointers.len());
for p in pointers {
if !is_valid_lfs_oid(&p.oid) {
results.push(ObjectResult {
oid: p.oid,
size: p.size,
download: None,
upload: None,
error: Some("invalid object id".to_string()),
});
continue;
}
let href = alloc::format!("{base}/lfs/{}", p.oid);
let (download, upload, error) = match operation {
Operation::Download => {
if store.contains(&p.oid) {
(Some(action(href)), None, None)
} else {
(None, None, Some("object not found".to_string()))
}
}
Operation::Upload => {
if store.contains(&p.oid) {
(None, None, None) } else {
(None, Some(action(href)), None)
}
}
};
results.push(ObjectResult {
oid: p.oid,
size: p.size,
download,
upload,
error,
});
}
HttpResponse {
status: 200,
content_type: "application/vnd.git-lfs+json".into(),
body: batch::build_response(&results).into_bytes(),
}
}
fn handle_download<V: Vfs>(store: &LfsStore<V>, oid: &str) -> HttpResponse {
match store.read(oid) {
Ok(content) => HttpResponse {
status: 200,
content_type: "application/octet-stream".into(),
body: content,
},
Err(_) => http_text(404, "object not found"),
}
}
fn handle_upload<V: Vfs>(store: &LfsStore<V>, oid: &str, body: &[u8]) -> HttpResponse {
let pointer = Pointer::for_content(body);
if pointer.oid != oid {
return http_text(422, "uploaded content does not match oid");
}
match store.write_verified(&pointer, body) {
Ok(()) => http_text(200, "ok"),
Err(_) => http_text(500, "internal error"),
}
}
fn action(href: alloc::string::String) -> Action {
Action {
href,
headers: alloc::vec::Vec::new(),
}
}
fn http_text(status: u16, msg: &str) -> HttpResponse {
HttpResponse {
status,
content_type: "text/plain".into(),
body: msg.as_bytes().to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::StdFs;
#[test]
fn batch_download_and_object_transfer() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-lfssrv-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = LfsStore::new(StdFs::new(&dir));
let content = b"the large object bytes";
let pointer = store.write(content).unwrap();
let req = batch::build_request(Operation::Download, core::slice::from_ref(&pointer));
let resp = handle_lfs_http(
&store,
"POST",
"/r.git/info/lfs/objects/batch",
"https://host/r.git/info/lfs",
req.as_bytes(),
);
assert_eq!(resp.status, 200);
let results = batch::parse_response(core::str::from_utf8(&resp.body).unwrap()).unwrap();
let href = &results[0].download.as_ref().unwrap().href;
assert_eq!(
href,
&alloc::format!("https://host/r.git/info/lfs/lfs/{}", pointer.oid)
);
let get = handle_lfs_http(&store, "GET", href, "https://host/r.git/info/lfs", &[]);
assert_eq!(get.status, 200);
assert_eq!(get.body, content);
let new = b"a brand new object";
let new_ptr = Pointer::for_content(new);
let put_path = alloc::format!("/r.git/info/lfs/lfs/{}", new_ptr.oid);
let put = handle_lfs_http(&store, "PUT", &put_path, "https://host", new);
assert_eq!(put.status, 200);
assert!(store.contains(&new_ptr.oid));
let missing = Pointer {
oid: "00".repeat(32),
size: 1,
};
let req2 = batch::build_request(Operation::Download, core::slice::from_ref(&missing));
let resp2 = handle_lfs_http(
&store,
"POST",
"/x/objects/batch",
"https://host",
req2.as_bytes(),
);
let r2 = batch::parse_response(core::str::from_utf8(&resp2.body).unwrap()).unwrap();
assert!(r2[0].error.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn invalid_oid_validator_rejects_traversal_and_nonhex() {
assert!(!is_valid_lfs_oid("../../etc/passwd"));
assert!(!is_valid_lfs_oid("/etc/passwd"));
assert!(!is_valid_lfs_oid(".."));
assert!(!is_valid_lfs_oid("not-hex-at-all"));
assert!(!is_valid_lfs_oid(&"AB".repeat(32))); assert!(!is_valid_lfs_oid(&"a".repeat(63))); assert!(!is_valid_lfs_oid(&"a".repeat(65))); assert!(!is_valid_lfs_oid("é".repeat(32).as_str())); assert!(is_valid_lfs_oid(&"a".repeat(64)));
assert!(is_valid_lfs_oid(&"0123456789abcdef".repeat(4)));
}
#[test]
fn traversal_oid_rejected_before_store_access() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-lfssec-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = LfsStore::new(StdFs::new(&dir));
let get = handle_lfs_http(
&store,
"GET",
"/r.git/info/lfs/lfs/../../../../etc/passwd",
"https://host",
&[],
);
assert_eq!(get.status, 422);
let bad = Pointer {
oid: "../../secret".to_string(),
size: 1,
};
let req = batch::build_request(Operation::Download, core::slice::from_ref(&bad));
let resp = handle_lfs_http(
&store,
"POST",
"/r.git/info/lfs/objects/batch",
"https://host",
req.as_bytes(),
);
let results = batch::parse_response(core::str::from_utf8(&resp.body).unwrap()).unwrap();
assert!(results[0].download.is_none());
assert!(results[0].error.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn disabled_upload_is_forbidden_403() {
let dir = std::env::temp_dir().join(alloc::format!("puregit-lfsro-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let store = LfsStore::new(StdFs::new(&dir));
let content = b"blocked object";
let ptr = Pointer::for_content(content);
let put_path = alloc::format!("/r.git/info/lfs/lfs/{}", ptr.oid);
let resp = handle_lfs_http_with(
&store,
&LfsConfig::read_only(),
"PUT",
&put_path,
"https://host",
content,
);
assert_eq!(resp.status, 403);
assert!(!store.contains(&ptr.oid));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pull_smudges_worktree_from_lfs_server() {
use crate::repository::Repository;
let srv_dir =
std::env::temp_dir().join(alloc::format!("puregit-lfspull-srv-{}", core::line!()));
let _ = std::fs::remove_dir_all(&srv_dir);
std::fs::create_dir_all(&srv_dir).unwrap();
let server_store = LfsStore::new(StdFs::new(&srv_dir));
let real: alloc::vec::Vec<u8> = (0..4096u32).map(|i| (i % 256) as u8).collect();
let pointer = server_store.write(&real).unwrap();
let cli_dir =
std::env::temp_dir().join(alloc::format!("puregit-lfspull-cli-{}", core::line!()));
let _ = std::fs::remove_dir_all(&cli_dir);
std::fs::create_dir_all(&cli_dir).unwrap();
let repo = Repository::init(&cli_dir).unwrap();
std::fs::write(cli_dir.join("asset.bin"), pointer.serialize()).unwrap();
{
let id = repo
.write_object(crate::object::ObjectType::Blob, &pointer.serialize())
.unwrap();
let mut idx = repo.index().unwrap();
idx.entries.push(crate::index::IndexEntry {
ctime: (0, 0),
mtime: (0, 0),
dev: 0,
ino: 0,
mode: 0o100644,
uid: 0,
gid: 0,
size: 0,
id,
stage: 0,
assume_valid: false,
path: b"asset.bin".to_vec(),
});
repo.write_index(&idx).unwrap();
}
let fetch = |p: &Pointer| -> crate::error::Result<alloc::vec::Vec<u8>> {
let req = batch::build_request(Operation::Download, core::slice::from_ref(p));
let resp = handle_lfs_http(
&server_store,
"POST",
"/info/lfs/objects/batch",
"http://srv/info/lfs",
req.as_bytes(),
);
let results = batch::parse_response(core::str::from_utf8(&resp.body).unwrap()).unwrap();
let action = results[0]
.download
.as_ref()
.ok_or_else(|| crate::error::Error::Protocol("no download action".into()))?;
let get = handle_lfs_http(
&server_store,
"GET",
&action.href,
"http://srv/info/lfs",
&[],
);
Ok(get.body)
};
let n = repo.lfs_smudge_worktree(fetch).unwrap();
assert_eq!(n, 1);
assert_eq!(std::fs::read(cli_dir.join("asset.bin")).unwrap(), real);
assert!(repo.lfs_store().contains(&pointer.oid));
let _ = std::fs::remove_dir_all(&srv_dir);
let _ = std::fs::remove_dir_all(&cli_dir);
}
}