From d9bc5ed7fddef3a26de8e69a7124e1dbe5b8602f Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Thu, 4 Dec 2025 12:34:20 +0000 Subject: docs: update based on current implementation --- docs/explanation/architecture.md | 716 ++++++++----------------------- docs/explanation/inline-authorization.md | 126 +++--- 2 files changed, 231 insertions(+), 611 deletions(-) (limited to 'docs/explanation') diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index ebf7a74..3fb895c 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -2,16 +2,16 @@ ## Executive Summary -`ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that the `git-http-backend` Rust crate provides sufficient flexibility to intercept and validate Git push operations before they reach the Git repository, eliminating the need for pre-receive hooks. +`ngit-grasp` implements the GRASP protocol in Rust with **inline authorization** rather than Git hooks. The key architectural insight is that we can intercept and validate Git push operations at the HTTP handler level before reaching the Git repository, eliminating the need for pre-receive hooks. ## Architectural Decision: Inline vs. Hook-Based Authorization ### Investigation Summary -After examining both the reference implementation and the `git-http-backend` Rust crate, we have two options: +After examining both the reference implementation and HTTP server options, we have two options: #### Option 1: Hook-Based (Reference Implementation Approach) -- Use `git-http-backend` crate as-is +- Use standard Git HTTP backend - Create pre-receive and post-receive hooks - Hooks query the Nostr relay and validate pushes - **Pros**: Follows reference implementation closely @@ -28,7 +28,7 @@ After examining both the reference implementation and the `git-http-backend` Rus **Rationale:** -1. **The `git-http-backend` crate is sufficiently flexible**: Examining `src/actix/git_receive_pack.rs` shows it spawns `git receive-pack` as a subprocess and streams data. We can intercept this. +1. **Full control over HTTP layer**: Using Hyper directly gives us complete control over request handling, WebSocket upgrades, and CORS headers. 2. **Better Developer Experience**: - Validation errors can be returned as proper HTTP responses @@ -56,7 +56,7 @@ After examining both the reference implementation and the `git-http-backend` Rus │ │ │ ┌──────────────────┐ ┌──────────────────┐ │ │ │ HTTP Router │ │ Nostr Relay │ │ -│ │ (actix-web) │ │ (nostr-relay- │ │ +│ │ (Hyper) │ │ (nostr-relay- │ │ │ │ │ │ builder) │ │ │ └────────┬─────────┘ └────────┬─────────┘ │ │ │ │ │ @@ -90,299 +90,149 @@ After examining both the reference implementation and the `git-http-backend` Rus ## Component Design -### 1. Main Server (`src/main.rs`) +### 1. Main Server ([`src/main.rs`](src/main.rs)) **Responsibilities:** -- Initialize configuration from environment -- Set up actix-web HTTP server -- Initialize Nostr relay builder -- Set up shared storage -- Configure routes for both Git and Nostr endpoints +- Initialize configuration from environment (clap + dotenvy) +- Set up Hyper HTTP server with request routing +- Initialize Nostr relay builder with custom [`Nip34WritePolicy`](src/nostr/builder.rs:51) +- Set up shared storage (LMDB, NostrDB, or Memory) +- Handle WebSocket upgrades for Nostr relay - Handle graceful shutdown **Key Dependencies:** ```rust -actix-web = "4" +hyper = "1" tokio = { version = "1", features = ["full"] } nostr-relay-builder = "0.43" nostr-sdk = "0.43" +nostr-lmdb = "0.43" ``` -### 2. Git Module (`src/git/`) +### 2. HTTP Module ([`src/http/mod.rs`](src/http/mod.rs)) -#### `handler.rs` - Git HTTP Handlers +**Responsibilities:** +- Route HTTP requests to appropriate handlers +- WebSocket upgrade for Nostr relay at `/` +- Git Smart HTTP endpoints at `//.git/*` +- Landing pages and NIP-11 document serving +- CORS headers on all responses (GRASP-01 requirement) -Implements actix-web handlers for Git Smart HTTP protocol: +**Key Implementation Details:** ```rust -// GET //.git/info/refs?service=git-upload-pack -async fn info_refs_upload_pack( - req: HttpRequest, - state: web::Data, -) -> Result - -// POST //.git/git-upload-pack -async fn git_upload_pack( - req: HttpRequest, - body: web::Payload, - state: web::Data, -) -> Result - -// GET //.git/info/refs?service=git-receive-pack -async fn info_refs_receive_pack( - req: HttpRequest, - state: web::Data, -) -> Result - -// POST //.git/git-receive-pack -// THIS IS WHERE THE MAGIC HAPPENS -async fn git_receive_pack( - req: HttpRequest, - body: web::Payload, - state: web::Data, -) -> Result +// CORS headers required by GRASP-01 specification +const CORS_ALLOW_ORIGIN: &str = "*"; +const CORS_ALLOW_METHODS: &str = "GET, POST"; +const CORS_ALLOW_HEADERS: &str = "Content-Type"; + +/// Add CORS headers to a response builder +fn add_cors_headers(builder: http::response::Builder) -> http::response::Builder { + builder + .header("Access-Control-Allow-Origin", CORS_ALLOW_ORIGIN) + .header("Access-Control-Allow-Methods", CORS_ALLOW_METHODS) + .header("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS) +} ``` -#### `authorization.rs` - Push Validation +See [`src/http/mod.rs:29-84`](src/http/mod.rs:29-84) for the full CORS implementation. -**Core Logic:** +### 3. Git Module ([`src/git/`](src/git/)) -```rust -pub struct PushValidator { - nostr_client: Arc, - relay_url: String, -} +#### [`handlers.rs`](src/git/handlers.rs) - Git HTTP Handlers -impl PushValidator { - /// Validate a push operation against Nostr state - pub async fn validate_push( - &self, - npub: &str, - identifier: &str, - ref_updates: Vec, - ) -> Result { - // 1. Fetch announcement and state events from local relay - let events = self.fetch_events(identifier).await?; - - // 2. Extract pubkey from npub - let pubkey = decode_npub(npub)?; - - // 3. Get recursive maintainer set - let maintainers = get_maintainers(&events, &pubkey, identifier); - - // 4. Get latest state from maintainers - let state = get_state_from_maintainers(&events, &maintainers)?; - - // 5. Validate each ref update - for ref_update in ref_updates { - if ref_update.ref_name.starts_with("refs/nostr/") { - // Allow refs/nostr/ for PRs - validate_pr_ref(&ref_update)?; - } else if ref_update.ref_name.starts_with("refs/heads/pr/") { - // Reject pr/* branches - should use refs/nostr/ - return Err(Error::InvalidRef("pr/* branches must use refs/nostr/")); - } else { - // Validate against state event - validate_state_ref(&state, &ref_update)?; - } - } - - Ok(ValidationResult::Accept) - } -} -``` - -**Key Functions:** +Implements handlers for Git Smart HTTP protocol: ```rust -/// Parse ref updates from git-receive-pack request body -fn parse_ref_updates(body: &[u8]) -> Result> - -/// Recursively find all maintainers -fn get_maintainers( - events: &[Event], - pubkey: &str, +/// Handle GET /info/refs?service=git-{upload,receive}-pack +pub async fn handle_info_refs( + repo_path: PathBuf, + service: GitService, +) -> Result>, GitError> + +/// Handle POST /git-upload-pack (clone/fetch) +pub async fn handle_upload_pack( + repo_path: PathBuf, + body: Bytes, +) -> Result>, GitError> + +/// Handle POST /git-receive-pack (push) +/// THIS IS WHERE THE MAGIC HAPPENS - validates against state before accepting +pub async fn handle_receive_pack( + repo_path: PathBuf, + body: Bytes, + database: SharedDatabase, + npub: &str, identifier: &str, -) -> Vec - -/// Get latest state from maintainer set -fn get_state_from_maintainers( - events: &[Event], - maintainers: &[String], -) -> Result - -/// Validate a ref matches the state event -fn validate_state_ref( - state: &RepositoryState, - ref_update: &RefUpdate, -) -> Result<()> +) -> Result>, GitError> ``` -### 3. Nostr Module (`src/nostr/`) +See [`src/git/handlers.rs:22-98`](src/git/handlers.rs:22-98) for the info-refs implementation. -#### `relay.rs` - Relay Configuration +#### [`authorization.rs`](src/git/authorization.rs) - Push Validation -```rust -pub async fn build_relay(config: &Config) -> Result { - let builder = RelayBuilder::default() - .write_policy(RepositoryAnnouncementPolicy::new(config.domain.clone())) - .write_policy(RelatedEventsPolicy::new()) - .query_policy(StandardQueryPolicy::new()) - .on_event_saved(create_repository_hook(config.git_data_path.clone())); - - // Configure storage backend (LMDB or NDB) - let relay = LocalRelay::run(builder).await?; - - Ok(relay) -} -``` - -#### `events.rs` - Event Handlers +**Core Logic:** ```rust -/// Hook called when events are saved -pub fn create_repository_hook( - git_data_path: PathBuf, -) -> impl Fn(&Event) -> BoxFuture<'static, ()> { - move |event: &Event| { - let git_path = git_data_path.clone(); - Box::pin(async move { - if event.kind == Kind::RepositoryAnnouncement { - handle_repository_announcement(event, &git_path).await; - } else if event.kind == Kind::RepositoryState { - handle_repository_state(event, &git_path).await; - } - }) - } -} +/// Get authorization info for a repository owner +pub async fn get_authorization_for_owner( + database: &SharedDatabase, + pubkey: &PublicKey, + identifier: &str, +) -> Result -async fn handle_repository_announcement(event: &Event, git_path: &Path) { - // 1. Parse repository from event - // 2. Check if listed in clone and relays tags - // 3. Create empty bare Git repository - // 4. Configure uploadpack.allowTipSHA1InWant - // 5. Configure uploadpack.allowUnreachable - // 6. Configure http.receivepack -} +/// Validate that pushed refs match the authorized state +pub fn validate_push_refs( + pushed_refs: &[PushedRef], + state: &RepositoryState, +) -> Result<(), AuthorizationError> -async fn handle_repository_state(event: &Event, git_path: &Path) { - // 1. Parse state from event - // 2. Update repository HEAD if needed - // 3. Trigger proactive sync (GRASP-02) -} +/// Validate refs/nostr/ pushes +pub fn validate_nostr_ref_pushes( + pushed_refs: &[PushedRef], + database: &SharedDatabase, +) -> Result<(), AuthorizationError> ``` -**Write Policies:** +### 4. Nostr Module ([`src/nostr/`](src/nostr/)) -```rust -/// Accept repository announcements that list this instance -pub struct RepositoryAnnouncementPolicy { - domain: String, -} - -impl WritePolicy for RepositoryAnnouncementPolicy { - fn admit_event(&self, event: &Event, _addr: &SocketAddr) - -> BoxFuture - { - Box::pin(async move { - if event.kind != Kind::RepositoryAnnouncement { - return PolicyResult::Accept; // Not our concern - } - - // Check if this instance is in clone and relays tags - let has_clone = event.tags.iter() - .any(|t| t.kind() == "clone" && t.content() == Some(&self.domain)); - let has_relay = event.tags.iter() - .any(|t| t.kind() == "relays" && t.content() == Some(&self.domain)); - - if has_clone && has_relay { - PolicyResult::Accept - } else { - PolicyResult::Reject("instance not listed in clone and relays".into()) - } - }) - } -} +#### [`builder.rs`](src/nostr/builder.rs) - Relay Configuration -/// Accept events related to stored announcements/issues/patches -pub struct RelatedEventsPolicy; +The [`Nip34WritePolicy`](src/nostr/builder.rs:51) is the core event validation logic: -impl WritePolicy for RelatedEventsPolicy { - fn admit_event(&self, event: &Event, _addr: &SocketAddr) - -> BoxFuture - { - // Accept if event tags or is tagged by stored events - // Implementation requires querying the event store - } +```rust +/// NIP-34 Write Policy with Full GRASP-01 Event Validation +/// +/// Validates all events according to GRASP-01 specification: +/// - Repository announcements must list service in clone and relays tags +/// EXCEPTION: Recursive maintainer announcements are accepted even without +/// listing the service, to enable maintainer chain discovery and GRASP-02 sync +/// - Repository state announcements must have valid structure +/// - Other events must reference accepted repositories or events +/// - Forward references are supported (events referenced by accepted events) +/// - Orphan events with no valid references are rejected +pub struct Nip34WritePolicy { + domain: String, + database: SharedDatabase, + git_data_path: PathBuf, } ``` -### 4. Storage Module (`src/storage/`) +See [`src/nostr/builder.rs:38-78`](src/nostr/builder.rs:38-78) for the full policy struct. -#### `repository.rs` - Repository Management +#### [`events.rs`](src/nostr/events.rs) - Event Parsing + +Provides structures for parsing NIP-34 events: ```rust -pub struct RepositoryManager { - git_data_path: PathBuf, -} +/// Parsed repository announcement (Kind 30617) +pub struct RepositoryAnnouncement { ... } -impl RepositoryManager { - /// Create a new bare Git repository - pub async fn create_repository( - &self, - npub: &str, - identifier: &str, - ) -> Result { - let repo_path = self.git_data_path - .join(npub) - .join(format!("{}.git", identifier)); - - // Create directory - tokio::fs::create_dir_all(&repo_path).await?; - - // Initialize bare repo - Command::new("git") - .args(&["init", "--bare"]) - .arg(&repo_path) - .output() - .await?; - - // Configure - self.configure_repository(&repo_path).await?; - - Ok(repo_path) - } - - async fn configure_repository(&self, repo_path: &Path) -> Result<()> { - // Enable unauthenticated push (we handle auth ourselves) - git_config(repo_path, "http.receivepack", "true").await?; - - // Enable tip SHA1 fetching (required for ngit) - git_config(repo_path, "uploadpack.allowTipSHA1InWant", "true").await?; - - // Enable unreachable object fetching - git_config(repo_path, "uploadpack.allowUnreachable", "true").await?; - - Ok(()) - } - - /// Check if repository exists - pub async fn repository_exists( - &self, - npub: &str, - identifier: &str, - ) -> bool { - let repo_path = self.git_data_path - .join(npub) - .join(format!("{}.git", identifier)); - - repo_path.join("HEAD").exists() && - repo_path.join("config").exists() - } -} +/// Parsed repository state (Kind 30618) +pub struct RepositoryState { ... } ``` -### 5. Configuration (`src/config.rs`) +### 5. Configuration ([`src/config.rs`](src/config.rs)) ```rust pub struct Config { @@ -393,34 +243,18 @@ pub struct Config { pub git_data_path: PathBuf, pub relay_data_path: PathBuf, pub bind_address: SocketAddr, - pub log_level: String, + pub database_backend: DatabaseBackend, } -impl Config { - pub fn from_env() -> Result { - Ok(Config { - domain: env::var("NGIT_DOMAIN")?, - owner_npub: env::var("NGIT_OWNER_NPUB")?, - relay_name: env::var("NGIT_RELAY_NAME")?, - relay_description: env::var("NGIT_RELAY_DESCRIPTION")?, - git_data_path: PathBuf::from( - env::var("NGIT_GIT_DATA_PATH") - .unwrap_or_else(|_| "./data/git".to_string()) - ), - relay_data_path: PathBuf::from( - env::var("NGIT_RELAY_DATA_PATH") - .unwrap_or_else(|_| "./data/relay".to_string()) - ), - bind_address: env::var("NGIT_BIND_ADDRESS") - .unwrap_or_else(|_| "127.0.0.1:8080".to_string()) - .parse()?, - log_level: env::var("RUST_LOG") - .unwrap_or_else(|_| "info".to_string()), - }) - } +pub enum DatabaseBackend { + Lmdb, // Default, production use + NostrDb, // Alternative + Memory, // Testing } ``` +Configuration is loaded via **clap CLI > environment variables > .env > defaults**. + ## Data Flow ### Push Operation Flow @@ -428,327 +262,120 @@ impl Config { ``` 1. Git Client → POST //.git/git-receive-pack ↓ -2. git_receive_pack handler receives request +2. HttpService routes to git::handlers::handle_receive_pack() ↓ -3. Parse ref updates from request body +3. Parse ref updates from request body (pkt-line format) ↓ 4. Extract npub and identifier from URL ↓ -5. PushValidator::validate_push() - ├─ Fetch events from local Nostr relay - ├─ Get maintainers recursively - ├─ Get latest state from maintainers - └─ Validate each ref update +5. authorization::get_authorization_for_owner() + ├─ Query database for announcements + ├─ Build recursive maintainer set + └─ Get latest authorized state + ↓ +6. authorization::validate_push_refs() + ├─ Check each ref matches state + └─ Validate refs/nostr/ pushes ↓ -6. If VALID: +7. If VALID: ├─ Spawn git-receive-pack subprocess ├─ Stream request body to git stdin └─ Stream git stdout back to client ↓ -7. If INVALID: +8. If INVALID: └─ Return HTTP 403 with error message ``` ### Repository Announcement Flow ``` -1. Nostr Client → EVENT (Kind 30317) +1. Nostr Client → EVENT (Kind 30617) ↓ 2. Nostr relay receives event ↓ -3. RepositoryAnnouncementPolicy::admit_event() +3. Nip34WritePolicy::admit_event() ├─ Check if instance in clone tags ├─ Check if instance in relays tags + ├─ OR: Check if recursive maintainer └─ Accept or reject ↓ 4. If ACCEPTED: - ├─ Event saved to store - └─ on_event_saved hook triggered + ├─ Event saved to database + └─ ensure_bare_repository() called ↓ -5. handle_repository_announcement() - ├─ Parse repository details - ├─ Create Git repository directory - ├─ Initialize bare Git repo - └─ Configure Git settings -``` - -## Key Implementation Details - -### 1. Parsing Git Receive-Pack Protocol - -The Git receive-pack protocol uses a pkt-line format. We need to parse: - -``` -0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/main\0 report-status -0000-0000-0000-0000 0000-0000-0000-0000 refs/heads/dev -``` - -Each line has: -- Old SHA (40 hex chars) -- Space -- New SHA (40 hex chars) -- Space -- Ref name -- Optional capabilities (first line only, after \0) - -```rust -pub struct RefUpdate { - pub old_sha: String, - pub new_sha: String, - pub ref_name: String, -} - -pub fn parse_ref_updates(body: &[u8]) -> Result> { - // Parse pkt-line format - // Extract ref updates - // Return structured data -} +5. Bare Git repository created at + //.git ``` -### 2. Maintainer Recursion - -The maintainer resolution must handle cycles and correctly build the set: +### State Event Flow -```rust -fn get_maintainers_recursive( - events: &[Event], - pubkey: &str, - identifier: &str, - visited: &mut HashSet, -) -> HashSet { - if visited.contains(pubkey) { - return HashSet::new(); - } - - visited.insert(pubkey.to_string()); - - let announcement = find_announcement(events, pubkey, identifier); - if announcement.is_none() { - return HashSet::new(); - } - - let repo = parse_repository(announcement.unwrap()); - - for maintainer in repo.maintainers { - get_maintainers_recursive(events, &maintainer, identifier, visited); - } - - visited.clone() -} ``` - -### 3. State Event Validation - -```rust -fn validate_state_ref( - state: &RepositoryState, - ref_update: &RefUpdate, -) -> Result<()> { - if ref_update.ref_name.starts_with("refs/heads/") { - let branch_name = &ref_update.ref_name[11..]; - if let Some(commit) = state.branches.get(branch_name) { - if commit == &ref_update.new_sha { - return Ok(()); - } - return Err(Error::StateMismatch { - ref_name: ref_update.ref_name.clone(), - expected: commit.clone(), - got: ref_update.new_sha.clone(), - }); - } - return Err(Error::RefNotInState(ref_update.ref_name.clone())); - } - - if ref_update.ref_name.starts_with("refs/tags/") { - let tag_name = &ref_update.ref_name[10..]; - if let Some(commit) = state.tags.get(tag_name) { - if commit == &ref_update.new_sha { - return Ok(()); - } - return Err(Error::StateMismatch { - ref_name: ref_update.ref_name.clone(), - expected: commit.clone(), - got: ref_update.new_sha.clone(), - }); - } - return Err(Error::RefNotInState(ref_update.ref_name.clone())); - } - - Err(Error::InvalidRef(ref_update.ref_name.clone())) -} -``` - -### 4. CORS Support - -As per GRASP-01, we must support CORS: - -```rust -use actix_cors::Cors; - -fn configure_cors() -> Cors { - Cors::default() - .allow_any_origin() - .allowed_methods(vec!["GET", "POST", "OPTIONS"]) - .allowed_headers(vec!["Content-Type"]) - .max_age(3600) -} - -// In main.rs -App::new() - .wrap(configure_cors()) - .configure(git_routes) - .configure(nostr_routes) +1. Nostr Client → EVENT (Kind 30618) + ↓ +2. Nostr relay receives event + ↓ +3. Nip34WritePolicy::admit_event() + ├─ Check author is in maintainer set + ├─ Validate state structure + └─ Accept or reject + ↓ +4. If ACCEPTED and is latest state: + ├─ Align repository refs to match state + ├─ Create/update/delete refs as needed + └─ Set HEAD if commit available ``` ## Testing Strategy -See [TEST_STRATEGY.md](TEST_STRATEGY.md) for comprehensive testing documentation, including: - -- **GRASP Compliance Testing Tool**: Reusable test suite that validates any GRASP implementation against the spec -- **Spec-Mirrored Tests**: Test structure matches GRASP protocol documents exactly -- **Clear Failure Messages**: Test failures cite exact spec lines (e.g., "GRASP-01:12-13") -- **Multiple Test Levels**: Unit, integration, compliance, and end-to-end tests +See [test-strategy.md](../reference/test-strategy.md) for comprehensive testing documentation. ### Quick Overview -```rust -// Unit Tests - Individual functions -#[test] -fn test_parse_ref_updates() { - let body = b"0000... 0000... refs/heads/main\0report-status\n"; - let updates = parse_ref_updates(body).unwrap(); - assert_eq!(updates.len(), 1); - assert_eq!(updates[0].ref_name, "refs/heads/main"); -} +**Integration Tests** ([`tests/`](tests/)): +- Use [`TestRelay`](tests/common/relay.rs:14) fixture for automatic relay lifecycle +- Each test file in [`tests/`](tests/) covers a GRASP-01 requirement -// Integration Tests - Component interaction -#[tokio::test] -async fn test_full_push_flow() { - let app = test_app().await; - let (announcement, state) = app.create_repo_with_state() - .branch("main", "commit-123") - .build() - .await; - - let result = app.git_push("main", "commit-123").await; - assert!(result.success); -} +**Audit Tests** ([`grasp-audit/`](grasp-audit/)): +- Reusable compliance testing for any GRASP implementation +- Spec-mirrored structure in [`grasp-audit/src/specs/grasp01/`](grasp-audit/src/specs/grasp01/) -// Compliance Tests - GRASP spec validation +```rust +// Example: tests/nip01_compliance.rs #[tokio::test] -async fn test_grasp_01_compliance() { - use grasp_compliance_tests::{TestContext, Grasp01Spec}; - - let ctx = TestContext::builder() - .base_url(&server.url()) - .build(); - - let results = Grasp01Spec::test_compliance(&ctx).await; - assert!(results.all_passed(), "{}", results.report()); +async fn test_nip01_websocket_connection() { + let relay = TestRelay::start().await; + // Test NIP-01 compliance... + relay.stop().await; } ``` -The compliance testing tool is designed as a **standalone crate** that can be: -- Used by ngit-grasp for self-validation -- Published for other GRASP implementations to use -- Updated as new GRASP specs are released -- Run in CI/CD for continuous compliance verification - ## Performance Considerations ### 1. Async All The Way - Use `tokio` for all I/O -- Non-blocking Git subprocess spawning +- Non-blocking Git subprocess spawning via [`GitSubprocess`](src/git/subprocess.rs) - Stream large pack files without buffering -### 2. Connection Pooling - -- Reuse Nostr relay connections -- Connection pool for internal relay queries - -### 3. Caching +### 2. Shared Database -- Cache parsed state events (with TTL) -- Cache maintainer sets -- Invalidate on new state events +- Single database instance shared between relay and Git handlers +- Direct queries for push authorization (no WebSocket round-trip) -```rust -pub struct StateCache { - cache: Arc>>, -} - -struct CachedState { - state: RepositoryState, - maintainers: Vec, - timestamp: Instant, -} +### 3. Write Policy Caching -impl StateCache { - pub async fn get_or_fetch( - &self, - identifier: &str, - fetcher: impl Future)>>, - ) -> Result<(RepositoryState, Vec)> { - // Check cache - // Return if fresh - // Otherwise fetch and cache - } -} -``` +- Maintainer sets computed once per event validation +- State lookups use database indexes ## Future Extensions ### GRASP-02: Proactive Sync -Add background tasks: - -```rust -pub struct ProactiveSyncTask { - relay_client: Client, - git_manager: RepositoryManager, -} - -impl ProactiveSyncTask { - pub async fn run(&self) { - loop { - tokio::time::sleep(Duration::from_secs(3600)).await; - - // Fetch all announcements from our relay - let announcements = self.fetch_announcements().await; - - for ann in announcements { - // Sync events from listed relays - self.sync_events(&ann).await; - - // Sync git data from listed clones - self.sync_git_data(&ann).await; - - // Fetch PR data - self.sync_pr_data(&ann).await; - } - } - } -} -``` +See [grasp-02-proactive-sync.md](grasp-02-proactive-sync.md) for detailed design. ### GRASP-05: Archive -Relax the policy: - -```rust -pub struct ArchiveAnnouncementPolicy; - -impl WritePolicy for ArchiveAnnouncementPolicy { - fn admit_event(&self, event: &Event, _addr: &SocketAddr) - -> BoxFuture - { - // Accept all repository announcements - // Don't check clone/relays tags - PolicyResult::Accept - } -} -``` +Relax the write policy to accept all repository announcements regardless of clone/relays tags. ## Deployment @@ -756,7 +383,7 @@ impl WritePolicy for ArchiveAnnouncementPolicy { ```bash cargo build --release -./target/release/ngit-grasp +./target/release/ngit-grasp --domain example.com --owner-npub npub1... ``` ### Docker @@ -799,10 +426,17 @@ WantedBy=multi-user.target 2. **Path Traversal**: Prevent directory traversal in repository paths 3. **DoS Protection**: Rate limiting on both HTTP and WebSocket 4. **Resource Limits**: Limit pack file sizes, event sizes -5. **Nostr Event Validation**: Strict signature verification +5. **Nostr Event Validation**: Strict signature verification (handled by nostr-relay-builder) ## Conclusion -The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. The Rust ecosystem provides excellent libraries for both Git and Nostr protocols, enabling a high-performance, type-safe implementation. +The inline authorization approach provides a cleaner, more maintainable architecture than hook-based authorization while maintaining full GRASP-01 compliance. Using Hyper for the HTTP layer gives us complete control over request handling, WebSocket upgrades, and CORS headers. The key insight is that we don't need to rely on Git's hook mechanism when we have full control over the HTTP layer that Git operates through. By intercepting at the HTTP handler level, we gain better error handling, easier testing, and tighter integration between the Git and Nostr components. + +## Related Documentation + +- [Inline Authorization Explanation](inline-authorization.md) - Why we chose this approach +- [GRASP-02 Proactive Sync](grasp-02-proactive-sync.md) - Future work design +- [Test Strategy](../reference/test-strategy.md) - Comprehensive testing documentation +- [GRASP-01 Implementation Learnings](../learnings/grasp-01-implementation.md) - Patterns and lessons learned \ No newline at end of file diff --git a/docs/explanation/inline-authorization.md b/docs/explanation/inline-authorization.md index 98f6e5a..4538602 100644 --- a/docs/explanation/inline-authorization.md +++ b/docs/explanation/inline-authorization.md @@ -150,14 +150,17 @@ rm -rf /tmp/test-repo ```rust #[tokio::test] async fn test_unauthorized_push() { - let state = create_test_state().await; + let relay = TestRelay::start().await; let result = validate_push(&state, "refs/heads/main", alice_pubkey).await; assert!(result.is_err()); + relay.stop().await; } ``` **Result:** Pure Rust unit tests, no shell scripts, no Git setup. +See [`tests/push_authorization.rs`](tests/push_authorization.rs) for actual test examples. + ### 4. Shared State and Types **With hooks:** @@ -168,19 +171,17 @@ async fn test_unauthorized_push() { **With inline authorization:** ```rust -pub struct GitHandler { - nostr_relay: Arc, // Shared! - state_cache: Arc, // Shared! -} - -impl GitHandler { - async fn validate_push(&self, refs: &[RefUpdate]) -> Result<()> { - // Direct access to Nostr state - let state = self.state_cache.get_latest().await?; - // Validate using shared types - state.validate_refs(refs)?; - Ok(()) - } +// From src/git/handlers.rs +pub async fn handle_receive_pack( + repo_path: PathBuf, + body: Bytes, + database: SharedDatabase, // Shared with Nostr relay! + npub: &str, + identifier: &str, +) -> Result>, GitError> { + // Direct database access for authorization + let auth = get_authorization_for_owner(&database, pubkey, identifier).await?; + // ... } ``` @@ -208,9 +209,9 @@ Setup steps: **With inline authorization (ngit-grasp):** ``` Single Rust binary: - - HTTP server (actix-web) + - HTTP server (Hyper) - Git protocol handler - - Nostr relay + - Nostr relay (nostr-relay-builder) - Authorization logic Setup steps: @@ -235,44 +236,38 @@ Content-Type: application/x-git-receive-pack-request 0000000000000000000000000000000000000000 abc123... refs/heads/main\0 report-status ``` -We parse this **before** spawning Git: +We parse this **before** spawning Git. See [`src/git/authorization.rs`](src/git/authorization.rs) for the implementation: ```rust -pub async fn git_receive_pack( - req: HttpRequest, - body: web::Bytes, -) -> Result { - // 1. Parse ref updates from request body - let ref_updates = parse_ref_updates(&body)?; - - // 2. Validate against Nostr state - let state = get_latest_state(&repo).await?; - validate_push(&state, &ref_updates).await?; - - // 3. If valid, spawn git-receive-pack - spawn_git_receive_pack(req, body).await +/// Parse ref updates from git-receive-pack request body +pub fn parse_pushed_refs(body: &[u8]) -> Result, AuthorizationError> { + // Parse pkt-line format + // Extract ref updates + // Return structured data } ``` ### How We Validate -Validation checks: +Validation checks (from [`src/git/authorization.rs`](src/git/authorization.rs)): + 1. Does pusher's pubkey have write access? 2. Are they listed as a maintainer in the latest state event? -3. Do maintainer sets form a valid chain? +3. Do the refs match the state event? ```rust -async fn validate_push( - state: &RepoState, - refs: &[RefUpdate], -) -> Result<()> { - for ref_update in refs { - // Check if pusher is authorized for this ref - if !state.is_authorized(&ref_update.name, pusher_pubkey) { - return Err(Error::Unauthorized { - ref_name: ref_update.name.clone(), - pubkey: pusher_pubkey, - }); +/// Validate that pushed refs match the authorized state +pub fn validate_push_refs( + pushed_refs: &[PushedRef], + state: &RepositoryState, +) -> Result<(), AuthorizationError> { + for pushed_ref in pushed_refs { + if pushed_ref.ref_name.starts_with("refs/heads/") { + // Validate branch against state + } else if pushed_ref.ref_name.starts_with("refs/tags/") { + // Validate tag against state + } else if pushed_ref.ref_name.starts_with("refs/nostr/") { + // Allow refs/nostr/ for PRs } } Ok(()) @@ -291,7 +286,7 @@ async fn validate_push( | **Performance** | Spawns Git first | Validates first | | **Testing** | Shell scripts + Go tests | Pure Rust tests | | **Deployment** | Docker + supervisord | Single binary | -| **State sharing** | WebSocket query | Direct memory access | +| **State sharing** | WebSocket query | Direct database access | Both are GRASP-compliant, but inline authorization is simpler and more efficient. @@ -314,34 +309,25 @@ Both are GRASP-compliant, but inline authorization is simpler and more efficient ### Is It Worth It? **Yes**, because: -1. The `git-http-backend` crate handles protocol parsing +1. We handle protocol parsing in [`src/git/protocol.rs`](src/git/protocol.rs) 2. GRASP is already non-standard (Nostr authorization) 3. Benefits far outweigh the coupling cost 4. We can still add hook support later if needed --- -## Alternative Considered: Hybrid Approach - -We could use **both** inline validation and hooks: - -```rust -// Inline: Fast path for common cases -if !quick_validate(pusher).await? { - return Err(Error::Unauthorized); -} - -// Hook: Detailed validation -spawn_git_with_hook().await?; -``` +## Implementation References -**Why we didn't choose this:** -- Added complexity -- Redundant validation -- Slower (two validation steps) -- Harder to maintain +Key files in the ngit-grasp implementation: -If inline validation is sufficient, why add hooks? +| Component | Location | +|-----------|----------| +| HTTP routing | [`src/http/mod.rs`](src/http/mod.rs) | +| Git handlers | [`src/git/handlers.rs`](src/git/handlers.rs) | +| Push authorization | [`src/git/authorization.rs`](src/git/authorization.rs) | +| Git protocol parsing | [`src/git/protocol.rs`](src/git/protocol.rs) | +| Subprocess management | [`src/git/subprocess.rs`](src/git/subprocess.rs) | +| Event acceptance policy | [`src/nostr/builder.rs:51`](src/nostr/builder.rs:51) - `Nip34WritePolicy` | --- @@ -365,9 +351,8 @@ This would allow: ### If Git Protocol Changes -The `git-http-backend` crate abstracts protocol details. If the Git protocol changes: -- Update the crate dependency -- Adjust our ref parsing if needed +The protocol parsing is isolated in [`src/git/protocol.rs`](src/git/protocol.rs). If the Git protocol changes: +- Update the protocol module - Tests will catch any breakage --- @@ -380,11 +365,11 @@ The `git-http-backend` crate abstracts protocol details. If the Git protocol cha 2. It's more performant (early rejection) 3. It's easier to test (pure Rust) 4. It's simpler to deploy (single binary) -5. It enables better integration (shared state) +5. It enables better integration (shared database) The trade-off (coupling to Git HTTP protocol) is acceptable because: - The protocol is stable and well-specified -- The `git-http-backend` crate abstracts details +- Protocol handling is isolated in one module - Benefits far outweigh the cost This decision aligns with our goal of creating a **developer-friendly, production-ready GRASP implementation**. @@ -397,7 +382,8 @@ This decision aligns with our goal of creating a **developer-friendly, productio - [Design Decisions](decisions.md) - All architectural choices - [Comparison with ngit-relay](comparison.md) - Detailed comparison - [Git Protocol Reference](../reference/git-protocol.md) - Protocol details +- [Test Strategy](../reference/test-strategy.md) - How we test this --- -*Part of the [ngit-grasp explanation docs](./)* +*Part of the [ngit-grasp explanation docs](./)* \ No newline at end of file -- cgit v1.2.3