diff --git a/Cargo.toml b/Cargo.toml index c23cea5a..a98adf7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,17 @@ uuid = { version = "1.18.1", features = ["v4"] } tokio = { version = "1.47.1", features = ["fs"] } bincode = { version = "2.0.1", features = ["serde"] } axum = { version = "0.8.6", features = ["macros", "json"] } +async-trait = "0.1.83" +futures = "0.3.31" +tokio-stream = "0.1.17" +http = "1.2.0" +base64 = "0.22.1" +# SSH server dependencies +russh = "0.54.6" +russh-keys = "0.49.2" +hyper = "1.5.1" +async-stream = "0.3.6" +anyhow = "1.0.93" [dev-dependencies] diff --git a/docs/PROTOCOL_ABSTRACTION.md b/docs/PROTOCOL_ABSTRACTION.md new file mode 100644 index 00000000..2a6f040a --- /dev/null +++ b/docs/PROTOCOL_ABSTRACTION.md @@ -0,0 +1,390 @@ +## Git Protocol Abstraction in git-internal + +The git-internal library provides a clean, transport-agnostic abstraction for Git smart protocol operations. This document outlines how the protocol abstraction works and how it separates concerns between transport layers, business logic, and Git protocol handling. + +### 1. Architecture Overview + +The git-internal library implements a layered architecture that cleanly separates different concerns: + + • **Transport Layer**: HTTP and SSH adapters that handle transport-specific details + • **Protocol Layer**: Core Git smart protocol implementation that is transport-agnostic + • **Business Logic Layer**: Trait-based abstractions for repository access and authentication + • **Storage Layer**: Internal Git object handling and pack file operations + +This separation allows the same Git protocol logic to work across different transports and integrate with any storage backend or business system. + +### 2. Core Abstractions + +#### (1) RepositoryAccess Trait + +The `RepositoryAccess` trait provides a clean interface for storage operations without exposing Git protocol details: + +```rust +#[async_trait] +pub trait RepositoryAccess: Send + Sync + Clone { + // Basic repository operations + async fn get_repository_refs(&self, repo_path: &str) -> Result, ProtocolError>; + async fn has_object(&self, repo_path: &str, object_hash: &str) -> Result; + async fn get_object(&self, repo_path: &str, object_hash: &str) -> Result, ProtocolError>; + async fn store_pack_data(&self, repo_path: &str, pack_data: &[u8]) -> Result<(), ProtocolError>; + + // Reference management + async fn update_reference(&self, repo_path: &str, ref_name: &str, old_hash: Option<&str>, new_hash: &str) -> Result<(), ProtocolError>; + + // Pack operations + async fn get_objects_for_pack(&self, repo_path: &str, wants: &[String], haves: &[String]) -> Result, ProtocolError>; + + // Repo-specific utilities + async fn has_default_branch(&self, repo_path: &str) -> Result; + async fn post_receive_hook(&self, repo_path: &str) -> Result<(), ProtocolError>; + + // Optional helpers with default impls (can be overridden): + // - get_blob/get_commit/get_tree + // - commit_exists + // - handle_pack_objects(repo_path, commits, trees, blobs) +} +``` + +This trait abstracts away all storage implementation details, allowing integration with any backend (filesystem, database, cloud storage, etc.). + +#### (2) AuthenticationService Trait + +The `AuthenticationService` trait handles authentication for both HTTP and SSH transports: + +```rust +#[async_trait] +pub trait AuthenticationService: Send + Sync { + /// Authenticate HTTP request using headers + async fn authenticate_http( + &self, + headers: &std::collections::HashMap + ) -> Result<(), ProtocolError>; + + /// Authenticate SSH connection using public key + async fn authenticate_ssh(&self, username: &str, public_key: &[u8]) -> Result<(), ProtocolError>; +} +``` + +This allows for flexible authentication strategies while keeping the protocol layer agnostic to authentication details. + +### 3. Transport Layer Abstraction + +#### (1) HTTP Transport Handler + +The `HttpGitHandler` provides HTTP-specific request handling: + +```rust +pub struct HttpGitHandler { + protocol: GitProtocol, +} + +impl HttpGitHandler { + // Handle HTTP info/refs requests + pub async fn handle_info_refs(&mut self, request_path: &str, query: &str) -> Result<(Vec, &'static str), ProtocolError>; + + // Handle HTTP upload-pack requests (clone/fetch) + pub async fn handle_upload_pack( + &mut self, + request_path: &str, + request_body: &[u8], + ) -> Result< + ( + std::pin::Pin> + Send>>, + &'static str, + ), + ProtocolError, + >; + + // Handle HTTP receive-pack requests (push) + pub async fn handle_receive_pack( + &mut self, + request_path: &str, + request_stream: std::pin::Pin> + Send>>, + ) -> Result< + ( + std::pin::Pin> + Send>>, + &'static str, + ), + ProtocolError, + >; + + // Authenticate HTTP request using headers (call before handle_*) + pub async fn authenticate_http(&self, headers: &std::collections::HashMap) -> Result<(), ProtocolError>; +} +``` + +The HTTP handler includes utility functions for: + +- Extracting repository paths from URLs +- Parsing query parameters +- Setting appropriate content types +- Validating Git requests + +#### (2) SSH Transport Handler + +The `SshGitHandler` provides SSH-specific command handling: + +```rust +pub struct SshGitHandler { + protocol: GitProtocol, +} + +impl SshGitHandler { + // Handle git-upload-pack command + pub async fn handle_upload_pack(&mut self, repo_path: &str, request_data: &[u8]) -> Result> + Send>>, ProtocolError>; + + // Handle git-receive-pack command + pub async fn handle_receive_pack( + &mut self, + repo_path: &str, + request_stream: std::pin::Pin> + Send>>, + ) -> Result> + Send>>, ProtocolError>; + + // Handle info/refs for SSH + pub async fn handle_info_refs(&mut self, repo_path: &str, service: &str) -> Result, ProtocolError>; + + // Authenticate SSH session (call once after handshake) + pub async fn authenticate_ssh(&self, username: &str, public_key: &[u8]) -> Result<(), ProtocolError>; +} +``` + +The SSH handler includes utility functions for: + +- Parsing SSH command lines +- Validating Git SSH commands +- Extracting repository paths from arguments + +### 4. Protocol Layer Implementation + +#### (1) GitProtocol Core + +The `GitProtocol` struct provides the main transport-agnostic interface: + +```rust +pub struct GitProtocol { + smart_protocol: SmartProtocol, +} +``` + +This struct delegates to `SmartProtocol` for the actual Git protocol implementation while providing a clean public API. + +Authentication helpers are also exposed to keep adapters minimal: + +```rust +impl GitProtocol { + pub async fn authenticate_http(&self, headers: &std::collections::HashMap) -> Result<(), ProtocolError>; + pub async fn authenticate_ssh(&self, username: &str, public_key: &[u8]) -> Result<(), ProtocolError>; +} +``` + +#### (2) SmartProtocol Implementation + +The `SmartProtocol` handles all Git smart protocol details: + +- **info/refs**: Advertises repository capabilities and references +- **upload-pack**: Handles fetch/clone operations with pack generation +- **receive-pack**: Handles push operations with pack unpacking + +The protocol implementation is completely transport-agnostic and uses the trait abstractions for all external dependencies. + +### 5. Pack File Handling + +The library includes comprehensive pack file support: + +#### (1) PackGenerator + +Handles pack file generation for upload-pack operations: + +```rust +pub struct PackGenerator<'a, R: RepositoryAccess> { + repo_access: &'a R, +} +``` + +Features: + +- Full pack generation for initial clones +- Incremental pack generation for fetches +- Efficient object traversal and collection +- Streaming pack output + +#### (2) Pack Decoding + +Built on the internal pack module for robust pack file handling: + +- Delta object reconstruction +- Streaming pack decoding +- Memory-efficient processing +- Error recovery and validation + +### 6. Integration Benefits + +#### (1) Transport Independence + +The same Git protocol logic works across: + +- HTTP/HTTPS web servers +- SSH servers +- Custom transport protocols +- Local file system access + +#### (2) Business Logic Separation + +The trait-based design allows: + +- Integration with any storage backend +- Custom authentication strategies +- Flexible repository management +- Easy testing and mocking + +#### (3) Framework Agnostic + +The library doesn't depend on: + +- Specific web frameworks (Axum, Warp, etc.) +- SSH libraries +- Database systems +- Authentication providers + +### 7. Usage Example + +```rust +use git_internal::{GitProtocol, RepositoryAccess, AuthenticationService}; + +// Implement traits for your specific backend +struct MyRepository; +struct MyAuth; + +#[async_trait] +impl RepositoryAccess for MyRepository { + // Implement storage operations... +} + +#[async_trait] +impl AuthenticationService for MyAuth { + // Implement authentication... +} + +// Use with HTTP +let http_handler = HttpGitHandler::new(MyRepository, MyAuth); +// Enforce auth at the entry (framework provides headers) +http_handler.authenticate_http(&headers).await?; +let response = http_handler.handle_info_refs("/repo.git/info/refs", "service=git-upload-pack").await?; + +// Use with SSH +let ssh_handler = SshGitHandler::new(MyRepository, MyAuth); +// Authenticate once after SSH handshake +ssh_handler.authenticate_ssh(username, public_key).await?; +let response = ssh_handler.handle_upload_pack("repo.git", &request_data).await?; + +// Error handling +// On authentication failure, methods return ProtocolError::Unauthorized("...") +``` + +### 8. Advantages + +The abstraction provides significant benefits: + + • **Reduced Coupling**: Business logic is separated from Git protocol details + • **Improved Testability**: Each layer can be tested independently + • **Enhanced Reusability**: The same protocol logic works across different systems + • **Simplified Maintenance**: Changes to transport or storage don't affect protocol logic + • **Performance-Oriented**: Optimized pack handling and streaming operations + +### 9. Design Notes + +- Removed the default `update_ref` method from `RepositoryAccess` to prevent misuse. Callers should always use `update_reference(repo_path, ...)` with an explicit repository path. +- Corrected examples to match the implementation: + - `AuthenticationService::{authenticate_http, authenticate_ssh}` return `Result<(), ProtocolError>`. + - `RepositoryAccess` includes `has_default_branch` and `post_receive_hook` utilities. + - Optional helpers exist with default implementations: `get_blob`, `get_commit`, `get_tree`, `commit_exists`, and `handle_pack_objects(repo_path, ...)`. + +## 10. Capabilities & Feature Matrix + +This section summarizes supported capabilities and how they map to transport handlers: + +- Capability advertisement: `info/refs` exposes negotiated capability lists (`COMMON_CAP_LIST`, `UPLOAD_CAP_LIST`, `RECEIVE_CAP_LIST`). +- LFS advertisement: `COMMON_CAP_LIST` includes `lfs`; negotiation is supported, but object transfer/storage is application-layer (proxy/pass-through only). +- Side-band channels: multiplexed streams supported via `SmartProtocol::build_side_band_format`. +- Pack features: thin-pack, ofs-delta, shallow clones handled by pack encode/decode modules. +- Reference updates: atomic update semantics enforced via `update_reference(repo_path, old, new)`. +- Authentication gate: handlers call `authenticate_http` / `authenticate_ssh` prior to serving operations. + +Supported capability constants: + +```rust +pub const RECEIVE_CAP_LIST: &str = + "report-status report-status-v2 delete-refs quiet atomic no-thin "; +pub const COMMON_CAP_LIST: &str = + "side-band-64k ofs-delta lfs agent=git-internal/0.1.0"; +pub const UPLOAD_CAP_LIST: &str = + "multi_ack_detailed no-done include-tag "; +``` + +Note: trailing spaces in `RECEIVE_CAP_LIST` and `UPLOAD_CAP_LIST` are intentional; they concatenate with `COMMON_CAP_LIST` in `info/refs` advertisement. + +## 11. Error Handling & Mapping + +Protocol errors are framework-agnostic and live under `protocol/types.rs`: + +- Errors represent Git protocol semantics (e.g., `InvalidService`, `RepositoryNotFound`, `InvalidRequest`). +- Storage and parsing failures are wrapped using helpers like `repository_error(...)`. +- Transport adapters are responsible for mapping errors to framework responses. + +Example adapter-side mapping (do not couple the library to HTTP frameworks): + +```rust +// In application layer (e.g. HTTP framework adapter) +fn map_protocol_error_to_http(e: ProtocolError) -> http::StatusCode { + use http::StatusCode; + match e { + ProtocolError::InvalidService(_) => StatusCode::BAD_REQUEST, + ProtocolError::RepositoryNotFound(_) => StatusCode::NOT_FOUND, + ProtocolError::InvalidRequest(_) => StatusCode::BAD_REQUEST, + ProtocolError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + ProtocolError::RepositoryError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } +} +``` + +## 12. Security & Performance Considerations + +- Input validation: strict parsing for service/query/path; reject non-Git endpoints early. +- Resource limits: stream-based pack processing to avoid large memory spikes; consider timeouts and rate limiting at transport layer. +- DoS resilience: cap negotiation and bounded buffering to prevent unbounded allocations. +- Authentication: perform auth before any expensive operations; prefer fail-fast. +- Logging: use `tracing` for structured logs; avoid leaking sensitive data in errors. + +## 13. Testing Strategy & CI + +- Unit tests: cover pkt-line parsing, capability negotiation, ref command parsing. +- Property tests: object parsing and hash invariants using `quickcheck` where applicable. +- Integration tests: pack encode/decode streams on curated datasets (can be long-running). +- CI pipeline: GitHub Actions runs `cargo check`, `cargo clippy -- -D warnings`, and `cargo test`. Long-running tests may be split or marked for scheduled runs if needed. + +## 14. Adoption Guide (Trait-based Integration) + +Steps to integrate with any business system: + +- Implement `RepositoryAccess` for your storage backend: + - Provide refs/object IO, pack storage, ref updates, and optional helpers. +- Implement `AuthenticationService` for your auth strategy: + - Validate HTTP headers or SSH public keys. +- Wire transport adapters: + - HTTP: construct `HttpGitHandler` and route `info/refs`, `upload-pack`, `receive-pack`. + - SSH: construct `SshGitHandler` and dispatch `git-upload-pack` / `git-receive-pack`. +- Keep error mapping in the application layer to stay framework-agnostic. + +Example skeleton: + +```rust +let repo = MyRepoBackend::new(); +let auth = MyAuthService::new(); +let mut http = HttpGitHandler::new(repo.clone(), auth.clone()); +http.authenticate_http(&headers).await?; +let (bytes, content_type) = http.handle_info_refs("/repo.git/info/refs", "service=git-upload-pack").await?; + +let mut ssh = SshGitHandler::new(repo, auth); +ssh.authenticate_ssh(user, key).await?; +let stream = ssh.handle_upload_pack("repo.git", &request).await?; +``` diff --git a/src/hash.rs b/src/hash.rs index 85e5c898..9d22ca89 100644 --- a/src/hash.rs +++ b/src/hash.rs @@ -108,7 +108,7 @@ impl SHA1 { /// Calculate the SHA-1 hash of the byte slice, then create a Hash value pub fn new(data: &[u8]) -> SHA1 { let h = sha1::Sha1::digest(data); - SHA1::from_bytes(h.as_slice()) + SHA1::from_bytes(h.as_ref()) } /// Create a Hash from the object type and data /// This function is used to create a SHA1 hash from the object type and data. diff --git a/src/lib.rs b/src/lib.rs index a76bbaf9..0216f8a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! Git-Internal: a high-performance Rust library for Git objects and pack files—encode/decode, delta (ref/offset/zstd), +//! Git-Internal: a high-performance Rust library for Git objects and pack files—encode/decode, delta (ref/offset/zstd), //! caching, streaming, and sync/async pipelines. //! //! Goals @@ -33,7 +33,13 @@ pub mod errors; pub mod hash; pub mod internal; +pub mod protocol; pub mod utils; +mod delta; mod zstdelta; -mod delta; \ No newline at end of file + +// Core traits and types that external users need to implement/use +pub use protocol::{ + AuthenticationService, GitProtocol, ProtocolError, RepositoryAccess, ServiceType, +}; diff --git a/src/protocol/core.rs b/src/protocol/core.rs new file mode 100644 index 00000000..c99ef8c3 --- /dev/null +++ b/src/protocol/core.rs @@ -0,0 +1,270 @@ +//! Core Git protocol implementation +//! +//! This module provides the main `GitProtocol` struct and `RepositoryAccess` trait +//! that form the core interface of the git-internal library. + +use async_trait::async_trait; +use bytes::Bytes; +use futures::stream::StreamExt; +use std::collections::HashMap; +use std::str::FromStr; + +use crate::hash::SHA1; +use crate::internal::object::ObjectTrait; + +use super::smart::SmartProtocol; +use super::types::{ProtocolError, ProtocolStream, ServiceType}; + +/// Repository access trait for storage operations +/// +/// This trait only handles storage-level operations, not Git protocol details. +/// The git-internal library handles all Git protocol formatting and parsing. +#[async_trait] +pub trait RepositoryAccess: Send + Sync + Clone { + /// Get repository references as raw (name, hash) pairs + async fn get_repository_refs(&self) -> Result, ProtocolError>; + + /// Check if an object exists in the repository + async fn has_object(&self, object_hash: &str) -> Result; + + /// Get raw object data by hash + async fn get_object(&self, object_hash: &str) -> Result, ProtocolError>; + + /// Store pack data in the repository + async fn store_pack_data(&self, pack_data: &[u8]) -> Result<(), ProtocolError>; + + /// Update a single reference + async fn update_reference( + &self, + ref_name: &str, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result<(), ProtocolError>; + + /// Get objects needed for pack generation + async fn get_objects_for_pack( + &self, + wants: &[String], + haves: &[String], + ) -> Result, ProtocolError>; + + /// Check if repository has a default branch + async fn has_default_branch(&self) -> Result; + + /// Post-receive hook after successful push + async fn post_receive_hook(&self) -> Result<(), ProtocolError>; + + /// Get blob data by hash + /// + /// Default implementation parses the object data using the internal object module. + /// Override this method if you need custom blob handling logic. + async fn get_blob( + &self, + object_hash: &str, + ) -> Result { + let data = self.get_object(object_hash).await?; + let hash = SHA1::from_str(object_hash) + .map_err(|e| ProtocolError::repository_error(format!("Invalid hash format: {}", e)))?; + + crate::internal::object::blob::Blob::from_bytes(&data, hash) + .map_err(|e| ProtocolError::repository_error(format!("Failed to parse blob: {}", e))) + } + + /// Get commit data by hash + /// + /// Default implementation parses the object data using the internal object module. + /// Override this method if you need custom commit handling logic. + async fn get_commit( + &self, + commit_hash: &str, + ) -> Result { + let data = self.get_object(commit_hash).await?; + let hash = SHA1::from_str(commit_hash) + .map_err(|e| ProtocolError::repository_error(format!("Invalid hash format: {}", e)))?; + + crate::internal::object::commit::Commit::from_bytes(&data, hash) + .map_err(|e| ProtocolError::repository_error(format!("Failed to parse commit: {}", e))) + } + + /// Get tree data by hash + /// + /// Default implementation parses the object data using the internal object module. + /// Override this method if you need custom tree handling logic. + async fn get_tree( + &self, + tree_hash: &str, + ) -> Result { + let data = self.get_object(tree_hash).await?; + let hash = SHA1::from_str(tree_hash) + .map_err(|e| ProtocolError::repository_error(format!("Invalid hash format: {}", e)))?; + + crate::internal::object::tree::Tree::from_bytes(&data, hash) + .map_err(|e| ProtocolError::repository_error(format!("Failed to parse tree: {}", e))) + } + + /// Check if a commit exists + /// + /// Default implementation checks object existence and validates it's a commit. + /// Override this method if you have more efficient commit existence checking. + async fn commit_exists(&self, commit_hash: &str) -> Result { + match self.has_object(commit_hash).await { + Ok(exists) => { + if !exists { + return Ok(false); + } + + // Verify it's actually a commit by trying to parse it + match self.get_commit(commit_hash).await { + Ok(_) => Ok(true), + Err(_) => Ok(false), // Object exists but is not a valid commit + } + } + Err(e) => Err(e), + } + } + + /// Handle pack objects after unpacking + /// + /// Default implementation stores each object individually using store_pack_data. + /// Override this method if you need batch processing or custom storage logic. + async fn handle_pack_objects( + &self, + commits: Vec, + trees: Vec, + blobs: Vec, + ) -> Result<(), ProtocolError> { + // Store blobs + for blob in blobs { + let data = blob.to_data().map_err(|e| { + ProtocolError::repository_error(format!("Failed to serialize blob: {}", e)) + })?; + self.store_pack_data(&data).await.map_err(|e| { + ProtocolError::repository_error(format!("Failed to store blob {}: {}", blob.id, e)) + })?; + } + + // Store trees + for tree in trees { + let data = tree.to_data().map_err(|e| { + ProtocolError::repository_error(format!("Failed to serialize tree: {}", e)) + })?; + self.store_pack_data(&data).await.map_err(|e| { + ProtocolError::repository_error(format!("Failed to store tree {}: {}", tree.id, e)) + })?; + } + + // Store commits + for commit in commits { + let data = commit.to_data().map_err(|e| { + ProtocolError::repository_error(format!("Failed to serialize commit: {}", e)) + })?; + self.store_pack_data(&data).await.map_err(|e| { + ProtocolError::repository_error(format!( + "Failed to store commit {}: {}", + commit.id, e + )) + })?; + } + + Ok(()) + } +} + +/// Authentication service trait +#[async_trait] +pub trait AuthenticationService: Send + Sync { + /// Authenticate HTTP request + async fn authenticate_http( + &self, + headers: &std::collections::HashMap, + ) -> Result<(), ProtocolError>; + + /// Authenticate SSH public key + async fn authenticate_ssh( + &self, + username: &str, + public_key: &[u8], + ) -> Result<(), ProtocolError>; +} + +/// Transport-agnostic Git smart protocol handler +/// Main Git protocol handler +/// +/// This struct provides the core Git protocol implementation that works +/// across HTTP, SSH, and other transports. It uses SmartProtocol internally +/// to handle all Git protocol details. +pub struct GitProtocol { + smart_protocol: SmartProtocol, +} + +impl GitProtocol { + /// Create a new GitProtocol instance + pub fn new(repo_access: R, auth_service: A) -> Self { + Self { + smart_protocol: SmartProtocol::new( + super::types::TransportProtocol::Http, + repo_access, + auth_service, + ), + } + } + + /// Authenticate HTTP request before serving Git operations + pub async fn authenticate_http( + &self, + headers: &HashMap, + ) -> Result<(), ProtocolError> { + self.smart_protocol.authenticate_http(headers).await + } + + /// Authenticate SSH session before serving Git operations + pub async fn authenticate_ssh( + &self, + username: &str, + public_key: &[u8], + ) -> Result<(), ProtocolError> { + self.smart_protocol + .authenticate_ssh(username, public_key) + .await + } + + /// Set transport protocol (Http, Ssh, etc.) + pub fn set_transport(&mut self, protocol: super::types::TransportProtocol) { + self.smart_protocol.set_transport_protocol(protocol); + } + + /// Handle git info-refs request + pub async fn info_refs(&self, service: &str) -> Result, ProtocolError> { + let service_type = match service { + "git-upload-pack" => ServiceType::UploadPack, + "git-receive-pack" => ServiceType::ReceivePack, + _ => return Err(ProtocolError::invalid_service(service)), + }; + + let bytes = self.smart_protocol.git_info_refs(service_type).await?; + Ok(bytes.to_vec()) + } + + /// Handle git-upload-pack request (for clone/fetch) + pub async fn upload_pack( + &mut self, + request_data: &[u8], + ) -> Result { + let request_bytes = bytes::Bytes::from(request_data.to_vec()); + let (stream, _) = self.smart_protocol.git_upload_pack(request_bytes).await?; + Ok(Box::pin(stream.map(|data| Ok(Bytes::from(data))))) + } + + /// Handle git-receive-pack request (for push) + pub async fn receive_pack( + &mut self, + request_stream: ProtocolStream, + ) -> Result { + let result_bytes = self + .smart_protocol + .git_receive_pack_stream(request_stream) + .await?; + // Return the report status as a single-chunk stream + Ok(Box::pin(futures::stream::once(async { Ok(result_bytes) }))) + } +} diff --git a/src/protocol/http.rs b/src/protocol/http.rs new file mode 100644 index 00000000..3dab9c62 --- /dev/null +++ b/src/protocol/http.rs @@ -0,0 +1,168 @@ +use super::core::{AuthenticationService, GitProtocol, RepositoryAccess}; +use super::types::{ProtocolError, ProtocolStream}; +/// HTTP transport adapter for Git protocol +/// +/// This module provides HTTP-specific handling for Git smart protocol operations. +/// It's a thin wrapper around the core GitProtocol that handles HTTP-specific +/// request/response formatting and uses the utility functions for proper HTTP handling. +use serde::Deserialize; +use std::collections::HashMap; + +/// HTTP Git protocol handler +pub struct HttpGitHandler { + protocol: GitProtocol, +} + +impl HttpGitHandler { + /// Create a new HTTP Git handler + pub fn new(repo_access: R, auth_service: A) -> Self { + let mut protocol = GitProtocol::new(repo_access, auth_service); + protocol.set_transport(super::types::TransportProtocol::Http); + Self { protocol } + } + + /// Authenticate the HTTP request using provided headers + /// Call this before invoking handle_* methods if your server requires auth + pub async fn authenticate_http( + &self, + headers: &HashMap, + ) -> Result<(), ProtocolError> { + self.protocol.authenticate_http(headers).await + } + + /// Handle HTTP info/refs request + /// + /// Processes GET requests to /{repo}/info/refs?service=git-{service} + /// Uses extract_repo_path and get_service_from_query for proper parsing + pub async fn handle_info_refs( + &mut self, + request_path: &str, + query: &str, + ) -> Result<(Vec, &'static str), ProtocolError> { + // Validate repository path exists in request + extract_repo_path(request_path) + .ok_or_else(|| ProtocolError::InvalidRequest("Invalid repository path".to_string()))?; + + // Get service from query parameters + let service = get_service_from_query(query).ok_or_else(|| { + ProtocolError::InvalidRequest("Missing service parameter".to_string()) + })?; + + // Validate it's a Git request + if !is_git_request(request_path) { + return Err(ProtocolError::InvalidRequest( + "Not a Git request".to_string(), + )); + } + + let response_data = self.protocol.info_refs(service).await?; + let content_type = get_advertisement_content_type(service); + + Ok((response_data, content_type)) + } + + /// Handle HTTP upload-pack request + /// + /// Processes POST requests to /{repo}/git-upload-pack + pub async fn handle_upload_pack( + &mut self, + request_path: &str, + request_body: &[u8], + ) -> Result<(ProtocolStream, &'static str), ProtocolError> { + // Validate repository path exists in request + extract_repo_path(request_path) + .ok_or_else(|| ProtocolError::InvalidRequest("Invalid repository path".to_string()))?; + + // Validate it's a Git request + if !is_git_request(request_path) { + return Err(ProtocolError::InvalidRequest( + "Not a Git request".to_string(), + )); + } + + let response_stream = self.protocol.upload_pack(request_body).await?; + let content_type = get_content_type("git-upload-pack"); + + Ok((response_stream, content_type)) + } + + /// Handle HTTP receive-pack request + /// + /// Processes POST requests to /{repo}/git-receive-pack + pub async fn handle_receive_pack( + &mut self, + request_path: &str, + request_stream: ProtocolStream, + ) -> Result<(ProtocolStream, &'static str), ProtocolError> { + // Validate repository path exists in request + extract_repo_path(request_path) + .ok_or_else(|| ProtocolError::InvalidRequest("Invalid repository path".to_string()))?; + + // Validate it's a Git request + if !is_git_request(request_path) { + return Err(ProtocolError::InvalidRequest( + "Not a Git request".to_string(), + )); + } + + let response_stream = self.protocol.receive_pack(request_stream).await?; + let content_type = get_content_type("git-receive-pack"); + + Ok((response_stream, content_type)) + } +} + +/// HTTP-specific utility functions +/// Get content type for Git HTTP responses +pub fn get_content_type(service: &str) -> &'static str { + match service { + "git-upload-pack" => "application/x-git-upload-pack-result", + "git-receive-pack" => "application/x-git-receive-pack-result", + _ => "application/x-git-upload-pack-advertisement", + } +} + +/// Get content type for Git HTTP info/refs advertisement +pub fn get_advertisement_content_type(service: &str) -> &'static str { + match service { + "git-upload-pack" => "application/x-git-upload-pack-advertisement", + "git-receive-pack" => "application/x-git-receive-pack-advertisement", + _ => "application/x-git-upload-pack-advertisement", + } +} + +/// Check if request is a Git smart protocol request +pub fn is_git_request(path: &str) -> bool { + path.ends_with("/info/refs") + || path.ends_with("/git-upload-pack") + || path.ends_with("/git-receive-pack") +} + +/// Extract repository path from HTTP request path +pub fn extract_repo_path(path: &str) -> Option<&str> { + if let Some(pos) = path.rfind("/info/refs") { + Some(&path[..pos]) + } else if let Some(pos) = path.rfind("/git-upload-pack") { + Some(&path[..pos]) + } else if let Some(pos) = path.rfind("/git-receive-pack") { + Some(&path[..pos]) + } else { + None + } +} + +/// Get Git service from query parameters +pub fn get_service_from_query(query: &str) -> Option<&str> { + for param in query.split('&') { + if let Some(("service", value)) = param.split_once('=') { + return Some(value); + } + } + None +} + +/// Parameters for git info-refs request +#[derive(Debug, Deserialize)] +pub struct InfoRefsParams { + pub service: String, +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 00000000..e5666295 --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,16 @@ +/// Git Protocol Module +/// +/// This module provides a clean, minimal, and transport-agnostic Git smart protocol implementation. +/// It abstracts away the complexities of different transport layers (HTTP, SSH) and provides +/// a unified interface for Git operations. +pub mod core; +pub mod http; +pub mod pack; +pub mod smart; +pub mod ssh; +pub mod types; +pub mod utils; + +// Re-export main interfaces +pub use core::{AuthenticationService, GitProtocol, RepositoryAccess}; +pub use types::*; diff --git a/src/protocol/pack.rs b/src/protocol/pack.rs new file mode 100644 index 00000000..933cf4d6 --- /dev/null +++ b/src/protocol/pack.rs @@ -0,0 +1,481 @@ +use bytes::Bytes; +use std::collections::{HashSet, VecDeque}; +use std::io::Cursor; +use tokio; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; + +use super::core::RepositoryAccess; +use super::types::ProtocolError; +use crate::internal::object::types::ObjectType; +use crate::internal::object::{ObjectTrait, blob::Blob, commit::Commit, tree::Tree}; +use crate::internal::pack::{Pack, encode::PackEncoder, entry::Entry}; + +/// Pack generation service for Git protocol operations +/// +/// This handles the core Git pack generation logic internally within git-internal, +/// using the RepositoryAccess trait only for data access. +pub struct PackGenerator<'a, R> +where + R: RepositoryAccess, +{ + repo_access: &'a R, +} + +impl<'a, R> PackGenerator<'a, R> +where + R: RepositoryAccess, +{ + pub fn new(repo_access: &'a R) -> Self { + Self { repo_access } + } + + /// Generate a full pack containing all requested objects + pub async fn generate_full_pack( + &self, + want: Vec, + ) -> Result>, ProtocolError> { + let (tx, rx) = mpsc::channel(1024); + + // Collect all objects needed for the wanted commits + let all_objects = self.collect_all_objects(want).await?; + + // Generate pack data + tokio::spawn(async move { + if let Err(e) = Self::generate_pack_stream(all_objects, tx).await { + tracing::error!("Failed to generate pack stream: {}", e); + } + }); + + Ok(ReceiverStream::new(rx)) + } + + /// Generate an incremental pack containing only objects not in 'have' + pub async fn generate_incremental_pack( + &self, + want: Vec, + have: Vec, + ) -> Result>, ProtocolError> { + let (tx, rx) = mpsc::channel(1024); + + // Collect objects for wanted commits + let wanted_objects = self.collect_all_objects(want).await?; + + // Collect objects for have commits (to exclude) + let have_objects = self.collect_all_objects(have).await?; + + // Filter out objects that are already in 'have' + let incremental_objects = Self::filter_objects(wanted_objects, have_objects); + + // Generate pack data + tokio::spawn(async move { + if let Err(e) = Self::generate_pack_stream(incremental_objects, tx).await { + tracing::error!("Failed to generate incremental pack stream: {}", e); + } + }); + + Ok(ReceiverStream::new(rx)) + } + + /// Unpack incoming pack stream and extract objects + pub async fn unpack_stream( + &self, + pack_data: Bytes, + ) -> Result<(Vec, Vec, Vec), ProtocolError> { + use std::sync::{Arc, Mutex}; + + let commits = Arc::new(Mutex::new(Vec::new())); + let trees = Arc::new(Mutex::new(Vec::new())); + let blobs = Arc::new(Mutex::new(Vec::new())); + + let commits_clone = commits.clone(); + let trees_clone = trees.clone(); + let blobs_clone = blobs.clone(); + + // Create a Pack instance for decoding + let mut pack = Pack::new(None, None, None, true); + let mut cursor = Cursor::new(pack_data.to_vec()); + + // Decode the pack and collect entries + pack.decode( + &mut cursor, + move |entry: Entry, _offset: usize| match entry.obj_type { + ObjectType::Commit => { + if let Ok(commit) = Commit::from_bytes(&entry.data, entry.hash) { + commits_clone.lock().unwrap().push(commit); + } else { + tracing::warn!("Failed to parse commit from pack entry"); + } + } + ObjectType::Tree => { + if let Ok(tree) = Tree::from_bytes(&entry.data, entry.hash) { + trees_clone.lock().unwrap().push(tree); + } else { + tracing::warn!("Failed to parse tree from pack entry"); + } + } + ObjectType::Blob => { + if let Ok(blob) = Blob::from_bytes(&entry.data, entry.hash) { + blobs_clone.lock().unwrap().push(blob); + } else { + tracing::warn!("Failed to parse blob from pack entry"); + } + } + _ => { + tracing::warn!("Unknown object type in pack: {:?}", entry.obj_type); + } + }, + ) + .map_err(|e| ProtocolError::invalid_request(&format!("Failed to decode pack: {}", e)))?; + + // Extract the results + let commits_result = Arc::try_unwrap(commits).unwrap().into_inner().unwrap(); + let trees_result = Arc::try_unwrap(trees).unwrap().into_inner().unwrap(); + let blobs_result = Arc::try_unwrap(blobs).unwrap().into_inner().unwrap(); + + Ok((commits_result, trees_result, blobs_result)) + } + + /// Collect all objects reachable from the given commit hashes + async fn collect_all_objects( + &self, + commit_hashes: Vec, + ) -> Result<(Vec, Vec, Vec), ProtocolError> { + let mut commits = Vec::new(); + let mut trees = Vec::new(); + let mut blobs = Vec::new(); + + let mut visited_commits = HashSet::new(); + let mut visited_trees = HashSet::new(); + let mut visited_blobs = HashSet::new(); + + let mut commit_queue = VecDeque::from(commit_hashes); + + // BFS traversal of commit graph + while let Some(commit_hash) = commit_queue.pop_front() { + if visited_commits.contains(&commit_hash) { + continue; + } + visited_commits.insert(commit_hash.clone()); + + // Get commit object + let commit = self + .repo_access + .get_commit(&commit_hash) + .await + .map_err(|e| { + ProtocolError::repository_error(format!( + "Failed to get commit {}: {}", + commit_hash, e + )) + })?; + + // Add parent commits to queue + for parent in &commit.parent_commit_ids { + let parent_str = parent.to_string(); + if !visited_commits.contains(&parent_str) { + commit_queue.push_back(parent_str); + } + } + + // Collect tree objects + Box::pin(self.collect_tree_objects( + &commit.tree_id.to_string(), + &mut trees, + &mut blobs, + &mut visited_trees, + &mut visited_blobs, + )) + .await?; + + commits.push(commit); + } + + Ok((commits, trees, blobs)) + } + + /// Recursively collect tree and blob objects + async fn collect_tree_objects( + &self, + tree_hash: &str, + trees: &mut Vec, + blobs: &mut Vec, + visited_trees: &mut HashSet, + visited_blobs: &mut HashSet, + ) -> Result<(), ProtocolError> { + if visited_trees.contains(tree_hash) { + return Ok(()); + } + visited_trees.insert(tree_hash.to_string()); + + let tree = self.repo_access.get_tree(tree_hash).await.map_err(|e| { + ProtocolError::repository_error(format!("Failed to get tree {}: {}", tree_hash, e)) + })?; + + for entry in &tree.tree_items { + let entry_hash = entry.id.to_string(); + match entry.mode { + crate::internal::object::tree::TreeItemMode::Tree => { + Box::pin(self.collect_tree_objects( + &entry_hash, + trees, + blobs, + visited_trees, + visited_blobs, + )) + .await?; + } + crate::internal::object::tree::TreeItemMode::Blob + | crate::internal::object::tree::TreeItemMode::BlobExecutable => { + if !visited_blobs.contains(&entry_hash) { + visited_blobs.insert(entry_hash.clone()); + let blob = self.repo_access.get_blob(&entry_hash).await.map_err(|e| { + ProtocolError::repository_error(format!( + "Failed to get blob {}: {}", + entry_hash, e + )) + })?; + blobs.push(blob); + } + } + _ => {} + } + } + + trees.push(tree); + Ok(()) + } + + /// Filter objects to exclude those already in 'have' + fn filter_objects( + wanted: (Vec, Vec, Vec), + have: (Vec, Vec, Vec), + ) -> (Vec, Vec, Vec) { + let (wanted_commits, wanted_trees, wanted_blobs) = wanted; + let (have_commits, have_trees, have_blobs) = have; + + // Create hash sets for efficient lookup + let have_commit_hashes: HashSet = + have_commits.iter().map(|c| c.id.to_string()).collect(); + let have_tree_hashes: HashSet = + have_trees.iter().map(|t| t.id.to_string()).collect(); + let have_blob_hashes: HashSet = + have_blobs.iter().map(|b| b.id.to_string()).collect(); + + // Filter out objects that are in 'have' + let filtered_commits: Vec = wanted_commits + .into_iter() + .filter(|c| !have_commit_hashes.contains(&c.id.to_string())) + .collect(); + + let filtered_trees: Vec = wanted_trees + .into_iter() + .filter(|t| !have_tree_hashes.contains(&t.id.to_string())) + .collect(); + + let filtered_blobs: Vec = wanted_blobs + .into_iter() + .filter(|b| !have_blob_hashes.contains(&b.id.to_string())) + .collect(); + + (filtered_commits, filtered_trees, filtered_blobs) + } + + /// Generate pack stream from objects + async fn generate_pack_stream( + objects: (Vec, Vec, Vec), + tx: mpsc::Sender>, + ) -> Result<(), ProtocolError> { + let (commits, trees, blobs) = objects; + + // Convert objects to entries + let mut entries = Vec::new(); + + for commit in commits { + entries.push(Entry::from(commit)); + } + + for tree in trees { + entries.push(Entry::from(tree)); + } + + for blob in blobs { + entries.push(Entry::from(blob)); + } + + // Create PackEncoder and encode entries + let (pack_tx, mut pack_rx) = mpsc::channel(1024); + let (entry_tx, entry_rx) = mpsc::channel(1024); + let mut encoder = PackEncoder::new(entries.len(), 10, pack_tx); // window_size = 10 + + // Spawn encoding task + tokio::spawn(async move { + if let Err(e) = encoder.encode(entry_rx).await { + tracing::error!("Failed to encode pack: {}", e); + } + }); + + // Send entries to encoder + tokio::spawn(async move { + for entry in entries { + if entry_tx.send(entry).await.is_err() { + break; // Receiver dropped + } + } + // Drop sender to signal end of entries + }); + + // Forward pack data to output channel + while let Some(chunk) = pack_rx.recv().await { + if tx.send(chunk).await.is_err() { + break; // Receiver dropped + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::internal::object::blob::Blob; + use crate::internal::object::commit::Commit; + use crate::internal::object::signature::{Signature, SignatureType}; + use crate::internal::object::tree::{Tree, TreeItem, TreeItemMode}; + use async_trait::async_trait; + use bytes::Bytes; + + #[derive(Clone)] + struct DummyRepoAccess; + + #[async_trait] + impl RepositoryAccess for DummyRepoAccess { + async fn get_repository_refs(&self) -> Result, ProtocolError> { + Ok(vec![]) + } + async fn has_object(&self, _object_hash: &str) -> Result { + Ok(false) + } + async fn get_object(&self, _object_hash: &str) -> Result, ProtocolError> { + Err(ProtocolError::repository_error( + "not implemented".to_string(), + )) + } + async fn store_pack_data(&self, _pack_data: &[u8]) -> Result<(), ProtocolError> { + Ok(()) + } + async fn update_reference( + &self, + _ref_name: &str, + _old_hash: Option<&str>, + _new_hash: &str, + ) -> Result<(), ProtocolError> { + Ok(()) + } + async fn get_objects_for_pack( + &self, + _wants: &[String], + _haves: &[String], + ) -> Result, ProtocolError> { + Ok(vec![]) + } + async fn has_default_branch(&self) -> Result { + Ok(false) + } + async fn post_receive_hook(&self) -> Result<(), ProtocolError> { + Ok(()) + } + } + + #[tokio::test] + async fn test_pack_roundtrip_encode_decode() { + // Create two Blob objects + let blob1 = Blob::from_content("hello"); + let blob2 = Blob::from_content("world"); + + // Create a Tree containing two file items + let item1 = TreeItem::new(TreeItemMode::Blob, blob1.id, "hello.txt".to_string()); + let item2 = TreeItem::new(TreeItemMode::Blob, blob2.id, "world.txt".to_string()); + let tree = Tree::from_tree_items(vec![item1, item2]).unwrap(); + + // Create a Commit pointing to the Tree + let author = Signature::new( + SignatureType::Author, + "tester".to_string(), + "tester@example.com".to_string(), + ); + let committer = Signature::new( + SignatureType::Committer, + "tester".to_string(), + "tester@example.com".to_string(), + ); + let commit = Commit::new(author, committer, tree.id, vec![], "init commit"); + + // Generate pack stream + let (tx, mut rx) = mpsc::channel::>(64); + PackGenerator::::generate_pack_stream( + ( + vec![commit.clone()], + vec![tree.clone()], + vec![blob1.clone(), blob2.clone()], + ), + tx, + ) + .await + .unwrap(); + + let mut pack_bytes: Vec = Vec::new(); + while let Some(chunk) = rx.recv().await { + pack_bytes.extend_from_slice(&chunk); + } + println!("Encoded pack size: {} bytes", pack_bytes.len()); + + // Unpack the pack stream + let dummy = DummyRepoAccess; + let generator = PackGenerator::new(&dummy); + let (decoded_commits, decoded_trees, decoded_blobs) = generator + .unpack_stream(Bytes::from(pack_bytes)) + .await + .unwrap(); + + println!( + "Decoded commits: {:?}", + decoded_commits + .iter() + .map(|c| c.id.to_string()) + .collect::>() + ); + println!( + "Decoded trees: {:?}", + decoded_trees + .iter() + .map(|t| t.id.to_string()) + .collect::>() + ); + println!( + "Decoded blobs: {:?}", + decoded_blobs + .iter() + .map(|b| b.id.to_string()) + .collect::>() + ); + + // Verify object ID roundtrip consistency + assert_eq!(decoded_commits.len(), 1); + assert_eq!(decoded_trees.len(), 1); + assert_eq!(decoded_blobs.len(), 2); + + assert_eq!(decoded_commits[0].id, commit.id); + assert_eq!(decoded_trees[0].id, tree.id); + + let mut orig_blob_ids = vec![blob1.id.to_string(), blob2.id.to_string()]; + orig_blob_ids.sort(); + let mut decoded_blob_ids = decoded_blobs + .iter() + .map(|b| b.id.to_string()) + .collect::>(); + decoded_blob_ids.sort(); + assert_eq!(orig_blob_ids, decoded_blob_ids); + } +} diff --git a/src/protocol/smart.rs b/src/protocol/smart.rs new file mode 100644 index 00000000..7b71b93a --- /dev/null +++ b/src/protocol/smart.rs @@ -0,0 +1,581 @@ +use bytes::{BufMut, Bytes, BytesMut}; +use std::collections::HashMap; +use tokio_stream::wrappers::ReceiverStream; + +use super::core::{AuthenticationService, RepositoryAccess}; +use super::pack::PackGenerator; +use super::types::ProtocolError; +use super::types::{ + COMMON_CAP_LIST, Capability, LF, NUL, PKT_LINE_END_MARKER, ProtocolStream, RECEIVE_CAP_LIST, + RefCommand, RefTypeEnum, SP, ServiceType, SideBand, TransportProtocol, UPLOAD_CAP_LIST, + ZERO_ID, +}; +use super::utils::{add_pkt_line_string, build_smart_reply, read_pkt_line, read_until_white_space}; + +/// Smart Git Protocol implementation +/// +/// This struct handles the Git smart protocol operations for both HTTP and SSH transports. +/// It uses trait abstractions to decouple from specific business logic implementations. +pub struct SmartProtocol +where + R: RepositoryAccess, + A: AuthenticationService, +{ + pub transport_protocol: TransportProtocol, + pub capabilities: Vec, + pub side_band: Option, + pub command_list: Vec, + + // Trait-based dependencies + repo_storage: R, + auth_service: A, +} + +impl SmartProtocol +where + R: RepositoryAccess, + A: AuthenticationService, +{ + /// Create a new SmartProtocol instance + pub fn new(transport_protocol: TransportProtocol, repo_storage: R, auth_service: A) -> Self { + Self { + transport_protocol, + capabilities: Vec::new(), + side_band: None, + command_list: Vec::new(), + repo_storage, + auth_service, + } + } + + /// Authenticate an HTTP request using the injected auth service + pub async fn authenticate_http( + &self, + headers: &HashMap, + ) -> Result<(), ProtocolError> { + self.auth_service.authenticate_http(headers).await + } + + /// Authenticate an SSH session using username and public key + pub async fn authenticate_ssh( + &self, + username: &str, + public_key: &[u8], + ) -> Result<(), ProtocolError> { + self.auth_service + .authenticate_ssh(username, public_key) + .await + } + + /// Set transport protocol (Http, Ssh, etc.) + pub fn set_transport_protocol(&mut self, protocol: TransportProtocol) { + self.transport_protocol = protocol; + } + + /// Get git info refs for the repository, with explicit service type + pub async fn git_info_refs( + &self, + service_type: ServiceType, + ) -> Result { + let refs = + self.repo_storage.get_repository_refs().await.map_err(|e| { + ProtocolError::repository_error(format!("Failed to get refs: {}", e)) + })?; + + // Convert to the expected format (head_hash, git_refs) + let head_hash = refs + .iter() + .find(|(name, _)| { + name == "HEAD" || name.ends_with("/main") || name.ends_with("/master") + }) + .map(|(_, hash)| hash.clone()) + .unwrap_or_else(|| "0000000000000000000000000000000000000000".to_string()); + + let git_refs: Vec = refs + .into_iter() + .map(|(name, hash)| super::types::GitRef { name, hash }) + .collect(); + + // Determine capabilities based on service type + let cap_list = match service_type { + ServiceType::UploadPack => format!("{UPLOAD_CAP_LIST}{COMMON_CAP_LIST}"), + ServiceType::ReceivePack => format!("{RECEIVE_CAP_LIST}{COMMON_CAP_LIST}"), + }; + + // The stream MUST include capability declarations behind a NUL on the first ref. + let name = if head_hash == ZERO_ID { + "capabilities^{}" + } else { + "HEAD" + }; + let pkt_line = format!("{head_hash}{SP}{name}{NUL}{cap_list}{LF}"); + let mut ref_list = vec![pkt_line]; + + for git_ref in git_refs { + let pkt_line = format!("{}{}{}{}", git_ref.hash, SP, git_ref.name, LF); + ref_list.push(pkt_line); + } + + let pkt_line_stream = + build_smart_reply(self.transport_protocol, &ref_list, service_type.to_string()); + tracing::debug!("git_info_refs, return: --------> {:?}", pkt_line_stream); + Ok(pkt_line_stream) + } + + /// Handle git-upload-pack request + pub async fn git_upload_pack( + &mut self, + upload_request: Bytes, + ) -> Result<(ReceiverStream>, BytesMut), ProtocolError> { + let mut upload_request = upload_request; + let mut want: Vec = Vec::new(); + let mut have: Vec = Vec::new(); + let mut last_common_commit = String::new(); + + let mut read_first_line = false; + loop { + let (bytes_take, pkt_line) = read_pkt_line(&mut upload_request); + + if bytes_take == 0 { + break; + } + + if pkt_line.is_empty() { + break; + } + + let mut pkt_line = pkt_line; + let command = read_until_white_space(&mut pkt_line); + + match command.as_str() { + "want" => { + let hash = read_until_white_space(&mut pkt_line); + want.push(hash); + if !read_first_line { + let cap_str = String::from_utf8_lossy(&pkt_line).to_string(); + self.parse_capabilities(&cap_str); + read_first_line = true; + } + } + "have" => { + let hash = read_until_white_space(&mut pkt_line); + have.push(hash); + } + "done" => { + break; + } + _ => { + tracing::warn!("Unknown upload-pack command: {}", command); + } + } + } + + let mut protocol_buf = BytesMut::new(); + + // Create pack generator for this operation + let pack_generator = PackGenerator::new(&self.repo_storage); + + if have.is_empty() { + // Full pack + add_pkt_line_string(&mut protocol_buf, String::from("NAK\n")); + let pack_stream = pack_generator.generate_full_pack(want).await?; + return Ok((pack_stream, protocol_buf)); + } + + // Check for common commits + for hash in &have { + let exists = self.repo_storage.commit_exists(hash).await.map_err(|e| { + ProtocolError::repository_error(format!("Failed to check commit existence: {}", e)) + })?; + if exists { + add_pkt_line_string(&mut protocol_buf, format!("ACK {hash} common\n")); + if last_common_commit.is_empty() { + last_common_commit = hash.clone(); + } + } + } + + if last_common_commit.is_empty() { + // No common commits found + add_pkt_line_string(&mut protocol_buf, String::from("NAK\n")); + let pack_stream = pack_generator.generate_full_pack(want).await?; + return Ok((pack_stream, protocol_buf)); + } + + // Generate incremental pack + add_pkt_line_string( + &mut protocol_buf, + format!("ACK {last_common_commit} ready\n"), + ); + protocol_buf.put(&PKT_LINE_END_MARKER[..]); + + add_pkt_line_string(&mut protocol_buf, format!("ACK {last_common_commit} \n")); + + let pack_stream = pack_generator.generate_incremental_pack(want, have).await?; + + Ok((pack_stream, protocol_buf)) + } + + /// Parse receive pack commands from protocol bytes + pub fn parse_receive_pack_commands(&mut self, mut protocol_bytes: Bytes) { + loop { + let (bytes_take, pkt_line) = read_pkt_line(&mut protocol_bytes); + + if bytes_take == 0 { + break; + } + + if pkt_line.is_empty() { + break; + } + + let ref_command = self.parse_ref_command(&mut pkt_line.clone()); + self.command_list.push(ref_command); + } + } + + /// Handle git receive-pack operation (push) + pub async fn git_receive_pack_stream( + &mut self, + data_stream: ProtocolStream, + ) -> Result { + // Collect all pack data from stream + let mut pack_data = BytesMut::new(); + let mut stream = data_stream; + + while let Some(chunk_result) = futures::StreamExt::next(&mut stream).await { + let chunk = chunk_result + .map_err(|e| ProtocolError::invalid_request(&format!("Stream error: {}", e)))?; + pack_data.extend_from_slice(&chunk); + } + + // Create pack generator for unpacking + let pack_generator = PackGenerator::new(&self.repo_storage); + + // Unpack the received data + let (commits, trees, blobs) = pack_generator.unpack_stream(pack_data.freeze()).await?; + + // Store the unpacked objects via the repository access trait + self.repo_storage + .handle_pack_objects(commits, trees, blobs) + .await + .map_err(|e| { + ProtocolError::repository_error(format!("Failed to store pack objects: {}", e)) + })?; + + // Build status report + let mut report_status = BytesMut::new(); + add_pkt_line_string(&mut report_status, "unpack ok\n".to_owned()); + + let mut default_exist = self.repo_storage.has_default_branch().await.map_err(|e| { + ProtocolError::repository_error(format!("Failed to check default branch: {}", e)) + })?; + + // Update refs with proper error handling + for command in &mut self.command_list { + if command.ref_type == RefTypeEnum::Tag { + // Just update if refs type is tag + // Convert ZERO_ID to None for old hash + let old_hash = if command.old_hash == ZERO_ID { + None + } else { + Some(command.old_hash.as_str()) + }; + if let Err(e) = self + .repo_storage + .update_reference(&command.ref_name, old_hash, &command.new_hash) + .await + { + command.failed(e.to_string()); + } + } else { + // Handle default branch setting for the first branch + if !default_exist { + command.default_branch = true; + default_exist = true; + } + // Convert ZERO_ID to None for old hash + let old_hash = if command.old_hash == ZERO_ID { + None + } else { + Some(command.old_hash.as_str()) + }; + if let Err(e) = self + .repo_storage + .update_reference(&command.ref_name, old_hash, &command.new_hash) + .await + { + command.failed(e.to_string()); + } + } + add_pkt_line_string(&mut report_status, command.get_status()); + } + + // Post-receive hook + self.repo_storage.post_receive_hook().await.map_err(|e| { + ProtocolError::repository_error(format!("Post-receive hook failed: {}", e)) + })?; + + report_status.put(&PKT_LINE_END_MARKER[..]); + Ok(report_status.freeze()) + } + + /// Builds the packet data in the sideband format if the SideBand/64k capability is enabled. + pub fn build_side_band_format(&self, from_bytes: BytesMut, length: usize) -> BytesMut { + let mut to_bytes = BytesMut::new(); + if self.capabilities.contains(&Capability::SideBand) + || self.capabilities.contains(&Capability::SideBand64k) + { + let length = length + 5; + to_bytes.put(Bytes::from(format!("{length:04x}"))); + to_bytes.put_u8(SideBand::PackfileData.value()); + to_bytes.put(from_bytes); + } else { + to_bytes.put(from_bytes); + } + to_bytes + } + + /// Parse capabilities from capability string + pub fn parse_capabilities(&mut self, cap_str: &str) { + for cap in cap_str.split_whitespace() { + if let Ok(capability) = cap.parse::() { + self.capabilities.push(capability); + } + } + } + + /// Parse a reference command from packet line + pub fn parse_ref_command(&self, pkt_line: &mut Bytes) -> RefCommand { + let old_id = read_until_white_space(pkt_line); + let new_id = read_until_white_space(pkt_line); + let ref_name = read_until_white_space(pkt_line); + let _capabilities = String::from_utf8_lossy(&pkt_line[..]).to_string(); + + RefCommand::new(old_id, new_id, ref_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::internal::object::blob::Blob; + use crate::internal::object::commit::Commit; + use crate::internal::object::signature::{Signature, SignatureType}; + use crate::internal::object::tree::{Tree, TreeItem, TreeItemMode}; + use crate::internal::pack::{encode::PackEncoder, entry::Entry}; + use crate::protocol::types::{RefCommand, ZERO_ID}; // import sibling types + use crate::protocol::utils; // import sibling module + use async_trait::async_trait; + use bytes::Bytes; + use futures; + use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }; + use tokio::sync::mpsc; + + // Simplify complex type via aliases to satisfy clippy::type_complexity + type UpdateRecord = (String, Option, String); + type UpdateList = Vec; + type SharedUpdates = Arc>; + + #[derive(Clone)] + struct TestRepoAccess { + updates: SharedUpdates, + stored_count: Arc>, + default_branch_exists: Arc>, + post_called: Arc, + } + + impl TestRepoAccess { + fn new() -> Self { + Self { + updates: Arc::new(Mutex::new(vec![])), + stored_count: Arc::new(Mutex::new(0)), + default_branch_exists: Arc::new(Mutex::new(false)), + post_called: Arc::new(AtomicBool::new(false)), + } + } + + fn updates_len(&self) -> usize { + self.updates.lock().unwrap().len() + } + + fn post_hook_called(&self) -> bool { + self.post_called.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl RepositoryAccess for TestRepoAccess { + async fn get_repository_refs(&self) -> Result, ProtocolError> { + Ok(vec![ + ( + "HEAD".to_string(), + "0000000000000000000000000000000000000000".to_string(), + ), + ( + "refs/heads/main".to_string(), + "1111111111111111111111111111111111111111".to_string(), + ), + ]) + } + + async fn has_object(&self, _object_hash: &str) -> Result { + Ok(true) + } + + async fn get_object(&self, _object_hash: &str) -> Result, ProtocolError> { + Ok(vec![]) + } + + async fn store_pack_data(&self, _pack_data: &[u8]) -> Result<(), ProtocolError> { + *self.stored_count.lock().unwrap() += 1; + Ok(()) + } + + async fn update_reference( + &self, + ref_name: &str, + old_hash: Option<&str>, + new_hash: &str, + ) -> Result<(), ProtocolError> { + self.updates.lock().unwrap().push(( + ref_name.to_string(), + old_hash.map(|s| s.to_string()), + new_hash.to_string(), + )); + Ok(()) + } + + async fn get_objects_for_pack( + &self, + _wants: &[String], + _haves: &[String], + ) -> Result, ProtocolError> { + Ok(vec![]) + } + + async fn has_default_branch(&self) -> Result { + let mut exists = self.default_branch_exists.lock().unwrap(); + let current = *exists; + *exists = true; // flip to true after first check + Ok(current) + } + + async fn post_receive_hook(&self) -> Result<(), ProtocolError> { + self.post_called.store(true, Ordering::SeqCst); + Ok(()) + } + } + + struct TestAuth; + + #[async_trait] + impl AuthenticationService for TestAuth { + async fn authenticate_http( + &self, + _headers: &std::collections::HashMap, + ) -> Result<(), ProtocolError> { + Ok(()) + } + + async fn authenticate_ssh( + &self, + _username: &str, + _public_key: &[u8], + ) -> Result<(), ProtocolError> { + Ok(()) + } + } + + #[tokio::test] + async fn test_receive_pack_stream_status_report() { + // Build simple objects + let blob1 = Blob::from_content("hello"); + let blob2 = Blob::from_content("world"); + + let item1 = TreeItem::new(TreeItemMode::Blob, blob1.id, "hello.txt".to_string()); + let item2 = TreeItem::new(TreeItemMode::Blob, blob2.id, "world.txt".to_string()); + let tree = Tree::from_tree_items(vec![item1, item2]).unwrap(); + + let author = Signature::new( + SignatureType::Author, + "tester".to_string(), + "tester@example.com".to_string(), + ); + let committer = Signature::new( + SignatureType::Committer, + "tester".to_string(), + "tester@example.com".to_string(), + ); + let commit = Commit::new(author, committer, tree.id, vec![], "init commit"); + + // Encode pack bytes via PackEncoder + let (pack_tx, mut pack_rx) = mpsc::channel(1024); + let (entry_tx, entry_rx) = mpsc::channel(1024); + let mut encoder = PackEncoder::new(4, 10, pack_tx); + + tokio::spawn(async move { + if let Err(e) = encoder.encode(entry_rx).await { + panic!("Failed to encode pack: {}", e); + } + }); + + let commit_clone = commit.clone(); + let tree_clone = tree.clone(); + let blob1_clone = blob1.clone(); + let blob2_clone = blob2.clone(); + tokio::spawn(async move { + let _ = entry_tx.send(Entry::from(commit_clone)).await; + let _ = entry_tx.send(Entry::from(tree_clone)).await; + let _ = entry_tx.send(Entry::from(blob1_clone)).await; + let _ = entry_tx.send(Entry::from(blob2_clone)).await; + // sender drop indicates end + }); + + let mut pack_bytes: Vec = Vec::new(); + while let Some(chunk) = pack_rx.recv().await { + pack_bytes.extend_from_slice(&chunk); + } + + // Prepare protocol and command + let repo_access = TestRepoAccess::new(); + let auth = TestAuth; + let mut smart = SmartProtocol::new(TransportProtocol::Http, repo_access.clone(), auth); + smart.command_list.push(RefCommand::new( + ZERO_ID.to_string(), + commit.id.to_string(), + "refs/heads/main".to_string(), + )); + + // Create request stream + let request_stream = Box::pin(futures::stream::once(async { Ok(Bytes::from(pack_bytes)) })); + + // Execute receive-pack + let result_bytes = smart + .git_receive_pack_stream(request_stream) + .await + .expect("receive-pack should succeed"); + + // Verify pkt-lines + let mut out = result_bytes.clone(); + let (_c1, l1) = utils::read_pkt_line(&mut out); + assert_eq!(String::from_utf8(l1.to_vec()).unwrap(), "unpack ok\n"); + + let (_c2, l2) = utils::read_pkt_line(&mut out); + assert_eq!( + String::from_utf8(l2.to_vec()).unwrap(), + "ok refs/heads/main" + ); + + let (c3, l3) = utils::read_pkt_line(&mut out); + assert_eq!(c3, 4); + assert!(l3.is_empty()); + + // Verify side effects + assert_eq!(repo_access.updates_len(), 1); + assert!(repo_access.post_hook_called()); + } +} diff --git a/src/protocol/ssh.rs b/src/protocol/ssh.rs new file mode 100644 index 00000000..20d94830 --- /dev/null +++ b/src/protocol/ssh.rs @@ -0,0 +1,76 @@ +/// SSH transport adapter for Git protocol +/// +/// This module provides SSH-specific handling for Git smart protocol operations. +/// It's a thin wrapper around the core GitProtocol that handles SSH command +/// execution and data streaming. +use super::core::{AuthenticationService, GitProtocol, RepositoryAccess}; +use super::types::{ProtocolError, ProtocolStream}; + +/// SSH Git protocol handler +pub struct SshGitHandler { + protocol: GitProtocol, +} + +impl SshGitHandler { + /// Create a new SSH Git handler + pub fn new(repo_access: R, auth_service: A) -> Self { + let mut protocol = GitProtocol::new(repo_access, auth_service); + protocol.set_transport(super::types::TransportProtocol::Ssh); + Self { protocol } + } + + /// Authenticate SSH session using username and public key + /// Call this once after SSH handshake, before running Git commands + pub async fn authenticate_ssh( + &self, + username: &str, + public_key: &[u8], + ) -> Result<(), ProtocolError> { + self.protocol.authenticate_ssh(username, public_key).await + } + + /// Handle git-upload-pack command (for clone/fetch) + pub async fn handle_upload_pack( + &mut self, + request_data: &[u8], + ) -> Result { + self.protocol.upload_pack(request_data).await + } + + /// Handle git-receive-pack command (for push) + pub async fn handle_receive_pack( + &mut self, + request_stream: ProtocolStream, + ) -> Result { + self.protocol.receive_pack(request_stream).await + } + + /// Handle info/refs request for SSH + pub async fn handle_info_refs(&mut self, service: &str) -> Result, ProtocolError> { + self.protocol.info_refs(service).await + } +} + +/// SSH-specific utility functions +/// Parse SSH command line into command and arguments +pub fn parse_ssh_command(command_line: &str) -> Option<(String, Vec)> { + let parts: Vec<&str> = command_line.split_whitespace().collect(); + if parts.is_empty() { + return None; + } + + let command = parts[0].to_string(); + let args = parts[1..].iter().map(|s| s.to_string()).collect(); + + Some((command, args)) +} + +/// Check if command is a valid Git SSH command +pub fn is_git_ssh_command(command: &str) -> bool { + matches!(command, "git-upload-pack" | "git-receive-pack") +} + +/// Extract repository path from SSH command arguments +pub fn extract_repo_path_from_args(args: &[String]) -> Option<&str> { + args.first().map(|s| s.as_str()) +} diff --git a/src/protocol/types.rs b/src/protocol/types.rs new file mode 100644 index 00000000..14a8f5b1 --- /dev/null +++ b/src/protocol/types.rs @@ -0,0 +1,380 @@ +use bytes::Bytes; +use futures::stream::Stream; +use std::fmt; +use std::pin::Pin; +use std::str::FromStr; + +/// Type alias for protocol data streams to reduce nesting +pub type ProtocolStream = Pin> + Send>>; + +/// Protocol error types +#[derive(Debug, thiserror::Error)] +pub enum ProtocolError { + #[error("Invalid service: {0}")] + InvalidService(String), + + #[error("Repository not found: {0}")] + RepositoryNotFound(String), + + #[error("Object not found: {0}")] + ObjectNotFound(String), + + #[error("Invalid request: {0}")] + InvalidRequest(String), + + #[error("Unauthorized: {0}")] + Unauthorized(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Pack error: {0}")] + Pack(String), + + #[error("Internal error: {0}")] + Internal(String), +} + +impl ProtocolError { + pub fn invalid_service(service: &str) -> Self { + ProtocolError::InvalidService(service.to_string()) + } + + pub fn repository_error(msg: String) -> Self { + ProtocolError::Internal(msg) + } + + pub fn invalid_request(msg: &str) -> Self { + ProtocolError::InvalidRequest(msg.to_string()) + } + + pub fn unauthorized(msg: &str) -> Self { + ProtocolError::Unauthorized(msg.to_string()) + } +} + +/// Git transport protocol types +#[derive(Debug, PartialEq, Clone, Copy, Default)] +pub enum TransportProtocol { + Local, + #[default] + Http, + Ssh, + Git, +} + +/// Git service types for smart protocol +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum ServiceType { + UploadPack, + ReceivePack, +} + +impl fmt::Display for ServiceType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ServiceType::UploadPack => write!(f, "git-upload-pack"), + ServiceType::ReceivePack => write!(f, "git-receive-pack"), + } + } +} + +impl FromStr for ServiceType { + type Err = ProtocolError; + + fn from_str(s: &str) -> Result { + match s { + "git-upload-pack" => Ok(ServiceType::UploadPack), + "git-receive-pack" => Ok(ServiceType::ReceivePack), + _ => Err(ProtocolError::InvalidService(s.to_string())), + } + } +} + +/// Git protocol capabilities +/// +/// ## Implementation Status Overview +/// +/// ### Implemented capabilities: +/// - **Data transmission**: SideBand, SideBand64k - Multiplexed data streams via side-band formatter +/// - **Status reporting**: ReportStatus, ReportStatusv2 - Push status feedback via protocol handlers +/// - **Pack optimization**: OfsDelta, ThinPack, NoThin - Delta compression and efficient transmission +/// - **Protocol control**: MultiAckDetailed, NoDone - ACK mechanism optimization for upload-pack +/// - **Push control**: Atomic, DeleteRefs, Quiet - Atomic operations and reference management +/// - **Tag handling**: IncludeTag - Automatic tag inclusion for upload-pack +/// - **Client identification**: Agent - Client/server identification in capability negotiation +/// +/// ### Not yet implemented capabilities: +/// - **Basic protocol**: MultiAck - Basic multi-ack support (only detailed version implemented) +/// - **Shallow cloning**: Shallow, DeepenSince, DeepenNot, DeepenRelative - Depth control for shallow clones +/// - **Progress control**: NoProgress - Progress output suppression +/// - **Special fetch**: AllowTipSha1InWant, AllowReachableSha1InWant - SHA1 validation in want processing +/// - **Security**: PushCert - Push certificate verification mechanism +/// - **Extensions**: PushOptions, Filter, Symref - Extended parameter handling +/// - **Session management**: SessionId, ObjectFormat - Session and format negotiation +#[derive(Debug, Clone, PartialEq)] +pub enum Capability { + /// Multi-ack capability for upload-pack protocol + MultiAck, + /// Multi-ack-detailed capability for more granular acknowledgment + MultiAckDetailed, + /// No-done capability to optimize upload-pack protocol + NoDone, + /// Side-band capability for multiplexing data streams + SideBand, + /// Side-band-64k capability for larger side-band packets + SideBand64k, + /// Report-status capability for push status reporting + ReportStatus, + /// Report-status-v2 capability for enhanced push status reporting + ReportStatusv2, + /// OFS-delta capability for offset-based delta compression + OfsDelta, + /// Deepen-since capability for shallow clone with time-based depth + DeepenSince, + /// Deepen-not capability for shallow clone exclusions + DeepenNot, + /// Deepen-relative capability for relative depth specification + DeepenRelative, + /// Thin-pack capability for efficient pack transmission + ThinPack, + /// Shallow capability for shallow clone support + Shallow, + /// Include-tag capability for automatic tag inclusion + IncludeTag, + /// Delete-refs capability for reference deletion + DeleteRefs, + /// Quiet capability to suppress output + Quiet, + /// Atomic capability for atomic push operations + Atomic, + /// No-thin capability to disable thin pack + NoThin, + /// No-progress capability to disable progress reporting + NoProgress, + /// Allow-tip-sha1-in-want capability for fetching specific commits + AllowTipSha1InWant, + /// Allow-reachable-sha1-in-want capability for fetching reachable commits + AllowReachableSha1InWant, + /// Push-cert capability for signed push certificates + PushCert(String), + /// Push-options capability for additional push metadata + PushOptions, + /// Object-format capability for specifying hash algorithm + ObjectFormat(String), + /// Session-id capability for session tracking + SessionId(String), + /// Filter capability for partial clone support + Filter(String), + /// Symref capability for symbolic reference information + Symref(String), + /// Agent capability for client/server identification + Agent(String), + /// Unknown capability for forward compatibility + Unknown(String), +} + +impl FromStr for Capability { + type Err = (); + + fn from_str(s: &str) -> Result { + // Parameterized capabilities + if let Some(rest) = s.strip_prefix("agent=") { + return Ok(Capability::Agent(rest.to_string())); + } + if let Some(rest) = s.strip_prefix("session-id=") { + return Ok(Capability::SessionId(rest.to_string())); + } + if let Some(rest) = s.strip_prefix("push-cert=") { + return Ok(Capability::PushCert(rest.to_string())); + } + if let Some(rest) = s.strip_prefix("object-format=") { + return Ok(Capability::ObjectFormat(rest.to_string())); + } + if let Some(rest) = s.strip_prefix("filter=") { + return Ok(Capability::Filter(rest.to_string())); + } + if let Some(rest) = s.strip_prefix("symref=") { + return Ok(Capability::Symref(rest.to_string())); + } + + match s { + "multi_ack" => Ok(Capability::MultiAck), + "multi_ack_detailed" => Ok(Capability::MultiAckDetailed), + "no-done" => Ok(Capability::NoDone), + "side-band" => Ok(Capability::SideBand), + "side-band-64k" => Ok(Capability::SideBand64k), + "report-status" => Ok(Capability::ReportStatus), + "report-status-v2" => Ok(Capability::ReportStatusv2), + "ofs-delta" => Ok(Capability::OfsDelta), + "deepen-since" => Ok(Capability::DeepenSince), + "deepen-not" => Ok(Capability::DeepenNot), + "deepen-relative" => Ok(Capability::DeepenRelative), + "thin-pack" => Ok(Capability::ThinPack), + "shallow" => Ok(Capability::Shallow), + "include-tag" => Ok(Capability::IncludeTag), + "delete-refs" => Ok(Capability::DeleteRefs), + "quiet" => Ok(Capability::Quiet), + "atomic" => Ok(Capability::Atomic), + "no-thin" => Ok(Capability::NoThin), + "no-progress" => Ok(Capability::NoProgress), + "allow-tip-sha1-in-want" => Ok(Capability::AllowTipSha1InWant), + "allow-reachable-sha1-in-want" => Ok(Capability::AllowReachableSha1InWant), + "push-options" => Ok(Capability::PushOptions), + _ => Ok(Capability::Unknown(s.to_string())), + } + } +} + +impl std::fmt::Display for Capability { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Capability::MultiAck => write!(f, "multi_ack"), + Capability::MultiAckDetailed => write!(f, "multi_ack_detailed"), + Capability::NoDone => write!(f, "no-done"), + Capability::SideBand => write!(f, "side-band"), + Capability::SideBand64k => write!(f, "side-band-64k"), + Capability::ReportStatus => write!(f, "report-status"), + Capability::ReportStatusv2 => write!(f, "report-status-v2"), + Capability::OfsDelta => write!(f, "ofs-delta"), + Capability::DeepenSince => write!(f, "deepen-since"), + Capability::DeepenNot => write!(f, "deepen-not"), + Capability::DeepenRelative => write!(f, "deepen-relative"), + Capability::ThinPack => write!(f, "thin-pack"), + Capability::Shallow => write!(f, "shallow"), + Capability::IncludeTag => write!(f, "include-tag"), + Capability::DeleteRefs => write!(f, "delete-refs"), + Capability::Quiet => write!(f, "quiet"), + Capability::Atomic => write!(f, "atomic"), + Capability::NoThin => write!(f, "no-thin"), + Capability::NoProgress => write!(f, "no-progress"), + Capability::AllowTipSha1InWant => write!(f, "allow-tip-sha1-in-want"), + Capability::AllowReachableSha1InWant => write!(f, "allow-reachable-sha1-in-want"), + Capability::PushCert(value) => write!(f, "push-cert={}", value), + Capability::PushOptions => write!(f, "push-options"), + Capability::ObjectFormat(format) => write!(f, "object-format={}", format), + Capability::SessionId(id) => write!(f, "session-id={}", id), + Capability::Filter(filter) => write!(f, "filter={}", filter), + Capability::Symref(symref) => write!(f, "symref={}", symref), + Capability::Agent(agent) => write!(f, "agent={}", agent), + Capability::Unknown(s) => write!(f, "{}", s), + } + } +} + +/// Side-band types for multiplexed data streams +pub enum SideBand { + /// Sideband 1 contains packfile data + PackfileData, + /// Sideband 2 contains progress information + ProgressInfo, + /// Sideband 3 contains error information + Error, +} + +impl SideBand { + pub fn value(&self) -> u8 { + match self { + Self::PackfileData => b'\x01', + Self::ProgressInfo => b'\x02', + Self::Error => b'\x03', + } + } +} + +/// Reference types in Git +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum RefTypeEnum { + Branch, + Tag, +} + +/// Git reference information +#[derive(Clone, Debug)] +pub struct GitRef { + pub name: String, + pub hash: String, +} + +/// Reference command for push operations +#[derive(Debug, Clone)] +pub struct RefCommand { + pub old_hash: String, + pub new_hash: String, + pub ref_name: String, + pub ref_type: RefTypeEnum, + pub default_branch: bool, + pub status: CommandStatus, + pub error_message: Option, +} + +#[derive(Debug, Clone)] +pub enum CommandStatus { + Pending, + Success, + Failed, +} + +impl RefCommand { + pub fn new(old_hash: String, new_hash: String, ref_name: String) -> Self { + // Determine ref type based on ref name + let ref_type = if ref_name.starts_with("refs/tags/") { + RefTypeEnum::Tag + } else { + RefTypeEnum::Branch + }; + + Self { + old_hash, + new_hash, + ref_name, + ref_type, + default_branch: false, + status: CommandStatus::Pending, + error_message: None, + } + } + + pub fn failed(&mut self, error: String) { + self.status = CommandStatus::Failed; + self.error_message = Some(error); + } + + pub fn success(&mut self) { + self.status = CommandStatus::Success; + self.error_message = None; + } + + pub fn get_status(&self) -> String { + match &self.status { + CommandStatus::Success => format!("ok {}", self.ref_name), + CommandStatus::Failed => { + let error = self.error_message.as_deref().unwrap_or("unknown error"); + format!("ng {} {}", self.ref_name, error) + } + CommandStatus::Pending => format!("ok {}", self.ref_name), // Default to ok for pending + } + } +} + +#[derive(Debug, PartialEq, Clone)] +pub enum CommandType { + Create, + Update, + Delete, +} + +/// Zero object ID constant +pub const ZERO_ID: &str = "0000000000000000000000000000000000000000"; + +/// Protocol constants +pub const LF: char = '\n'; +pub const SP: char = ' '; +pub const NUL: char = '\0'; +pub const PKT_LINE_END_MARKER: &[u8; 4] = b"0000"; + +// Git protocol capability lists +pub const RECEIVE_CAP_LIST: &str = + "report-status report-status-v2 delete-refs quiet atomic no-thin "; +pub const COMMON_CAP_LIST: &str = "side-band-64k ofs-delta agent=git-internal/0.1.0"; +pub const UPLOAD_CAP_LIST: &str = "multi_ack_detailed no-done include-tag "; diff --git a/src/protocol/utils.rs b/src/protocol/utils.rs new file mode 100644 index 00000000..366f53fb --- /dev/null +++ b/src/protocol/utils.rs @@ -0,0 +1,116 @@ +use bytes::{Buf, BufMut, Bytes, BytesMut}; + +use super::types::{PKT_LINE_END_MARKER, TransportProtocol}; + +/// Read a packet line from the given bytes buffer +/// +/// Returns a tuple of (bytes_consumed, packet_data) +/// +/// This is the original simple implementation from ceres +pub fn read_pkt_line(bytes: &mut Bytes) -> (usize, Bytes) { + if bytes.is_empty() { + return (0, Bytes::new()); + } + + // Ensure we have at least 4 bytes for the length prefix + if bytes.len() < 4 { + return (0, Bytes::new()); + } + + let pkt_length = bytes.copy_to_bytes(4); + let pkt_length_str = match core::str::from_utf8(&pkt_length) { + Ok(s) => s, + Err(_) => { + tracing::warn!("Invalid UTF-8 in packet length: {:?}", pkt_length); + return (0, Bytes::new()); + } + }; + + let pkt_length = match usize::from_str_radix(pkt_length_str, 16) { + Ok(len) => len, + Err(_) => { + tracing::warn!("Invalid hex packet length: {:?}", pkt_length_str); + return (0, Bytes::new()); + } + }; + + if pkt_length == 0 { + return (4, Bytes::new()); // Consumed 4 bytes for the "0000" marker + } + + if pkt_length < 4 { + tracing::warn!("Invalid packet length: {} (must be >= 4)", pkt_length); + return (0, Bytes::new()); + } + + let data_length = pkt_length - 4; + if bytes.len() < data_length { + tracing::warn!( + "Insufficient data: need {} bytes, have {}", + data_length, + bytes.len() + ); + return (0, Bytes::new()); + } + + // this operation will change the original bytes + let pkt_line = bytes.copy_to_bytes(data_length); + tracing::debug!("pkt line: {:?}", pkt_line); + + (pkt_length, pkt_line) +} + +/// Add a packet line string to the buffer with proper length prefix +/// +/// This is the original simple implementation from ceres +pub fn add_pkt_line_string(pkt_line_stream: &mut BytesMut, buf_str: String) { + let buf_str_length = buf_str.len() + 4; + pkt_line_stream.put(Bytes::from(format!("{buf_str_length:04x}"))); + pkt_line_stream.put(buf_str.as_bytes()); +} + +/// Read until whitespace and return the extracted string +/// +/// This is the original implementation from ceres +pub fn read_until_white_space(bytes: &mut Bytes) -> String { + let mut buf = Vec::new(); + while bytes.has_remaining() { + let c = bytes.get_u8(); + if c.is_ascii_whitespace() || c == 0 { + break; + } + buf.push(c); + } + match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => { + tracing::warn!("Invalid UTF-8 in protocol data: {}", e); + String::new() // Return empty string on invalid UTF-8 + } + } +} + +pub fn build_smart_reply( + transport_protocol: TransportProtocol, + ref_list: &[String], + service: String, +) -> BytesMut { + let mut pkt_line_stream = BytesMut::new(); + if transport_protocol == TransportProtocol::Http { + add_pkt_line_string(&mut pkt_line_stream, format!("# service={service}\n")); + pkt_line_stream.put(&PKT_LINE_END_MARKER[..]); + } + + for ref_line in ref_list { + add_pkt_line_string(&mut pkt_line_stream, ref_line.to_string()); + } + pkt_line_stream.put(&PKT_LINE_END_MARKER[..]); + pkt_line_stream +} + +/// Search for a subsequence in a byte slice +pub fn search_subsequence(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +}