use std::io::{Cursor, Read};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::{Client, Response, Url};
use serde::Deserialize;
use zip::ZipArchive;
use crate::domain::errors::{AgentError, AgentResult, ErrorCode};
use crate::domain::skills::{SkillFile, SkillSource};
use super::skill_store::{MAX_FILE_BYTES, MAX_FILES, MAX_TOTAL_BYTES};
const GITHUB_API_BASE: &str = "https://api.github.com";
const MAX_COMMIT_RESPONSE_BYTES: usize = 64 * 1024;
const MAX_ARCHIVE_BYTES: usize = 20 * 1024 * 1024;
pub(crate) struct FetchedSkillArchive {
pub(crate) revision: String,
pub(crate) bytes: Vec<u8>,
}
#[async_trait]
pub(crate) trait SkillSourceFetcher: Send + Sync {
async fn fetch(&self, source: &SkillSource) -> AgentResult<FetchedSkillArchive>;
}
pub(super) fn github_fetcher(client: Client) -> Arc<dyn SkillSourceFetcher> {
let api_base = Url::parse(GITHUB_API_BASE).expect("valid built-in GitHub API URL");
Arc::new(GithubSourceFetcher { client, api_base })
}
#[cfg(test)]
pub(super) fn github_fetcher_with_base(
client: Client,
api_base: &str,
) -> AgentResult<Arc<dyn SkillSourceFetcher>> {
let api_base = Url::parse(api_base).map_err(|_| download_failed())?;
Ok(Arc::new(GithubSourceFetcher { client, api_base }))
}
struct GithubSourceFetcher {
client: Client,
api_base: Url,
}
#[derive(Deserialize)]
struct GithubCommit {
sha: String,
}
#[async_trait]
impl SkillSourceFetcher for GithubSourceFetcher {
async fn fetch(&self, source: &SkillSource) -> AgentResult<FetchedSkillArchive> {
let (owner, repository) = parse_github_repository(&source.repository)?;
if source.revision.trim().is_empty() {
return Err(download_failed());
}
let commit_url = github_api_url(
&self.api_base,
&["repos", &owner, &repository, "commits", &source.revision],
)?;
let commit_bytes = read_bounded_response(
self.client
.get(commit_url)
.send()
.await
.map_err(|_| download_failed())?,
MAX_COMMIT_RESPONSE_BYTES,
)
.await?;
let commit: GithubCommit =
serde_json::from_slice(&commit_bytes).map_err(|_| download_failed())?;
if !(40..=64).contains(&commit.sha.len())
|| !commit.sha.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err(download_failed());
}
let archive_url = github_api_url(
&self.api_base,
&["repos", &owner, &repository, "zipball", &commit.sha],
)?;
let bytes = read_bounded_response(
self.client
.get(archive_url)
.send()
.await
.map_err(|_| download_failed())?,
MAX_ARCHIVE_BYTES,
)
.await?;
Ok(FetchedSkillArchive {
revision: commit.sha,
bytes,
})
}
}
pub(super) fn extract_archive(bytes: &[u8], source_directory: &str) -> AgentResult<Vec<SkillFile>> {
let source_directory = validated_relative_path(source_directory)?;
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(|_| skill_invalid())?;
let mut archive_root: Option<PathBuf> = None;
let mut files = Vec::new();
let mut total = 0usize;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|_| skill_invalid())?;
if entry.name().contains('\\') || entry.name().contains('\0') {
return Err(skill_invalid());
}
let enclosed = entry
.enclosed_name()
.ok_or_else(skill_invalid)?
.to_path_buf();
let mut components = enclosed.components();
let root = match components.next() {
Some(Component::Normal(root)) => PathBuf::from(root),
_ => return Err(skill_invalid()),
};
if archive_root.as_ref().is_some_and(|known| known != &root) {
return Err(skill_invalid());
}
archive_root.get_or_insert(root);
if entry.is_symlink() {
return Err(skill_invalid());
}
if entry.unix_mode().is_some_and(|mode| {
let file_type = mode & 0o170000;
if entry.is_dir() {
file_type != 0 && file_type != 0o040000
} else {
file_type != 0 && file_type != 0o100000
}
}) {
return Err(skill_invalid());
}
let relative: PathBuf = components.collect();
let Ok(extracted) = relative.strip_prefix(&source_directory) else {
continue;
};
if extracted.as_os_str().is_empty() {
if entry.is_dir() {
continue;
}
return Err(skill_invalid());
}
if entry.is_dir() {
continue;
}
if !entry.is_file() || files.len() == MAX_FILES || entry.size() > MAX_FILE_BYTES as u64 {
return Err(skill_invalid());
}
let path = extracted.to_str().ok_or_else(skill_invalid)?.to_owned();
validated_relative_path(&path)?;
let size = usize::try_from(entry.size()).map_err(|_| skill_invalid())?;
total = total
.checked_add(size)
.filter(|total| *total <= MAX_TOTAL_BYTES)
.ok_or_else(skill_invalid)?;
let executable = entry.unix_mode().is_some_and(|mode| mode & 0o111 != 0);
let mut contents = Vec::with_capacity(size);
(&mut entry)
.take((MAX_FILE_BYTES + 1) as u64)
.read_to_end(&mut contents)
.map_err(|_| skill_invalid())?;
if contents.len() != size {
return Err(skill_invalid());
}
files.push(SkillFile {
path,
bytes: contents,
executable,
});
}
Ok(files)
}
fn validated_relative_path(raw: &str) -> AgentResult<PathBuf> {
if raw.is_empty() || raw.contains('\0') || raw.contains('\\') {
return Err(skill_invalid());
}
let path = Path::new(raw);
if path.is_absolute()
|| path.components().any(
|component| !matches!(component, Component::Normal(segment) if !segment.is_empty()),
)
{
return Err(skill_invalid());
}
Ok(path.to_path_buf())
}
fn parse_github_repository(raw: &str) -> AgentResult<(String, String)> {
let url = Url::parse(raw).map_err(|_| download_failed())?;
if url.scheme() != "https"
|| url.host_str() != Some("github.com")
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(download_failed());
}
let segments: Vec<_> = url
.path_segments()
.ok_or_else(download_failed)?
.filter(|segment| !segment.is_empty())
.collect();
if segments.len() != 2
|| segments
.iter()
.any(|segment| *segment == "." || *segment == "..")
{
return Err(download_failed());
}
Ok((
segments[0].to_owned(),
segments[1].trim_end_matches(".git").to_owned(),
))
}
fn github_api_url(base: &Url, segments: &[&str]) -> AgentResult<Url> {
let mut url = base.clone();
url.set_query(None);
url.set_fragment(None);
{
let mut path = url.path_segments_mut().map_err(|_| download_failed())?;
path.pop_if_empty();
path.extend(segments);
}
Ok(url)
}
async fn read_bounded_response(response: Response, limit: usize) -> AgentResult<Vec<u8>> {
if !response.status().is_success() {
return Err(download_failed());
}
if response
.content_length()
.is_some_and(|length| length > limit as u64)
{
return Err(download_failed());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| download_failed())?;
let next = bytes
.len()
.checked_add(chunk.len())
.filter(|length| *length <= limit)
.ok_or_else(download_failed)?;
bytes.reserve(next - bytes.len());
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}
fn skill_invalid() -> AgentError {
AgentError::new(ErrorCode::SkillInvalid, "skill is invalid")
}
fn download_failed() -> AgentError {
AgentError::new(ErrorCode::SkillDownloadFailed, "skill download failed")
}