Initial commit
This commit is contained in:
commit
88fd274d0c
11 changed files with 2403 additions and 0 deletions
199
src/api_client.rs
Normal file
199
src/api_client.rs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
use std::{collections::HashSet, io::Write, path::PathBuf};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
use rand::Rng;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use sha2::Digest;
|
||||
|
||||
pub const USER_AGENT: &str = "Blindforge";
|
||||
pub const MAX_PAGE: u32 = 256;
|
||||
pub const MAX_ENTRIES: u32 = 256;
|
||||
pub const MAX_FILE_SIZE: u64 = 8 * 1024 * 1024;
|
||||
pub const MAX_PATH_SIZE: usize = 1024;
|
||||
pub const MAX_URL_SIZE: usize = 256;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RepoGetTagResponse {
|
||||
tarball_url: String,
|
||||
}
|
||||
|
||||
pub fn make_client() -> Result<Client, reqwest::Error> {
|
||||
reqwest::ClientBuilder::new().user_agent(USER_AGENT).build()
|
||||
}
|
||||
|
||||
pub async fn fetch_repo_archive_url(
|
||||
client: &mut Client,
|
||||
url: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
tag: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<String, reqwest::Error> {
|
||||
let mut req = client.get(&format!("{url}/api/v1/repos/{owner}/{repo}/tags/{tag}"));
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("token {token}"));
|
||||
}
|
||||
let res: RepoGetTagResponse = req.send().await?.error_for_status()?.json().await?;
|
||||
Ok(res.tarball_url)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitTreeResponse {
|
||||
tree: Vec<GitEntry>,
|
||||
truncated: bool,
|
||||
total_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitEntry {
|
||||
path: String,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
size: u64,
|
||||
url: String,
|
||||
}
|
||||
|
||||
async fn fetch_repo_tree_at_commit_page(
|
||||
client: &mut Client,
|
||||
url: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
commit_hash: &str,
|
||||
token: Option<&str>,
|
||||
page: u32,
|
||||
) -> Result<GitTreeResponse, reqwest::Error> {
|
||||
let mut req = client.get(&format!(
|
||||
"{url}/api/v1/repos/{owner}/{repo}/git/trees/{commit_hash}?recursive=true&page={page}"
|
||||
));
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("token {token}"));
|
||||
}
|
||||
// TODO ensure we don't use too much memory
|
||||
req.send().await?.error_for_status()?.json().await
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FetchRepoError {
|
||||
CannotCreateDir(std::io::Error),
|
||||
TooManyEntries,
|
||||
Reqwest(reqwest::Error),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for FetchRepoError {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Self::Reqwest(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RepoIndex {
|
||||
files: Vec<RepoIndexFile>,
|
||||
}
|
||||
|
||||
pub struct RepoIndexFile {
|
||||
path: String,
|
||||
url: String,
|
||||
}
|
||||
|
||||
pub async fn fetch_repo_tree_index_at_commit(
|
||||
client: &mut Client,
|
||||
url: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
commit_hash: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<RepoIndex, FetchRepoError> {
|
||||
let mut repo_index = RepoIndex::default();
|
||||
let mut count: u32 = 0;
|
||||
for page in 1..MAX_PAGE {
|
||||
let res =
|
||||
fetch_repo_tree_at_commit_page(client, url, owner, repo, commit_hash, token, page)
|
||||
.await?;
|
||||
count = count.saturating_add(res.total_count);
|
||||
if count > MAX_ENTRIES || res.total_count > MAX_ENTRIES {
|
||||
return Err(FetchRepoError::TooManyEntries);
|
||||
}
|
||||
if res.tree.is_empty() {
|
||||
break;
|
||||
}
|
||||
for entry in res.tree {
|
||||
if entry.entry_type.as_str() == "blob"
|
||||
&& entry.size <= MAX_FILE_SIZE
|
||||
&& entry.path.len() <= MAX_PATH_SIZE
|
||||
&& entry.url.len() <= MAX_URL_SIZE
|
||||
{
|
||||
repo_index.files.push(RepoIndexFile {
|
||||
path: entry.path,
|
||||
url: entry.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !res.truncated || count >= res.total_count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(repo_index)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GitBlob {
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub async fn fetch_repo_files(
|
||||
config: &Config,
|
||||
client: &mut Client,
|
||||
repo_index: &RepoIndex,
|
||||
token: Option<&str>,
|
||||
) -> Result<(), FetchRepoError> {
|
||||
let mut hasher = sha2::Sha256::default();
|
||||
|
||||
let repo_key: [u8; 32] = rand::rng().random();
|
||||
hasher.update(&repo_key);
|
||||
let repo_id: [u8; 32] = hasher.finalize_reset().into();
|
||||
let mut repo_id_str = [0; 24];
|
||||
base64_turbo::URL_SAFE
|
||||
.encode_into(&repo_id[0..16], &mut repo_id_str)
|
||||
.expect("unreachable");
|
||||
let repo_id_str = str::from_utf8(&repo_id_str).expect("unreachable");
|
||||
|
||||
let repo_dir = PathBuf::from(&config.data_dir).join(repo_id_str);
|
||||
std::fs::create_dir(&repo_dir).map_err(FetchRepoError::CannotCreateDir)?;
|
||||
|
||||
let mut file_index = HashSet::new();
|
||||
|
||||
for file in repo_index.files.iter() {
|
||||
let mut req = client.get(&file.url);
|
||||
if let Some(token) = token {
|
||||
req = req.header("Authorization", format!("token {token}"));
|
||||
}
|
||||
// TODO ensure we don't use too much memory
|
||||
let blob: GitBlob = req.send().await?.error_for_status()?.json().await?;
|
||||
if base64_turbo::STANDARD.estimate_decoded_len(blob.content.len()) as u64 > MAX_FILE_SIZE {
|
||||
continue;
|
||||
}
|
||||
if let Ok(content) = base64_turbo::STANDARD.decode(&blob.content) {
|
||||
hasher.update(&content);
|
||||
let file_name: [u8; 32] = hasher.finalize_reset().into();
|
||||
if file_index.insert(file_name) {
|
||||
let mut file_name_str = [0; 44];
|
||||
base64_turbo::URL_SAFE
|
||||
.encode_into(&file_name, &mut file_name_str)
|
||||
.expect("unreachable");
|
||||
let file_name_str = str::from_utf8(&file_name_str).expect("unreachable");
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(repo_dir.join(file_name_str))
|
||||
{
|
||||
file.write_all(&content).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue