feat(grasp-audit): add git HTTP protocol test for gzip-encoded requests

Add MVP implementation for testing git HTTP protocol compliance:

- Add git module with GitClient wrapper for HTTP protocol testing
- Add GitHttpProtocolTests with test_gzip_encoded_upload_pack test
- Add integration test in ngit-grasp that uses TestRelay fixture

The test validates that the server correctly handles gzip-encoded
git-upload-pack requests, which was the source of a production bug.
Uses actual git fetch operations to test the real production scenario.
This commit is contained in:
DanConwayDev
2026-01-21 17:22:40 +00:00
parent 22c9ccd8f1
commit b62231dc30
8 changed files with 530 additions and 0 deletions
Generated
+1
View File
@@ -787,6 +787,7 @@ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
"clap", "clap",
"flate2",
"futures", "futures",
"nostr-sdk", "nostr-sdk",
"regex", "regex",
+3
View File
@@ -37,6 +37,9 @@ chrono = "0.4"
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
regex = "1" regex = "1"
# Compression (for gzip-encoded git requests)
flate2 = "1"
# Logging # Logging
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+237
View File
@@ -0,0 +1,237 @@
//! Git HTTP protocol client for testing
//!
//! This module provides a minimal HTTP client specifically designed for testing
//! Git HTTP protocol compliance, including gzip-encoded request handling.
use anyhow::{Context, Result};
use std::io::Write;
/// Git HTTP protocol client for testing
///
/// This client provides methods for making Git HTTP protocol requests,
/// with support for gzip-encoded request bodies (which is how git clients
/// typically send data to servers).
///
/// ## Usage
///
/// ```no_run
/// use grasp_audit::git::GitClient;
///
/// # async fn example() -> anyhow::Result<()> {
/// let client = GitClient::new("localhost:7000");
///
/// // Test gzip-encoded upload-pack request
/// let response = client.upload_pack_gzip("npub1...", "my-repo", b"want data").await?;
/// # Ok(())
/// # }
/// ```
pub struct GitClient {
/// The relay domain (host:port)
relay_domain: String,
/// HTTP client for making requests
http_client: reqwest::Client,
}
impl GitClient {
/// Create a new GitClient for the given relay domain
///
/// # Arguments
/// * `relay_domain` - The relay domain (e.g., "localhost:7000" or "127.0.0.1:8080")
pub fn new(relay_domain: &str) -> Self {
Self {
relay_domain: relay_domain.to_string(),
http_client: reqwest::Client::new(),
}
}
/// Get the info/refs endpoint for git-upload-pack service
///
/// This is the first request git makes when cloning/fetching.
///
/// # Arguments
/// * `npub` - The bech32 public key of the repository owner
/// * `repo_id` - The repository identifier (d-tag value)
///
/// # Returns
/// The raw response body as bytes
pub async fn get_info_refs(&self, npub: &str, repo_id: &str) -> Result<Vec<u8>> {
let url = format!(
"http://{}/{}/{}.git/info/refs?service=git-upload-pack",
self.relay_domain, npub, repo_id
);
let response = self
.http_client
.get(&url)
.send()
.await
.context("Failed to send info/refs request")?;
if !response.status().is_success() {
anyhow::bail!(
"info/refs request failed with status: {}",
response.status()
);
}
response
.bytes()
.await
.map(|b| b.to_vec())
.context("Failed to read info/refs response body")
}
/// Send a gzip-encoded git-upload-pack request
///
/// This tests the server's ability to handle gzip-encoded request bodies,
/// which is how git clients typically send data. This was the source of
/// a production bug where the server failed to decompress gzip requests.
///
/// # Arguments
/// * `npub` - The bech32 public key of the repository owner
/// * `repo_id` - The repository identifier (d-tag value)
/// * `body` - The raw git protocol data to send (will be gzip-compressed)
///
/// # Returns
/// * `Ok(Vec<u8>)` - The response body
/// * `Err` - If the request failed
pub async fn upload_pack_gzip(
&self,
npub: &str,
repo_id: &str,
body: &[u8],
) -> Result<Vec<u8>> {
let url = format!(
"http://{}/{}/{}.git/git-upload-pack",
self.relay_domain, npub, repo_id
);
// Compress the body with gzip
let compressed = gzip_compress(body)?;
let response = self
.http_client
.post(&url)
.header("Content-Type", "application/x-git-upload-pack-request")
.header("Content-Encoding", "gzip")
.header("Accept-Encoding", "gzip")
.body(compressed)
.send()
.await
.context("Failed to send git-upload-pack request")?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
anyhow::bail!(
"git-upload-pack request failed with status {}: {}",
status,
body
);
}
response
.bytes()
.await
.map(|b| b.to_vec())
.context("Failed to read git-upload-pack response body")
}
/// Send a gzip-encoded git-receive-pack request
///
/// This tests the server's ability to handle gzip-encoded push requests.
///
/// # Arguments
/// * `npub` - The bech32 public key of the repository owner
/// * `repo_id` - The repository identifier (d-tag value)
/// * `body` - The raw git protocol data to send (will be gzip-compressed)
///
/// # Returns
/// * `Ok(Vec<u8>)` - The response body
/// * `Err` - If the request failed
pub async fn receive_pack_gzip(
&self,
npub: &str,
repo_id: &str,
body: &[u8],
) -> Result<Vec<u8>> {
let url = format!(
"http://{}/{}/{}.git/git-receive-pack",
self.relay_domain, npub, repo_id
);
// Compress the body with gzip
let compressed = gzip_compress(body)?;
let response = self
.http_client
.post(&url)
.header("Content-Type", "application/x-git-receive-pack-request")
.header("Content-Encoding", "gzip")
.body(compressed)
.send()
.await
.context("Failed to send git-receive-pack request")?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
anyhow::bail!(
"git-receive-pack request failed with status {}: {}",
status,
body
);
}
response
.bytes()
.await
.map(|b| b.to_vec())
.context("Failed to read git-receive-pack response body")
}
}
/// Compress data using gzip
fn gzip_compress(data: &[u8]) -> Result<Vec<u8>> {
use flate2::write::GzEncoder;
use flate2::Compression;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(data)
.context("Failed to write data to gzip encoder")?;
encoder
.finish()
.context("Failed to finish gzip compression")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gzip_compress() {
let data = b"Hello, World!";
let compressed = gzip_compress(data).unwrap();
// Compressed data should be different from original
assert_ne!(compressed.as_slice(), data);
// Compressed data should have gzip magic bytes
assert!(compressed.len() >= 2);
assert_eq!(compressed[0], 0x1f);
assert_eq!(compressed[1], 0x8b);
}
#[test]
fn test_git_client_new() {
let client = GitClient::new("localhost:7000");
assert_eq!(client.relay_domain, "localhost:7000");
}
}
+33
View File
@@ -0,0 +1,33 @@
//! Git HTTP protocol testing utilities
//!
//! This module provides a `GitClient` wrapper for testing Git HTTP protocol
//! compliance, particularly for operations that require specific HTTP headers
//! or encoding (like gzip-compressed request bodies).
//!
//! ## Overview
//!
//! The Git HTTP protocol uses specific content types and encodings that differ
//! from standard HTTP. This module provides utilities to:
//!
//! - Make raw Git HTTP protocol requests
//! - Test gzip-encoded request handling
//! - Verify protocol compliance
//!
//! ## Example
//!
//! ```no_run
//! use grasp_audit::git::GitClient;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let client = GitClient::new("localhost:7000");
//!
//! // Fetch refs advertisement
//! let refs = client.get_info_refs("npub1...", "my-repo").await?;
//! println!("Refs: {:?}", refs);
//! # Ok(())
//! # }
//! ```
mod client;
pub use client::GitClient;
+1
View File
@@ -31,6 +31,7 @@
pub mod audit; pub mod audit;
pub mod client; pub mod client;
pub mod fixtures; pub mod fixtures;
pub mod git;
pub mod isolation; pub mod isolation;
pub mod result; pub mod result;
pub mod specs; pub mod specs;
@@ -0,0 +1,186 @@
//! GRASP-01 Git HTTP Protocol Tests
//!
//! Tests that verify Git HTTP protocol compliance, particularly around
//! request encoding and content negotiation.
//!
//! ## Test Coverage
//!
//! - Gzip-encoded request handling (production bug fix validation)
//! - Content-Type header handling
//! - Git protocol version negotiation
//!
//! ## Background
//!
//! Git clients typically send gzip-compressed request bodies when communicating
//! with HTTP backends. A production bug was discovered where the server failed
//! to properly decompress these requests, causing fetch/clone operations to fail.
//!
//! These tests validate that the server correctly handles gzip-encoded requests.
//!
//! ## Running Tests
//!
//! ```bash
//! # From ngit-grasp root
//! cargo test --test git_http_protocol
//! ```
use crate::{clone_repo, AuditClient, FixtureKind, TestContext, TestResult};
use nostr_sdk::prelude::*;
use std::fs;
use std::process::Command;
/// Test suite for Git HTTP protocol compliance
pub struct GitHttpProtocolTests;
impl GitHttpProtocolTests {
/// Run all Git HTTP protocol tests
pub async fn run_all(client: &AuditClient, relay_domain: &str) -> crate::AuditResult {
let mut results = crate::AuditResult::new("GRASP-01 Git HTTP Protocol Tests");
results.add(Self::test_gzip_encoded_upload_pack(client, relay_domain).await);
results
}
/// Test that gzip-encoded git-upload-pack requests are handled correctly
///
/// This test validates the fix for a production bug where the server failed
/// to decompress gzip-encoded request bodies, causing git fetch/clone to fail.
///
/// ## What This Tests
///
/// 1. Creates a repository with pushed data (so there's something to fetch)
/// 2. Clones the repository
/// 3. Performs a git fetch operation (which sends gzip-encoded requests)
/// 4. Verifies the fetch succeeds
///
/// ## Git Protocol Details
///
/// Git clients send `Content-Encoding: gzip` headers when the request body
/// is compressed. The server MUST decompress the body before processing.
///
/// When git performs a fetch, it:
/// 1. Requests info/refs to discover available refs
/// 2. Sends a POST to git-upload-pack with "want" lines (gzip-encoded)
/// 3. Receives pack data in response
///
/// ## Spec Reference
///
/// While not explicitly in GRASP-01, this is required for HTTP backend
/// compatibility with standard git clients.
pub async fn test_gzip_encoded_upload_pack(
client: &AuditClient,
relay_domain: &str,
) -> TestResult {
let test_name = "test_gzip_encoded_upload_pack";
let spec_ref = "GRASP-01:git-http:implicit";
let requirement = "Server MUST handle gzip-encoded git-upload-pack requests";
let ctx = TestContext::new(client);
// Get a repository with pushed data (OwnerStateDataPushed ensures git data exists)
let state = match ctx.get_fixture(FixtureKind::OwnerStateDataPushed).await {
Ok(s) => s,
Err(e) => {
return TestResult::new(test_name, spec_ref, requirement)
.fail(format!("Failed to create fixture: {}", e));
}
};
// Extract repo info from the state event
let repo_id = match state
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
{
Some(id) => id.to_string(),
None => {
return TestResult::new(test_name, spec_ref, requirement)
.fail("State event missing d tag");
}
};
// Get the repo fixture to get the owner's npub
let repo = match ctx.get_fixture(FixtureKind::ValidRepo).await {
Ok(r) => r,
Err(e) => {
return TestResult::new(test_name, spec_ref, requirement)
.fail(format!("Failed to get repo fixture: {}", e));
}
};
let npub = match repo.pubkey.to_bech32() {
Ok(n) => n,
Err(e) => {
return TestResult::new(test_name, spec_ref, requirement)
.fail(format!("Failed to convert pubkey to npub: {}", e));
}
};
// Clone the repository
let clone_path = match clone_repo(relay_domain, &npub, &repo_id) {
Ok(p) => p,
Err(e) => {
return TestResult::new(test_name, spec_ref, requirement)
.fail(format!("Failed to clone repo: {}", e));
}
};
// Cleanup helper
let cleanup = || {
let _ = fs::remove_dir_all(&clone_path);
};
// Perform a git fetch operation
// Git fetch sends gzip-encoded POST requests to git-upload-pack
// This is the actual production scenario we're testing
let fetch_output = Command::new("git")
.args(["fetch", "--all", "-v"])
.current_dir(&clone_path)
.env("GIT_TERMINAL_PROMPT", "0")
// Ensure gzip encoding is used (this is the default, but be explicit)
.env("GIT_HTTP_LOW_SPEED_LIMIT", "0")
.env("GIT_HTTP_LOW_SPEED_TIME", "999999")
.output();
match fetch_output {
Ok(output) => {
cleanup();
if output.status.success() {
// Fetch succeeded - gzip handling works!
TestResult::new(test_name, spec_ref, requirement).pass()
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// Check for specific gzip-related errors
if stderr.contains("gzip")
|| stderr.contains("inflate")
|| stderr.contains("decompress")
|| stderr.contains("Content-Encoding")
{
TestResult::new(test_name, spec_ref, requirement).fail(format!(
"Server failed to handle gzip-encoded request (production bug): {}",
stderr
))
} else {
// Other fetch failure - might be unrelated to gzip
TestResult::new(test_name, spec_ref, requirement)
.fail(format!("Git fetch failed: {}", stderr))
}
}
}
Err(e) => {
cleanup();
TestResult::new(test_name, spec_ref, requirement)
.fail(format!("Failed to execute git fetch: {}", e))
}
}
}
}
#[cfg(test)]
mod tests {
// Unit tests for helper functions can go here
}
+2
View File
@@ -17,6 +17,7 @@ pub mod cors;
pub mod event_acceptance_policy; pub mod event_acceptance_policy;
pub mod git_clone; pub mod git_clone;
pub mod git_filter; pub mod git_filter;
pub mod git_http_protocol;
pub mod nip01_smoke; pub mod nip01_smoke;
pub mod nip11_document; pub mod nip11_document;
pub mod push_authorization; pub mod push_authorization;
@@ -27,6 +28,7 @@ pub use cors::CorsTests;
pub use event_acceptance_policy::EventAcceptancePolicyTests; pub use event_acceptance_policy::EventAcceptancePolicyTests;
pub use git_clone::GitCloneTests; pub use git_clone::GitCloneTests;
pub use git_filter::GitFilterTests; pub use git_filter::GitFilterTests;
pub use git_http_protocol::GitHttpProtocolTests;
pub use nip01_smoke::Nip01SmokeTests; pub use nip01_smoke::Nip01SmokeTests;
pub use nip11_document::Nip11DocumentTests; pub use nip11_document::Nip11DocumentTests;
pub use push_authorization::PushAuthorizationTests; pub use push_authorization::PushAuthorizationTests;
+67
View File
@@ -0,0 +1,67 @@
//! Git HTTP Protocol Integration Tests
//!
//! Tests that verify Git HTTP protocol compliance, particularly around
//! request encoding (gzip) and content negotiation.
//!
//! # Test Strategy
//!
//! - Each test runs in complete isolation with its own fresh relay instance
//! - Uses macro to eliminate boilerplate while maintaining test isolation
//! - Calls individual test methods from grasp-audit for minimal duplication
//! - Automatic cleanup via TestRelay fixture (removes container and temp dirs)
//!
//! # Background
//!
//! These tests validate the fix for a production bug where the server failed
//! to properly decompress gzip-encoded request bodies, causing git fetch/clone
//! operations to fail.
//!
//! # Running Tests
//!
//! ```bash
//! # Run all git HTTP protocol tests
//! cargo test --test git_http_protocol
//!
//! # Run specific test
//! cargo test --test git_http_protocol test_gzip_encoded_upload_pack
//!
//! # With output
//! cargo test --test git_http_protocol -- --nocapture
//! ```
mod common;
use common::TestRelay;
use grasp_audit::specs::grasp01::GitHttpProtocolTests;
use grasp_audit::*;
/// Macro to generate isolated integration tests with relay domain
///
/// Each test runs with its own fresh relay instance to ensure complete isolation.
/// This eliminates issues with leftover repositories and ensures clean state.
macro_rules! isolated_test {
($test_name:ident) => {
#[tokio::test]
async fn $test_name() {
let relay = TestRelay::start().await;
let config = AuditConfig::isolated();
let client = AuditClient::new(relay.url(), config)
.await
.expect("Failed to create audit client");
let result = GitHttpProtocolTests::$test_name(&client, &relay.domain()).await;
relay.stop().await;
assert!(
result.passed,
"{} failed: {}",
stringify!($test_name),
result.error.as_deref().unwrap_or("unknown error")
);
}
};
}
// Generate isolated tests for all git HTTP protocol tests
isolated_test!(test_gzip_encoded_upload_pack);