regy 0.1.1

Private-by-default desktop agent for the Regy web interface
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")
}