mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
Generated
+1
@@ -787,6 +787,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures",
|
||||
"nostr-sdk",
|
||||
"regex",
|
||||
|
||||
@@ -37,6 +37,9 @@ chrono = "0.4"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
regex = "1"
|
||||
|
||||
# Compression (for gzip-encoded git requests)
|
||||
flate2 = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -31,6 +31,7 @@
|
||||
pub mod audit;
|
||||
pub mod client;
|
||||
pub mod fixtures;
|
||||
pub mod git;
|
||||
pub mod isolation;
|
||||
pub mod result;
|
||||
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
|
||||
}
|
||||
@@ -17,6 +17,7 @@ pub mod cors;
|
||||
pub mod event_acceptance_policy;
|
||||
pub mod git_clone;
|
||||
pub mod git_filter;
|
||||
pub mod git_http_protocol;
|
||||
pub mod nip01_smoke;
|
||||
pub mod nip11_document;
|
||||
pub mod push_authorization;
|
||||
@@ -27,6 +28,7 @@ pub use cors::CorsTests;
|
||||
pub use event_acceptance_policy::EventAcceptancePolicyTests;
|
||||
pub use git_clone::GitCloneTests;
|
||||
pub use git_filter::GitFilterTests;
|
||||
pub use git_http_protocol::GitHttpProtocolTests;
|
||||
pub use nip01_smoke::Nip01SmokeTests;
|
||||
pub use nip11_document::Nip11DocumentTests;
|
||||
pub use push_authorization::PushAuthorizationTests;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user